mirror of
https://github.com/apache/superset.git
synced 2026-09-09 00:34:49 +00:00
Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0dbef703ad | ||
|
|
afc2ae2760 | ||
|
|
c09ec3f8b6 | ||
|
|
9939c8f577 | ||
|
|
0d025daaf7 | ||
|
|
c3109899e2 | ||
|
|
6956da1de5 | ||
|
|
c1822666da | ||
|
|
581ef8b7c1 | ||
|
|
0636ac4b11 | ||
|
|
99a910b81c | ||
|
|
635de27b25 | ||
|
|
e1ce6b601c | ||
|
|
db60f25ee9 | ||
|
|
297b0455f6 | ||
|
|
b1c33e5435 | ||
|
|
b925246bef | ||
|
|
592d378c0d | ||
|
|
935ee5f1a6 | ||
|
|
d9b201db74 |
@@ -0,0 +1,71 @@
|
||||
# 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.
|
||||
name: Check OpenAPI spec drift
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- "master"
|
||||
- "[0-9].[0-9]*"
|
||||
pull_request:
|
||||
types: [synchronize, opened, reopened, ready_for_review]
|
||||
|
||||
# No `paths:` filter on purpose, matching enforce-single-migration-head: a
|
||||
# required check that never runs for a given PR blocks that PR forever. The
|
||||
# job is ~10s, so it fires on every PR rather than guessing which file edits
|
||||
# can move the spec.
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
check-openapi-spec-drift:
|
||||
runs-on: ubuntu-26.04
|
||||
steps:
|
||||
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Setup Python
|
||||
uses: ./.github/actions/setup-backend/
|
||||
with:
|
||||
# base.txt pins apispec, which decides the generated output: 6.10.0
|
||||
# renders marshmallow 4's unknown=RAISE as "additionalProperties":
|
||||
# false while the pinned 6.6.1 does not. Regenerating off-pin
|
||||
# produces a spec no CI run can reproduce.
|
||||
requirements-type: base
|
||||
- name: Regenerate the spec
|
||||
env:
|
||||
# No SUPERSET_CONFIG_PATH: the published spec documents the routes a
|
||||
# default deployment registers. A config enabling feature flags adds
|
||||
# paths that would 404 for everyone who has not enabled them.
|
||||
SUPERSET__SQLALCHEMY_DATABASE_URI: "sqlite:///:memory:"
|
||||
FLASK_APP: "superset.app:create_app()"
|
||||
run: superset update-api-docs
|
||||
- name: Assert the published spec is up to date
|
||||
run: |
|
||||
if ! git diff --quiet -- docs/static/resources/openapi.json; then
|
||||
echo "::error::docs/static/resources/openapi.json is stale."
|
||||
echo "Regenerate it on the pinned requirements, with no config file:"
|
||||
echo " SUPERSET__SQLALCHEMY_DATABASE_URI='sqlite:///:memory:' \\"
|
||||
echo " FLASK_APP='superset.app:create_app()' superset update-api-docs"
|
||||
git diff --stat -- docs/static/resources/openapi.json
|
||||
exit 1
|
||||
fi
|
||||
@@ -139,6 +139,27 @@ The following URL parameters can be passed through the `urlParams` option in `da
|
||||
|
||||
---
|
||||
|
||||
## Embedding a Single Chart
|
||||
|
||||
Individual charts can also be embedded standalone, outside of a dashboard, using a chart permalink. From Explore, generate a permalink for the chart, then append URL parameters to it:
|
||||
|
||||
```
|
||||
/explore/p/<permalink-key>/?standalone=1&show_download=1
|
||||
```
|
||||
|
||||
| Parameter | Values | Effect |
|
||||
| --------------- | -------- | -------------------------------------------------------------------------- |
|
||||
| `standalone` | `0`, `1` | `0`: normal Explore view; `1`: hide the Explore header and controls |
|
||||
| `show_download` | `0`, `1` | Show a compact download control (CSV, JSON, Excel) on the standalone chart |
|
||||
|
||||
`show_download` is opt-in and has no effect outside of `standalone=1`. The download control still respects the viewer's export permissions — it's hidden for users who lack them even when the parameter is set. With the `GRANULAR_EXPORT_CONTROLS` feature flag enabled, this requires the `can_export_data` permission on `Superset`; otherwise it falls back to `can_csv` on `Superset`.
|
||||
|
||||
Explore's **Embed Code** button on a chart also generates one of these permalink URLs wrapped in an `<iframe>`, giving you a session-authenticated iframe embed. That's a separate flow from dashboard embedding above: it doesn't go through `@superset-ui/embedded-sdk`, guest tokens, or the `dashboardUiConfig` options — the viewer needs an existing Superset session with the `can read on Explore` permission, in addition to access to the chart itself. Viewing the same chart on a dashboard doesn't require that permission, so a role scoped only for dashboard viewing gets an access denial on this URL.
|
||||
|
||||
One caveat when the host page lives on a different site than Superset: the default `SESSION_COOKIE_SAMESITE = "Lax"` setting keeps the session cookie out of cross-site iframe requests, so the viewer lands on the login page instead of the chart. Serving both from the same site avoids this; otherwise set `SESSION_COOKIE_SAMESITE = "None"` together with `SESSION_COOKIE_SECURE = True` and allow the host origin in your `TALISMAN_CONFIG` `frame-ancestors`. Even with that configuration, browsers that block third-party cookies by default (including Safari) can still redirect the viewer to the login page. For those cases, host the iframe on the same site as Superset, or use the dashboard embedding SDK's guest-token flow above instead.
|
||||
|
||||
---
|
||||
|
||||
## Security Notes
|
||||
|
||||
- **Guest tokens expire** — their lifetime is controlled by the `GUEST_TOKEN_JWT_EXP_SECONDS` config (default: 5 minutes). Refresh tokens before they expire using a token refresh mechanism in your host app.
|
||||
|
||||
+1
-1
@@ -61,7 +61,7 @@
|
||||
"@storybook/addon-docs": "^10.5.10",
|
||||
"@superset-ui/core": "^0.20.4",
|
||||
"@swc/core": "^1.16.1",
|
||||
"antd": "^6.6.1",
|
||||
"antd": "^6.6.2",
|
||||
"baseline-browser-mapping": "^2.11.20",
|
||||
"caniuse-lite": "^1.0.30001810",
|
||||
"docusaurus-plugin-openapi-docs": "^5.2.0",
|
||||
|
||||
Vendored
+110
-46
@@ -13719,6 +13719,111 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"chart_get_list_schema": {
|
||||
"properties": {
|
||||
"columns": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"filters": {
|
||||
"items": {
|
||||
"properties": {
|
||||
"col": {
|
||||
"type": "string"
|
||||
},
|
||||
"opr": {
|
||||
"type": "string"
|
||||
},
|
||||
"value": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"items": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "boolean"
|
||||
}
|
||||
]
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"col",
|
||||
"opr",
|
||||
"value"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"keys": {
|
||||
"items": {
|
||||
"enum": [
|
||||
"list_columns",
|
||||
"order_columns",
|
||||
"label_columns",
|
||||
"description_columns",
|
||||
"list_title",
|
||||
"none"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"order_column": {
|
||||
"type": "string"
|
||||
},
|
||||
"order_direction": {
|
||||
"enum": [
|
||||
"asc",
|
||||
"desc"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"page": {
|
||||
"type": "integer"
|
||||
},
|
||||
"page_size": {
|
||||
"type": "integer"
|
||||
},
|
||||
"select_columns": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"viz_type_order": {
|
||||
"description": "Visualization type slugs in display-name order. Used only when order_column is viz_type.",
|
||||
"items": {
|
||||
"maxLength": 250,
|
||||
"type": "string"
|
||||
},
|
||||
"maxItems": 256,
|
||||
"type": "array",
|
||||
"uniqueItems": true
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"database_catalogs_query_schema": {
|
||||
"properties": {
|
||||
"force": {
|
||||
@@ -15680,10 +15785,11 @@
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/get_list_schema"
|
||||
"$ref": "#/components/schemas/chart_get_list_schema"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Rison-encoded list query. viz_type_order may contain up to 256 unique visualization type slugs, each at most 250 characters, in the display-name order to use when sorting by viz_type.",
|
||||
"in": "query",
|
||||
"name": "q"
|
||||
}
|
||||
@@ -15695,57 +15801,15 @@
|
||||
"schema": {
|
||||
"properties": {
|
||||
"count": {
|
||||
"description": "The total record count on the backend",
|
||||
"type": "number"
|
||||
},
|
||||
"description_columns": {
|
||||
"properties": {
|
||||
"column_name": {
|
||||
"description": "The description for the column name. Will be translated by babel",
|
||||
"example": "A Nice description for the column",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
"type": "integer"
|
||||
},
|
||||
"ids": {
|
||||
"description": "A list of item ids, useful when you don't know the column id",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"label_columns": {
|
||||
"properties": {
|
||||
"column_name": {
|
||||
"description": "The label for the column name. Will be translated by babel",
|
||||
"example": "A Nice label for the column",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"list_columns": {
|
||||
"description": "A list of columns",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"list_title": {
|
||||
"description": "A title to render. Will be translated by babel",
|
||||
"example": "List Items",
|
||||
"type": "string"
|
||||
},
|
||||
"order_columns": {
|
||||
"description": "A list of allowed columns to sort",
|
||||
"items": {
|
||||
"type": "string"
|
||||
"type": "integer"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"result": {
|
||||
"description": "The result from the get list query",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/ChartRestApi.get_list"
|
||||
},
|
||||
@@ -15756,7 +15820,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Items from Model"
|
||||
"description": "Charts"
|
||||
},
|
||||
"400": {
|
||||
"$ref": "#/components/responses/400"
|
||||
|
||||
+44
-44
@@ -3687,21 +3687,21 @@
|
||||
"@rc-component/virtual-list" "^1.4.0"
|
||||
clsx "^2.1.1"
|
||||
|
||||
"@rc-component/mentions@~1.11.0":
|
||||
version "1.11.0"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/mentions/-/mentions-1.11.0.tgz#cee0c4710f26766ad8550d386cfec5ff86fd58d9"
|
||||
integrity sha512-IC2qXuEBMFHxPIXEFfYWj6Sr7UiDZnOqJHCYQBbwPzopBJOPZIR6mV9U4QH1bYQRlKYlYnIsajWDMgVGgWQyWQ==
|
||||
"@rc-component/mentions@~1.12.0":
|
||||
version "1.12.0"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/mentions/-/mentions-1.12.0.tgz#4c939e22ad8bc82e2936bdf8427132bc367dc66d"
|
||||
integrity sha512-v5MBx8zDcqCT+uybJgKpcx3Fgrvj0m3u+A/15RlSBaMx12lLoJZTTYggC3pbYYfohcKujWWUyBz6O6Gp2vGiNA==
|
||||
dependencies:
|
||||
"@rc-component/input" "~1.3.1"
|
||||
"@rc-component/menu" "~1.4.0"
|
||||
"@rc-component/menu" "~1.5.0"
|
||||
"@rc-component/trigger" "^3.0.0"
|
||||
"@rc-component/util" "^1.11.1"
|
||||
clsx "^2.1.1"
|
||||
|
||||
"@rc-component/menu@~1.4.0", "@rc-component/menu@~1.4.1":
|
||||
version "1.4.1"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/menu/-/menu-1.4.1.tgz#aa20b6d6087f5ddd23d4a21b0ccce35b98988c8e"
|
||||
integrity sha512-3GsVRoQ4cnF/AoIQ4P+Z1haBfgfBPQfLT1RJY3Nu4DzOnheTslfCiGSPj7bv/cLj5sW5pHqN25dDXGP3JELAlQ==
|
||||
"@rc-component/menu@~1.5.0":
|
||||
version "1.5.0"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/menu/-/menu-1.5.0.tgz#53599217a012d5f9087e02ba71cff856e9cba05b"
|
||||
integrity sha512-RjvzPsZkwVEg3xu+/jDDqDeUCBJAnWylONFlGeQgg5enlNAnlpybqhaMGE/UPEIvr6hL90PGhjFBguJqj0Qb2Q==
|
||||
dependencies:
|
||||
"@rc-component/motion" "^1.1.4"
|
||||
"@rc-component/overflow" "^1.0.0"
|
||||
@@ -3731,10 +3731,10 @@
|
||||
dependencies:
|
||||
"@rc-component/util" "^1.2.0"
|
||||
|
||||
"@rc-component/notification@~2.0.7":
|
||||
version "2.0.7"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/notification/-/notification-2.0.7.tgz#f2450a482f87e4698285833c4a8efcac169acabb"
|
||||
integrity sha512-nqZzpf6BPdaj+3ILx7si79LLmqPKyUmQoXa+/9gg0SkH0v1DbD66oJgRMSBEVnd/zUT3D4gwxWIHUKebYf2ZXQ==
|
||||
"@rc-component/notification@~2.0.8":
|
||||
version "2.0.8"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/notification/-/notification-2.0.8.tgz#27bfd803306c7215384e0781c75343216495b15a"
|
||||
integrity sha512-MTRA3b8eHE14gh5R9nlXzbWZEDEBFVkEHj/FI1VOXw2zrSnSxqbuuU3drsq+k53e6MHwtegoFay1tnNYhtl61Q==
|
||||
dependencies:
|
||||
"@rc-component/motion" "^1.1.4"
|
||||
"@rc-component/util" "^1.11.0"
|
||||
@@ -3777,12 +3777,12 @@
|
||||
"@rc-component/util" "^1.11.0"
|
||||
clsx "^2.1.1"
|
||||
|
||||
"@rc-component/progress@~1.0.2":
|
||||
version "1.0.2"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/progress/-/progress-1.0.2.tgz#9aba5e24d3ca73a61a451fd041f5d03ca8907c62"
|
||||
integrity sha512-WZUnH9eGxH1+xodZKqdrHke59uyGZSWgj5HBM5Kwk5BrTMuAORO7VJ2IP5Qbm9aH3n9x3IcesqHHR0NWPBC7fQ==
|
||||
"@rc-component/progress@~1.0.3":
|
||||
version "1.0.3"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/progress/-/progress-1.0.3.tgz#e2bddab0e6bccdbe6b87a313efa9daab346273f5"
|
||||
integrity sha512-Q1nPLIvKr95el/q76yaBh5nv1hqCsWaHQb8U7vXEDMefEhHgM7n3CPoN+o4LHnTyANQHmMFgoS/naAT4Bhxuaw==
|
||||
dependencies:
|
||||
"@rc-component/util" "^1.2.1"
|
||||
"@rc-component/util" "^1.11.1"
|
||||
clsx "^2.1.1"
|
||||
|
||||
"@rc-component/qrcode@~2.0.0":
|
||||
@@ -3836,12 +3836,12 @@
|
||||
"@rc-component/util" "^1.3.0"
|
||||
clsx "^2.1.1"
|
||||
|
||||
"@rc-component/steps@~1.2.2":
|
||||
version "1.2.2"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/steps/-/steps-1.2.2.tgz#8440329540e987ccaed252e008972d0b63723d6f"
|
||||
integrity sha512-/yVIZ00gDYYPHSY0JP+M+s3ZvuXLu2f9rEjQqiUDs7EcYsUYrpJ/1bLj9aI9R7MBR3fu/NGh6RM9u2qGfqp+Nw==
|
||||
"@rc-component/steps@~1.2.3":
|
||||
version "1.2.3"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/steps/-/steps-1.2.3.tgz#e423f501da8b03a93e97b4800977f59777457d14"
|
||||
integrity sha512-/b9gHcvDGjTDWJZW7+1kKFvzs0jrEeQ8mFvvIgX+h7xNT0FsOYODJYKjyJR4fPbvMBCfCOEqaBK66HBxov5BQA==
|
||||
dependencies:
|
||||
"@rc-component/util" "^1.2.1"
|
||||
"@rc-component/util" "^1.11.1"
|
||||
clsx "^2.1.1"
|
||||
|
||||
"@rc-component/switch@~1.0.3":
|
||||
@@ -3863,22 +3863,22 @@
|
||||
"@rc-component/virtual-list" "^1.0.1"
|
||||
clsx "^2.1.1"
|
||||
|
||||
"@rc-component/tabs@~1.12.0":
|
||||
version "1.12.0"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/tabs/-/tabs-1.12.0.tgz#41a1a77ed1afc4f1b8b727003a058c631aceea1b"
|
||||
integrity sha512-XL7Kqy5fnUE2WTlO1/fCGrrfNlGFebdr7JseGkEIjzcVMAtIFQJ8sqCSOmxcXstjU6fonD/4rnhZHxj7sDTajQ==
|
||||
"@rc-component/tabs@~1.13.0":
|
||||
version "1.13.0"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/tabs/-/tabs-1.13.0.tgz#22dd1ac7b96a81e26b39eac1fea6ffdb5e90b58f"
|
||||
integrity sha512-3FXr+9ZII8tFLWuDWrgjRaxZd2mazkKJAGRFEfVmp4r1qnBglOlxQ78l54Td1aj6/iFaaEReKSAF2HToztU5uw==
|
||||
dependencies:
|
||||
"@rc-component/dropdown" "~1.0.0"
|
||||
"@rc-component/menu" "~1.4.0"
|
||||
"@rc-component/menu" "~1.5.0"
|
||||
"@rc-component/motion" "^1.1.3"
|
||||
"@rc-component/resize-observer" "^1.0.0"
|
||||
"@rc-component/util" "^1.11.1"
|
||||
clsx "^2.1.1"
|
||||
|
||||
"@rc-component/tooltip@~1.5.0":
|
||||
version "1.5.0"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/tooltip/-/tooltip-1.5.0.tgz#422aa0760b310e0a1d0f9f7223e7f0d455de57a2"
|
||||
integrity sha512-agQ/+mBqrEQfTX4D3KhQ7j+ZbX4/VHjoJ7Noa2wIdZ1/FbQTOd7Sn92rp+jtCoqAVTLUgSOydePIgZ204gi2EQ==
|
||||
"@rc-component/tooltip@~1.5.2":
|
||||
version "1.5.2"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/tooltip/-/tooltip-1.5.2.tgz#616366cd797626b08fb134458468dd489d4949da"
|
||||
integrity sha512-VLq4BclU3LgRe46g2hTr2bdsM8tFYs7dVwpuD88rPNNg4lCGe8mBSS4FiRzYzS+ffnZ46y4SI8KYKo9K7qBnhw==
|
||||
dependencies:
|
||||
"@rc-component/trigger" "^3.10.0"
|
||||
"@rc-component/util" "^1.11.1"
|
||||
@@ -3933,7 +3933,7 @@
|
||||
"@rc-component/util" "^1.11.1"
|
||||
clsx "^2.1.1"
|
||||
|
||||
"@rc-component/util@^1.11.0", "@rc-component/util@^1.11.1", "@rc-component/util@^1.12.0", "@rc-component/util@^1.2.0", "@rc-component/util@^1.2.1", "@rc-component/util@^1.3.0", "@rc-component/util@^1.3.1", "@rc-component/util@^1.4.0", "@rc-component/util@^1.7.0", "@rc-component/util@^1.9.0":
|
||||
"@rc-component/util@^1.11.0", "@rc-component/util@^1.11.1", "@rc-component/util@^1.12.0", "@rc-component/util@^1.2.0", "@rc-component/util@^1.3.0", "@rc-component/util@^1.3.1", "@rc-component/util@^1.4.0", "@rc-component/util@^1.7.0", "@rc-component/util@^1.9.0":
|
||||
version "1.12.0"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/util/-/util-1.12.0.tgz#58e453585810bcb8a35ff1aafd5e01187457b86f"
|
||||
integrity sha512-AEjPL8JVdohIITaiXokyjL9WQ6tKWWjAYK9QU16tGNE9JaQABBQy+hA4H2Lup5MgXy9yY3iLrbZJheuU13hTdQ==
|
||||
@@ -6213,10 +6213,10 @@ ansis@^3.2.0:
|
||||
resolved "https://registry.yarnpkg.com/ansis/-/ansis-3.17.0.tgz#fa8d9c2a93fe7d1177e0c17f9eeb562a58a832d7"
|
||||
integrity sha512-0qWUglt9JEqLFr3w1I1pbrChn1grhaiAR2ocX1PP/flRmxgtwTzPFFFnfIlD6aMOLQZgSuCRlidD70lvx8yhzg==
|
||||
|
||||
antd@^6.6.1:
|
||||
version "6.6.1"
|
||||
resolved "https://registry.yarnpkg.com/antd/-/antd-6.6.1.tgz#3235d76413b525b1f3287b87bdaf6ba0e7148521"
|
||||
integrity sha512-QHIHYoUk9N9nJy1T9fyxWKjY0qApdTEDd/6lzqYng8Uryv9FejNmbhKvYF7obGqB+TuLXQsPVF7fOVgyzM1KrQ==
|
||||
antd@^6.6.2:
|
||||
version "6.6.2"
|
||||
resolved "https://registry.yarnpkg.com/antd/-/antd-6.6.2.tgz#f111adec5c5b53c97e3fbc3af71b21aea9938a4a"
|
||||
integrity sha512-aTOPWXsqfWrlSiI0a1qR9UWR4jBCTJ8FNZfZmYQP9/aGGLJSrw9/c5uE+4vSVSt3riQlIPAwR3BcAgEHmE7GMg==
|
||||
dependencies:
|
||||
"@ant-design/colors" "^8.0.1"
|
||||
"@ant-design/cssinjs" "^2.1.2"
|
||||
@@ -6237,25 +6237,25 @@ antd@^6.6.1:
|
||||
"@rc-component/input" "~1.3.1"
|
||||
"@rc-component/input-number" "~1.6.2"
|
||||
"@rc-component/listy" "~1.2.3"
|
||||
"@rc-component/mentions" "~1.11.0"
|
||||
"@rc-component/menu" "~1.4.1"
|
||||
"@rc-component/mentions" "~1.12.0"
|
||||
"@rc-component/menu" "~1.5.0"
|
||||
"@rc-component/motion" "^1.3.3"
|
||||
"@rc-component/mutate-observer" "^2.0.1"
|
||||
"@rc-component/notification" "~2.0.7"
|
||||
"@rc-component/notification" "~2.0.8"
|
||||
"@rc-component/pagination" "~1.4.0"
|
||||
"@rc-component/picker" "~1.12.0"
|
||||
"@rc-component/progress" "~1.0.2"
|
||||
"@rc-component/progress" "~1.0.3"
|
||||
"@rc-component/qrcode" "~2.0.0"
|
||||
"@rc-component/rate" "~1.0.1"
|
||||
"@rc-component/resize-observer" "^1.1.2"
|
||||
"@rc-component/segmented" "~1.3.0"
|
||||
"@rc-component/select" "~1.10.1"
|
||||
"@rc-component/slider" "~1.1.1"
|
||||
"@rc-component/steps" "~1.2.2"
|
||||
"@rc-component/steps" "~1.2.3"
|
||||
"@rc-component/switch" "~1.0.3"
|
||||
"@rc-component/table" "~1.11.1"
|
||||
"@rc-component/tabs" "~1.12.0"
|
||||
"@rc-component/tooltip" "~1.5.0"
|
||||
"@rc-component/tabs" "~1.13.0"
|
||||
"@rc-component/tooltip" "~1.5.2"
|
||||
"@rc-component/tour" "~2.4.0"
|
||||
"@rc-component/tree" "~1.4.0"
|
||||
"@rc-component/tree-select" "~1.16.1"
|
||||
|
||||
+8
-5
@@ -44,7 +44,7 @@ dependencies = [
|
||||
# without the ``base.txt`` lock file (#40962).
|
||||
"cachetools>=7.1.7, <8",
|
||||
"celery>=5.6.3, <6.0.0",
|
||||
"click>=8.4.2",
|
||||
"click>=8.5.0",
|
||||
"click-option-group",
|
||||
"colorama",
|
||||
"flask-cors>=6.0.5, <7.0",
|
||||
@@ -83,7 +83,7 @@ dependencies = [
|
||||
"marshmallow>=3.0, <5",
|
||||
"marshmallow-union>=0.1.15.post1",
|
||||
"msgpack>=1.2.0, <1.3",
|
||||
"nh3>=0.3.5, <0.4",
|
||||
"nh3>=0.3.7, <0.4",
|
||||
"numpy>=1.23.5, <2.5",
|
||||
"packaging",
|
||||
# --------------------------
|
||||
@@ -109,11 +109,14 @@ dependencies = [
|
||||
|
||||
"shillelagh[gsheetsapi]>=1.4.5, <2.0",
|
||||
"sshtunnel>=0.4.0, <0.5",
|
||||
"simplejson>=4.1.1",
|
||||
"simplejson>=4.1.2",
|
||||
"slack_sdk>=3.43.0, <4",
|
||||
"sqlalchemy>=2.0.52, <2.1",
|
||||
"sqlalchemy-continuum>=1.6.0, <2.0.0",
|
||||
"sqlalchemy-utils>=0.42.1, <0.43", # expanding lowerbound to work with pydoris
|
||||
# Dialect-specific gaps/bugs against this pin are worked around in
|
||||
# superset/sql/dialects/ (e.g. starrocks.py); check there for anything
|
||||
# that can be cleaned up when bumping
|
||||
"sqlglot>=30.17.0, <31", # 30.16.0 adds Trino inline UDF IF/CASE routine statement parsing
|
||||
# newer pandas needs 0.9+
|
||||
"tabulate>=0.10.0, <1.0",
|
||||
@@ -135,7 +138,7 @@ athena = ["pyathena[pandas]>=3.35.4, <4"]
|
||||
# superset/db_engine_specs/aurora.py's known_incompatibilities metadata.
|
||||
aurora-data-api = ["preset-sqlalchemy-aurora-data-api>=0.2.8,<0.3"]
|
||||
bigquery = [
|
||||
"pandas-gbq>=0.35.1",
|
||||
"pandas-gbq>=0.35.2",
|
||||
# 1.17.1 is likely the final release: googleapis/python-bigquery-sqlalchemy
|
||||
# was archived 2026-05-16. Both 1.17.0 and 1.17.1 support SQLAlchemy 1.4/2.0.
|
||||
"sqlalchemy-bigquery>=1.17.2",
|
||||
@@ -210,7 +213,7 @@ firebird = ["sqlalchemy-firebird>=2.2.0"]
|
||||
firebolt = ["firebolt-sqlalchemy>=1.1.2, <2"]
|
||||
gevent = ["gevent>=26.8.0"]
|
||||
gsheets = ["shillelagh[gsheetsapi]>=1.4.5, <2"]
|
||||
hana = ["hdbcli==2.29.25", "sqlalchemy_hana==3.0.3"]
|
||||
hana = ["hdbcli==2.29.27", "sqlalchemy_hana==3.0.3"]
|
||||
hive = [
|
||||
"pyhive[hive_pure_sasl]>=0.7.0",
|
||||
"tableschema",
|
||||
|
||||
@@ -58,7 +58,7 @@ cffi==2.0.0
|
||||
# pynacl
|
||||
charset-normalizer==3.4.2
|
||||
# via requests
|
||||
click==8.4.2
|
||||
click==8.5.0
|
||||
# via
|
||||
# apache-superset (pyproject.toml)
|
||||
# celery
|
||||
@@ -240,7 +240,7 @@ msgpack==1.2.1
|
||||
# via apache-superset (pyproject.toml)
|
||||
msgspec==0.19.0
|
||||
# via flask-session
|
||||
nh3==0.3.6
|
||||
nh3==0.3.7
|
||||
# via apache-superset (pyproject.toml)
|
||||
numexpr==2.10.2
|
||||
# via -r requirements/base.in
|
||||
@@ -370,7 +370,7 @@ setuptools==84.0.0
|
||||
# via -r requirements/base.in
|
||||
shillelagh==1.4.5
|
||||
# via apache-superset (pyproject.toml)
|
||||
simplejson==4.1.1
|
||||
simplejson==4.1.2
|
||||
# via apache-superset (pyproject.toml)
|
||||
six==1.17.0
|
||||
# via
|
||||
|
||||
@@ -131,7 +131,7 @@ charset-normalizer==3.4.2
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# requests
|
||||
click==8.4.2
|
||||
click==8.5.0
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
@@ -569,7 +569,7 @@ msgspec==0.19.0
|
||||
# flask-session
|
||||
mysqlclient==2.2.8
|
||||
# via apache-superset
|
||||
nh3==0.3.6
|
||||
nh3==0.3.7
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
@@ -639,7 +639,7 @@ pandas==2.3.3
|
||||
# db-dtypes
|
||||
# pandas-gbq
|
||||
# prophet
|
||||
pandas-gbq==0.35.1
|
||||
pandas-gbq==0.35.2
|
||||
# via apache-superset
|
||||
parameterized==0.9.0
|
||||
# via apache-superset
|
||||
@@ -932,7 +932,7 @@ shillelagh==1.4.5
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
simplejson==4.1.1
|
||||
simplejson==4.1.2
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
|
||||
Generated
+932
-30
File diff suppressed because it is too large
Load Diff
@@ -294,7 +294,7 @@
|
||||
"@types/unzipper": "^0.10.11",
|
||||
"@typescript-eslint/eslint-plugin": "^8.68.0",
|
||||
"@typescript-eslint/parser": "^8.63.0",
|
||||
"babel-jest": "^30.4.1",
|
||||
"babel-jest": "^30.5.0",
|
||||
"babel-loader": "^10.1.1",
|
||||
"babel-plugin-dynamic-import-node": "^2.3.3",
|
||||
"babel-plugin-jsx-remove-data-test-id": "^3.0.0",
|
||||
@@ -321,7 +321,7 @@
|
||||
"html-webpack-plugin": "^5.6.8",
|
||||
"imports-loader": "^5.0.0",
|
||||
"jest": "^30.4.2",
|
||||
"jest-environment-jsdom": "^30.4.1",
|
||||
"jest-environment-jsdom": "^30.5.0",
|
||||
"jest-html-reporter": "^4.4.0",
|
||||
"jest-websocket-mock": "^2.5.0",
|
||||
"js-yaml-loader": "^1.2.2",
|
||||
|
||||
-115
@@ -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.
|
||||
*/
|
||||
import { fireEvent, render, screen, userEvent } from '@superset-ui/core/spec';
|
||||
import { useState } from 'react';
|
||||
import { DynamicEditableTitle } from '.';
|
||||
|
||||
const Harness = ({ initialTitle = 'Original' }: { initialTitle?: string }) => {
|
||||
const [title, setTitle] = useState(initialTitle);
|
||||
return (
|
||||
<DynamicEditableTitle
|
||||
title={title}
|
||||
placeholder="placeholder"
|
||||
canEdit
|
||||
label="Title"
|
||||
onSave={setTitle}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
test('rapid typing then backspacing keeps every keystroke', async () => {
|
||||
render(<Harness />);
|
||||
const input = screen.getByRole('textbox') as HTMLInputElement;
|
||||
userEvent.click(input);
|
||||
await userEvent.type(input, 'abc', { delay: 1 });
|
||||
expect(input.value).toBe('Originalabc');
|
||||
await userEvent.type(input, '{backspace}{backspace}{backspace}', {
|
||||
delay: 1,
|
||||
});
|
||||
expect(input.value).toBe('Original');
|
||||
});
|
||||
|
||||
test('a change event that arrives before isEditing flips is not dropped', () => {
|
||||
// Reproduces the regression: the input is focused but `isEditing` is still
|
||||
// false because no click has been registered yet (e.g. focus arrived via
|
||||
// tab, autofocus, or programmatic focus). The pre-fix `handleChange`
|
||||
// bailed out with `!isEditing`, dropping the keystroke. Because the
|
||||
// input is controlled, antd's internal `useMergedState` then resyncs the
|
||||
// DOM value back to the (stale) `props.value`, so the user sees their
|
||||
// typed character disappear. This test fires a raw change event so it
|
||||
// doesn't go through userEvent's implicit click.
|
||||
const onSave = jest.fn();
|
||||
render(
|
||||
<DynamicEditableTitle
|
||||
title="Foo"
|
||||
placeholder="placeholder"
|
||||
canEdit
|
||||
label="Title"
|
||||
onSave={onSave}
|
||||
/>,
|
||||
);
|
||||
const input = screen.getByRole('textbox') as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { value: 'FooX' } });
|
||||
expect(input.value).toBe('FooX');
|
||||
});
|
||||
|
||||
test('prop changes mid-edit do not clobber unsaved typing', async () => {
|
||||
// Rerender DynamicEditableTitle directly with a changed title prop so the
|
||||
// sync effect actually runs. Going through Harness would not exercise the
|
||||
// bug because Harness owns its own state and only reads initialTitle once.
|
||||
const onSave = jest.fn();
|
||||
const props = {
|
||||
placeholder: 'placeholder',
|
||||
canEdit: true,
|
||||
label: 'Title',
|
||||
onSave,
|
||||
};
|
||||
const { rerender } = render(<DynamicEditableTitle {...props} title="Foo" />);
|
||||
const input = screen.getByRole('textbox') as HTMLInputElement;
|
||||
userEvent.click(input);
|
||||
await userEvent.type(input, 'X', { delay: 1 });
|
||||
expect(input.value).toBe('FooX');
|
||||
rerender(<DynamicEditableTitle {...props} title="Bar" />);
|
||||
expect(input.value).toBe('FooX');
|
||||
// Locks in commit semantics: blur after a real edit must persist the
|
||||
// user's typed value, even when a competing parent-driven title arrived
|
||||
// mid-edit.
|
||||
fireEvent.blur(input);
|
||||
expect(onSave).toHaveBeenCalledWith('FooX');
|
||||
});
|
||||
|
||||
test('passive focus then parent-driven title change then blur does not revert', () => {
|
||||
// Phantom-revert scenario: user clicks the input but does not type, the
|
||||
// parent autosaves a new title from elsewhere, then the user blurs. The
|
||||
// component must NOT call onSave with the stale local value, otherwise it
|
||||
// would silently overwrite the parent's update.
|
||||
const onSave = jest.fn();
|
||||
const props = {
|
||||
placeholder: 'placeholder',
|
||||
canEdit: true,
|
||||
label: 'Title',
|
||||
onSave,
|
||||
};
|
||||
const { rerender } = render(<DynamicEditableTitle {...props} title="Foo" />);
|
||||
const input = screen.getByRole('textbox') as HTMLInputElement;
|
||||
userEvent.click(input);
|
||||
rerender(<DynamicEditableTitle {...props} title="Bar" />);
|
||||
fireEvent.blur(input);
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
});
|
||||
+150
-34
@@ -16,10 +16,20 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { render, screen, userEvent } from '@superset-ui/core/spec';
|
||||
import {
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
userEvent,
|
||||
waitFor,
|
||||
} from '@superset-ui/core/spec';
|
||||
import { useState } from 'react';
|
||||
import { DynamicEditableTitle } from '.';
|
||||
import type { DynamicEditableTitleProps } from './types';
|
||||
|
||||
const createProps = (overrides: Record<string, any> = {}) => ({
|
||||
const createProps = (
|
||||
overrides: Partial<DynamicEditableTitleProps> = {},
|
||||
): DynamicEditableTitleProps => ({
|
||||
title: 'Chart title',
|
||||
placeholder: 'Add the name of the chart',
|
||||
canEdit: true,
|
||||
@@ -28,41 +38,147 @@ const createProps = (overrides: Record<string, any> = {}) => ({
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('Chart editable title', () => {
|
||||
test('renders chart title', () => {
|
||||
const props = createProps();
|
||||
render(<DynamicEditableTitle {...props} />);
|
||||
expect(screen.getByText('Chart title')).toBeVisible();
|
||||
const makeRect = (width: number): DOMRect => ({
|
||||
bottom: 0,
|
||||
height: 0,
|
||||
left: 0,
|
||||
right: width,
|
||||
top: 0,
|
||||
width,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => ({}),
|
||||
});
|
||||
|
||||
const Harness = ({ initialTitle = 'Original' }: { initialTitle?: string }) => {
|
||||
const [title, setTitle] = useState(initialTitle);
|
||||
return (
|
||||
<DynamicEditableTitle
|
||||
title={title}
|
||||
placeholder="placeholder"
|
||||
canEdit
|
||||
label="Title"
|
||||
onSave={setTitle}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
test('renders chart title', () => {
|
||||
const props = createProps();
|
||||
render(<DynamicEditableTitle {...props} />);
|
||||
expect(screen.getByText('Chart title')).toBeVisible();
|
||||
});
|
||||
|
||||
test('renders placeholder', () => {
|
||||
const props = createProps({
|
||||
title: '',
|
||||
});
|
||||
render(<DynamicEditableTitle {...props} />);
|
||||
expect(screen.getByText('Add the name of the chart')).toBeVisible();
|
||||
});
|
||||
|
||||
test('click, edit and save title', async () => {
|
||||
const props = createProps();
|
||||
render(<DynamicEditableTitle {...props} />);
|
||||
const textboxElement = screen.getByRole('textbox');
|
||||
await userEvent.click(textboxElement);
|
||||
await userEvent.type(textboxElement, ' edited');
|
||||
expect(screen.getByText('Chart title edited')).toBeVisible();
|
||||
await userEvent.type(textboxElement, '{enter}');
|
||||
expect(props.onSave).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('renders in non-editable mode', async () => {
|
||||
const props = createProps({ canEdit: false });
|
||||
render(<DynamicEditableTitle {...props} />);
|
||||
const titleElement = screen.getByLabelText('Chart title');
|
||||
const inputElement = screen.getByRole('textbox');
|
||||
expect(inputElement).toBeDisabled();
|
||||
expect(titleElement).toBeVisible();
|
||||
await userEvent.click(titleElement);
|
||||
await userEvent.type(titleElement, ' edited{enter}');
|
||||
expect(props.onSave).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('rapid typing then backspacing keeps every keystroke', async () => {
|
||||
render(<Harness />);
|
||||
const input = screen.getByRole('textbox') as HTMLInputElement;
|
||||
await userEvent.click(input);
|
||||
await userEvent.type(input, 'abc', { delay: 1 });
|
||||
expect(input.value).toBe('Originalabc');
|
||||
await userEvent.type(input, '{backspace}{backspace}{backspace}', {
|
||||
delay: 1,
|
||||
});
|
||||
expect(input.value).toBe('Original');
|
||||
});
|
||||
|
||||
test('renders placeholder', () => {
|
||||
const props = createProps({
|
||||
title: '',
|
||||
test('a change event that arrives before edit mode is committed is not dropped', () => {
|
||||
const onSave = jest.fn();
|
||||
render(
|
||||
<DynamicEditableTitle
|
||||
title="Foo"
|
||||
placeholder="placeholder"
|
||||
canEdit
|
||||
label="Title"
|
||||
onSave={onSave}
|
||||
/>,
|
||||
);
|
||||
const input = screen.getByRole('textbox') as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { value: 'FooX' } });
|
||||
expect(input.value).toBe('FooX');
|
||||
});
|
||||
|
||||
test('prop changes mid-edit do not clobber unsaved typing', async () => {
|
||||
const onSave = jest.fn();
|
||||
const props = {
|
||||
placeholder: 'placeholder',
|
||||
canEdit: true,
|
||||
label: 'Title',
|
||||
onSave,
|
||||
};
|
||||
const { rerender } = render(<DynamicEditableTitle {...props} title="Foo" />);
|
||||
const input = screen.getByRole('textbox') as HTMLInputElement;
|
||||
await userEvent.click(input);
|
||||
await userEvent.type(input, 'X', { delay: 1 });
|
||||
expect(input.value).toBe('FooX');
|
||||
rerender(<DynamicEditableTitle {...props} title="Bar" />);
|
||||
expect(input.value).toBe('FooX');
|
||||
fireEvent.blur(input);
|
||||
expect(onSave).toHaveBeenCalledWith('FooX');
|
||||
});
|
||||
|
||||
test('passive focus then parent-driven title change then blur does not revert', async () => {
|
||||
const onSave = jest.fn();
|
||||
const props = {
|
||||
placeholder: 'placeholder',
|
||||
canEdit: true,
|
||||
label: 'Title',
|
||||
onSave,
|
||||
};
|
||||
const { rerender } = render(<DynamicEditableTitle {...props} title="Foo" />);
|
||||
const input = screen.getByRole('textbox') as HTMLInputElement;
|
||||
await userEvent.click(input);
|
||||
rerender(<DynamicEditableTitle {...props} title="Bar" />);
|
||||
fireEvent.blur(input);
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('rounds fractional title measurements up when sizing the input', async () => {
|
||||
const getBoundingClientRect = jest
|
||||
.spyOn(HTMLElement.prototype, 'getBoundingClientRect')
|
||||
.mockImplementation(function getRect(this: HTMLElement) {
|
||||
return this.classList.contains('input-sizer')
|
||||
? makeRect(280.31)
|
||||
: makeRect(0);
|
||||
});
|
||||
render(<DynamicEditableTitle {...props} />);
|
||||
expect(screen.getByText('Add the name of the chart')).toBeVisible();
|
||||
});
|
||||
|
||||
test('click, edit and save title', async () => {
|
||||
const props = createProps();
|
||||
render(<DynamicEditableTitle {...props} />);
|
||||
const textboxElement = screen.getByRole('textbox');
|
||||
await userEvent.click(textboxElement);
|
||||
await userEvent.type(textboxElement, ' edited');
|
||||
expect(screen.getByText('Chart title edited')).toBeVisible();
|
||||
await userEvent.type(textboxElement, '{enter}');
|
||||
expect(props.onSave).toHaveBeenCalled();
|
||||
});
|
||||
try {
|
||||
render(<DynamicEditableTitle {...createProps({ title: 'Trends' })} />);
|
||||
|
||||
test('renders in non-editable mode', async () => {
|
||||
const props = createProps({ canEdit: false });
|
||||
render(<DynamicEditableTitle {...props} />);
|
||||
const titleElement = screen.getByLabelText('Chart title');
|
||||
const inputElement = screen.getByRole('textbox');
|
||||
expect(inputElement).toBeDisabled();
|
||||
expect(titleElement).toBeVisible();
|
||||
await userEvent.click(titleElement);
|
||||
await userEvent.type(titleElement, ' edited{enter}');
|
||||
expect(props.onSave).not.toHaveBeenCalled();
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('textbox')).toHaveStyle({ width: '281px' });
|
||||
});
|
||||
} finally {
|
||||
getBoundingClientRect.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
+10
-13
@@ -34,6 +34,9 @@ import { Input } from '../Input';
|
||||
import type { InputRef } from '../Input';
|
||||
import type { DynamicEditableTitleProps } from './types';
|
||||
|
||||
const measureWidth = (element: HTMLElement) =>
|
||||
Math.ceil(element.getBoundingClientRect().width);
|
||||
|
||||
const titleStyles = (theme: SupersetTheme) => css`
|
||||
display: flex;
|
||||
font-size: ${theme.fontSizeXL}px;
|
||||
@@ -117,14 +120,14 @@ export const DynamicEditableTitle = memo(
|
||||
// a trick to make the input grow when user types text
|
||||
// we make an additional span component, place it somewhere out of view and
|
||||
// mirror the input value, then measure the span synchronously (pre-paint)
|
||||
// to resize the input element. Reading offsetWidth in a useLayoutEffect
|
||||
// forces a sync layout, so the input width updates in the same commit as
|
||||
// the value change — preventing a flicker frame where the input is shown
|
||||
// with new value but stale width.
|
||||
// to resize the input element. Measuring in a useLayoutEffect forces a
|
||||
// sync layout, so the input width updates in the same commit as the value
|
||||
// change — preventing a flicker frame where the input is shown with new
|
||||
// value but stale width.
|
||||
useLayoutEffect(() => {
|
||||
if (sizerRef.current) {
|
||||
sizerRef.current.textContent = currentTitle || placeholder;
|
||||
setInputWidth(sizerRef.current.offsetWidth);
|
||||
setInputWidth(measureWidth(sizerRef.current));
|
||||
}
|
||||
}, [currentTitle, placeholder]);
|
||||
|
||||
@@ -135,7 +138,7 @@ export const DynamicEditableTitle = memo(
|
||||
let cancelled = false;
|
||||
document.fonts?.ready?.then(() => {
|
||||
if (!cancelled && sizerRef.current) {
|
||||
setInputWidth(sizerRef.current.offsetWidth);
|
||||
setInputWidth(measureWidth(sizerRef.current));
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
@@ -236,6 +239,7 @@ export const DynamicEditableTitle = memo(
|
||||
onClick={handleClick}
|
||||
onPressEnter={handleKeyPress}
|
||||
placeholder={placeholder}
|
||||
style={inputWidth > 0 ? { width: inputWidth } : undefined}
|
||||
css={css`
|
||||
${
|
||||
!canEdit &&
|
||||
@@ -246,13 +250,6 @@ export const DynamicEditableTitle = memo(
|
||||
}
|
||||
font-size: ${theme.fontSizeXL}px;
|
||||
transition: auto;
|
||||
${
|
||||
inputWidth &&
|
||||
inputWidth > 0 &&
|
||||
css`
|
||||
width: ${inputWidth}px;
|
||||
`
|
||||
}
|
||||
`}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
|
||||
+60
@@ -290,6 +290,66 @@ test('observes extra control height changes when ResizeObserver is available', a
|
||||
expect(disconnectSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('uses the post-control body height for compact custom-legend visibility', async () => {
|
||||
mockOffsetHeight = 40;
|
||||
const { queryByTestId } = render(
|
||||
<EchartsTimeseries
|
||||
{...defaultProps}
|
||||
height={140}
|
||||
echartOptions={{
|
||||
grid: { bottom: 80, containLabel: true, top: 20 },
|
||||
}}
|
||||
formData={{ ...defaultFormData, zoomable: true }}
|
||||
customLegend={
|
||||
{
|
||||
grid: { bottom: 80, top: 20 },
|
||||
items: Array.from({ length: 20 }, (_, index) => ({
|
||||
color: '#123456',
|
||||
interactive: true,
|
||||
name: `Series ${index}`,
|
||||
selected: true,
|
||||
})),
|
||||
orientation: LegendOrientation.Top,
|
||||
showSelectors: true,
|
||||
} as never
|
||||
}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(queryByTestId('timeseries-custom-legend')).not.toBeInTheDocument();
|
||||
expect(getLatestHeight()).toBe(100);
|
||||
expect(getLatestEchartProps().echartOptions.grid).toEqual({
|
||||
bottom: 80,
|
||||
containLabel: false,
|
||||
top: 12,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('keeps a no-legend grid within a very small post-control body', async () => {
|
||||
mockOffsetHeight = 40;
|
||||
render(
|
||||
<EchartsTimeseries
|
||||
{...defaultProps}
|
||||
height={50}
|
||||
echartOptions={{
|
||||
grid: { bottom: 37, containLabel: false, top: 12 },
|
||||
}}
|
||||
formData={{ ...defaultFormData, zoomable: true }}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getLatestHeight()).toBe(10);
|
||||
expect(getLatestEchartProps().echartOptions.grid).toEqual({
|
||||
bottom: 0,
|
||||
containLabel: false,
|
||||
top: 9,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('falls back to window resize listener when ResizeObserver is unavailable', async () => {
|
||||
(globalThis as { ResizeObserver?: typeof ResizeObserver }).ResizeObserver =
|
||||
undefined;
|
||||
|
||||
+112
-13
@@ -26,6 +26,7 @@ import {
|
||||
getColumnLabel,
|
||||
getNumberFormatter,
|
||||
LegendState,
|
||||
WithLegend,
|
||||
ensureIsArray,
|
||||
createTimeRangeFromGranularity,
|
||||
} from '@superset-ui/core';
|
||||
@@ -40,17 +41,50 @@ import type GlobalModel from 'echarts/types/src/model/Global';
|
||||
import type ComponentModel from 'echarts/types/src/model/Component';
|
||||
import { EchartsHandler, EventHandlers } from '../types';
|
||||
import Echart from '../components/Echart';
|
||||
import {
|
||||
getViableTimeseriesEchartOptions,
|
||||
resolveTimeseriesGridOffset,
|
||||
} from './transformers';
|
||||
import {
|
||||
rebaseSeriesData,
|
||||
snapToNearestX,
|
||||
SeriesDataPoint,
|
||||
} from './percentChange';
|
||||
import { OrientationType, TimeseriesChartTransformedProps } from './types';
|
||||
import {
|
||||
OrientationType,
|
||||
TimeseriesChartTransformedProps,
|
||||
TimeseriesCustomLegend,
|
||||
} from './types';
|
||||
import { formatSeriesName } from '../utils/series';
|
||||
import { getTemporalXAxisDrillByFilter } from '../utils/xAxisDrillByFilter';
|
||||
import { ExtraControls } from '../components/ExtraControls';
|
||||
import TimeseriesLegend from './TimeseriesLegend';
|
||||
import { TIMESERIES_CONSTANTS } from '../constants';
|
||||
|
||||
const TIMER_DURATION = 300;
|
||||
const MAX_CUSTOM_LEGEND_HEIGHT = 160;
|
||||
const MAX_CUSTOM_LEGEND_HEIGHT_RATIO = 0.3;
|
||||
const MIN_TIMESERIES_PLOT_HEIGHT = 80;
|
||||
|
||||
// Bound the legend after accounting for the fixed ECharts grid reservations,
|
||||
// leaving enough coordinate space for the plot itself to remain usable.
|
||||
export const getTimeseriesLegendMaxHeight = (
|
||||
chartBodyHeight: number,
|
||||
grid: TimeseriesCustomLegend['grid'],
|
||||
) =>
|
||||
Math.min(
|
||||
MAX_CUSTOM_LEGEND_HEIGHT,
|
||||
Math.floor(Math.max(chartBodyHeight, 0) * MAX_CUSTOM_LEGEND_HEIGHT_RATIO),
|
||||
Math.max(
|
||||
Math.floor(
|
||||
chartBodyHeight -
|
||||
resolveTimeseriesGridOffset(grid.top, chartBodyHeight) -
|
||||
resolveTimeseriesGridOffset(grid.bottom, chartBodyHeight) -
|
||||
MIN_TIMESERIES_PLOT_HEIGHT,
|
||||
),
|
||||
0,
|
||||
),
|
||||
);
|
||||
const getTimestampFromTimeAxisValue = (value: string | number) => {
|
||||
if (typeof value === 'number') {
|
||||
return Number.isFinite(value) ? value : undefined;
|
||||
@@ -84,6 +118,7 @@ const BASELINE_HANDLE_STRIPE_WIDTH = 2;
|
||||
export default function EchartsTimeseries({
|
||||
formData,
|
||||
height,
|
||||
customLegend,
|
||||
width,
|
||||
echartOptions,
|
||||
groupby,
|
||||
@@ -775,23 +810,87 @@ export default function EchartsTimeseries({
|
||||
},
|
||||
};
|
||||
|
||||
const dispatchLegendAction = useCallback(
|
||||
(action: { name?: string; seriesName?: string; type: string }) => {
|
||||
echartRef.current?.getEchartInstance()?.dispatchAction(action);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const chartBodyHeight = Math.max(height - extraControlHeight, 0);
|
||||
const customLegendMaxHeight = customLegend
|
||||
? getTimeseriesLegendMaxHeight(chartBodyHeight, customLegend.grid)
|
||||
: 0;
|
||||
const shouldRenderCustomLegend =
|
||||
customLegend !== undefined &&
|
||||
chartBodyHeight > TIMESERIES_CONSTANTS.compactChartHeight &&
|
||||
customLegendMaxHeight > 0;
|
||||
const chartEchartOptions = useMemo(
|
||||
() =>
|
||||
getViableTimeseriesEchartOptions(
|
||||
echartOptions,
|
||||
chartBodyHeight,
|
||||
formData.zoomable,
|
||||
),
|
||||
[chartBodyHeight, echartOptions, formData.zoomable],
|
||||
);
|
||||
const renderEchart = ({
|
||||
chartHeight,
|
||||
chartWidth,
|
||||
}: {
|
||||
chartHeight: number;
|
||||
chartWidth: number;
|
||||
}) => (
|
||||
<Echart
|
||||
ref={echartRef}
|
||||
refs={refs}
|
||||
height={chartHeight}
|
||||
width={chartWidth}
|
||||
echartOptions={chartEchartOptions}
|
||||
eventHandlers={eventHandlers}
|
||||
queryEventHandlers={queryEventHandlers}
|
||||
zrEventHandlers={zrEventHandlers}
|
||||
selectedValues={selectedValues}
|
||||
vizType={formData.vizType}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div ref={extraControlRef}>
|
||||
<ExtraControls formData={formData} setControlValue={setControlValue} />
|
||||
</div>
|
||||
<Echart
|
||||
ref={echartRef}
|
||||
refs={refs}
|
||||
height={height - extraControlHeight}
|
||||
width={width}
|
||||
echartOptions={echartOptions}
|
||||
eventHandlers={eventHandlers}
|
||||
queryEventHandlers={queryEventHandlers}
|
||||
zrEventHandlers={zrEventHandlers}
|
||||
selectedValues={selectedValues}
|
||||
vizType={formData.vizType}
|
||||
/>
|
||||
{customLegend && shouldRenderCustomLegend ? (
|
||||
<WithLegend
|
||||
height={chartBodyHeight}
|
||||
position={customLegend.orientation}
|
||||
width={width}
|
||||
renderLegend={() => (
|
||||
<TimeseriesLegend
|
||||
{...customLegend}
|
||||
maxHeight={customLegendMaxHeight}
|
||||
onAll={() => dispatchLegendAction({ type: 'legendAllSelect' })}
|
||||
onHover={name =>
|
||||
dispatchLegendAction({
|
||||
seriesName: name ?? undefined,
|
||||
type: name === null ? 'downplay' : 'highlight',
|
||||
})
|
||||
}
|
||||
onInverse={() =>
|
||||
dispatchLegendAction({ type: 'legendInverseSelect' })
|
||||
}
|
||||
onToggle={name =>
|
||||
dispatchLegendAction({ name, type: 'legendToggleSelect' })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
renderChart={({ height: chartHeight, width: chartWidth }) =>
|
||||
renderEchart({ chartHeight, chartWidth })
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
renderEchart({ chartHeight: chartBodyHeight, chartWidth: width })
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* 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 { memo } from 'react';
|
||||
import { styled } from '@apache-superset/core/theme';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import type { TimeseriesCustomLegend } from './types';
|
||||
|
||||
const LegendViewport = styled.div<{ maxHeight: number }>`
|
||||
${({ maxHeight, theme }) => `
|
||||
box-sizing: border-box;
|
||||
color: ${theme.colorText};
|
||||
font-size: ${theme.fontSizeSM}px;
|
||||
max-height: ${maxHeight}px;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
padding: ${theme.sizeUnit}px ${theme.sizeUnit * 2}px;
|
||||
width: 100%;
|
||||
`}
|
||||
`;
|
||||
|
||||
const SelectorRow = styled.div`
|
||||
${({ theme }) => `
|
||||
align-items: center;
|
||||
background: ${theme.colorBgContainer};
|
||||
display: flex;
|
||||
gap: ${theme.sizeUnit}px;
|
||||
justify-content: flex-end;
|
||||
padding-bottom: ${theme.sizeUnit}px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
`}
|
||||
`;
|
||||
|
||||
const SelectorButton = styled.button`
|
||||
${({ theme }) => `
|
||||
background: transparent;
|
||||
border: 1px solid ${theme.colorBorder};
|
||||
border-radius: ${theme.borderRadius}px;
|
||||
color: ${theme.colorText};
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
padding: 0 ${theme.sizeUnit}px;
|
||||
|
||||
&:hover {
|
||||
color: ${theme.colorPrimary};
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
const ItemList = styled.ul`
|
||||
${({ theme }) => `
|
||||
align-items: flex-start;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: ${theme.sizeUnit * 2}px;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
`}
|
||||
`;
|
||||
|
||||
const LegendItem = styled.li`
|
||||
min-width: 0;
|
||||
`;
|
||||
|
||||
const ItemButton = styled.button<{ selected: boolean }>`
|
||||
${({ selected, theme }) => `
|
||||
align-items: flex-start;
|
||||
appearance: none;
|
||||
background: none;
|
||||
border: none;
|
||||
color: ${selected ? theme.colorText : theme.colorTextDisabled};
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
font: inherit;
|
||||
gap: ${theme.sizeUnit}px;
|
||||
max-width: 100%;
|
||||
padding: 0;
|
||||
text-align: left;
|
||||
|
||||
&:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
const Swatch = styled.span<{ color: string; selected: boolean }>`
|
||||
${({ color, selected }) => `
|
||||
background: ${selected ? color : 'transparent'};
|
||||
border: 1px solid ${color};
|
||||
box-sizing: border-box;
|
||||
flex: 0 0 auto;
|
||||
height: 12px;
|
||||
margin-top: 2px;
|
||||
width: 12px;
|
||||
`}
|
||||
`;
|
||||
|
||||
const Label = styled.span`
|
||||
overflow-wrap: anywhere;
|
||||
white-space: pre-line;
|
||||
`;
|
||||
|
||||
const RowBreak = styled.li`
|
||||
flex-basis: 100%;
|
||||
height: 0;
|
||||
`;
|
||||
|
||||
export type TimeseriesLegendProps = TimeseriesCustomLegend & {
|
||||
maxHeight: number;
|
||||
onAll: () => void;
|
||||
onHover?: (name: string | null) => void;
|
||||
onInverse: () => void;
|
||||
onToggle: (name: string) => void;
|
||||
};
|
||||
|
||||
function TimeseriesLegend({
|
||||
items,
|
||||
maxHeight,
|
||||
onAll,
|
||||
onHover,
|
||||
onInverse,
|
||||
onToggle,
|
||||
showSelectors,
|
||||
}: TimeseriesLegendProps) {
|
||||
if (items.length === 0 || maxHeight <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<LegendViewport data-test="timeseries-custom-legend" maxHeight={maxHeight}>
|
||||
{showSelectors && (
|
||||
<SelectorRow>
|
||||
<SelectorButton type="button" onClick={onAll}>
|
||||
{t('All')}
|
||||
</SelectorButton>
|
||||
<SelectorButton type="button" onClick={onInverse}>
|
||||
{t('Inverse')}
|
||||
</SelectorButton>
|
||||
</SelectorRow>
|
||||
)}
|
||||
<ItemList>
|
||||
{items.map((item, index) =>
|
||||
item.name === '' || item.name === '\n' ? (
|
||||
<RowBreak
|
||||
// ECharts treats these exact values as row-break sentinels.
|
||||
key={`row-break-${item.name}-${index}`}
|
||||
/>
|
||||
) : (
|
||||
<LegendItem key={item.name}>
|
||||
<ItemButton
|
||||
aria-pressed={item.selected}
|
||||
disabled={!item.interactive}
|
||||
selected={item.selected}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (item.interactive) {
|
||||
onToggle(item.name);
|
||||
}
|
||||
}}
|
||||
onMouseEnter={() => onHover?.(item.name)}
|
||||
onMouseLeave={() => onHover?.(null)}
|
||||
>
|
||||
<Swatch
|
||||
aria-hidden
|
||||
color={item.color}
|
||||
selected={item.selected}
|
||||
/>
|
||||
<Label>{item.name}</Label>
|
||||
</ItemButton>
|
||||
</LegendItem>
|
||||
),
|
||||
)}
|
||||
</ItemList>
|
||||
</LegendViewport>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(TimeseriesLegend);
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
isIntervalAnnotationLayer,
|
||||
isPhysicalColumn,
|
||||
isTimeseriesAnnotationLayer,
|
||||
LegendState,
|
||||
resolveAutoCurrency,
|
||||
TimeseriesChartDataResponseResult,
|
||||
TimeseriesDataRecord,
|
||||
@@ -66,7 +67,9 @@ import {
|
||||
EchartsTimeseriesSeriesType,
|
||||
BarValueLabelPosition,
|
||||
OrientationType,
|
||||
TimeseriesCustomLegend,
|
||||
TimeseriesChartTransformedProps,
|
||||
TimeseriesLegendItem,
|
||||
} from './types';
|
||||
import { DEFAULT_FORM_DATA } from './constants';
|
||||
import {
|
||||
@@ -112,6 +115,7 @@ import { defaultGrid, defaultYAxis } from '../defaults';
|
||||
import {
|
||||
getBaselineSeriesForStream,
|
||||
getPadding,
|
||||
getViableTimeseriesEchartOptions,
|
||||
transformEventAnnotation,
|
||||
transformFormulaAnnotation,
|
||||
transformIntervalAnnotation,
|
||||
@@ -137,6 +141,77 @@ import {
|
||||
import { safeParseEChartOptions } from '../utils/safeEChartOptionsParser';
|
||||
import { mergeCustomEChartOptions } from '../utils/mergeCustomEChartOptions';
|
||||
|
||||
type LegendSeriesVisual = {
|
||||
itemStyle?: { color?: unknown };
|
||||
lineStyle?: { color?: unknown };
|
||||
name?: string | number;
|
||||
};
|
||||
|
||||
function getLegendSeriesColor(
|
||||
series: SeriesOption | undefined,
|
||||
fallbackColor: string,
|
||||
): string {
|
||||
const visual = series as LegendSeriesVisual | undefined;
|
||||
const color = visual?.itemStyle?.color ?? visual?.lineStyle?.color;
|
||||
return typeof color === 'string' ? color : fallbackColor;
|
||||
}
|
||||
|
||||
function buildTimeseriesCustomLegend({
|
||||
fallbackColor,
|
||||
grid,
|
||||
interactive,
|
||||
legendNames,
|
||||
legendState,
|
||||
orientation,
|
||||
series,
|
||||
}: {
|
||||
fallbackColor: string;
|
||||
grid: TimeseriesCustomLegend['grid'];
|
||||
interactive: boolean;
|
||||
legendNames: string[];
|
||||
legendState?: LegendState;
|
||||
orientation: LegendOrientation.Top | LegendOrientation.Bottom;
|
||||
series: SeriesOption[];
|
||||
}): TimeseriesCustomLegend {
|
||||
const firstSeriesByName = new Map<string, SeriesOption>();
|
||||
series.forEach(seriesOption => {
|
||||
const { name } = seriesOption as LegendSeriesVisual;
|
||||
if (name !== undefined && !firstSeriesByName.has(String(name))) {
|
||||
firstSeriesByName.set(String(name), seriesOption);
|
||||
}
|
||||
});
|
||||
|
||||
const seen = new Set<string>();
|
||||
const items = legendNames.flatMap<TimeseriesLegendItem>(name => {
|
||||
if (seen.has(name)) {
|
||||
return [];
|
||||
}
|
||||
seen.add(name);
|
||||
|
||||
const rowBreak = name === '' || name === '\n';
|
||||
const matchingSeries = firstSeriesByName.get(name);
|
||||
if (!rowBreak && !matchingSeries) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
color: getLegendSeriesColor(matchingSeries, fallbackColor),
|
||||
interactive: interactive && !rowBreak,
|
||||
name,
|
||||
selected: legendState?.[name] !== false,
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
return {
|
||||
grid,
|
||||
items,
|
||||
orientation,
|
||||
showSelectors: interactive,
|
||||
};
|
||||
}
|
||||
|
||||
const visibleDashPatterns: ([number, number] | 'dashed' | 'dotted')[] = [
|
||||
'dashed',
|
||||
'dotted',
|
||||
@@ -1138,9 +1213,30 @@ export default function transformProps(
|
||||
name,
|
||||
icon: 'roundRect',
|
||||
}));
|
||||
const isSmallChart = height < TIMESERIES_CONSTANTS.compactChartHeight;
|
||||
const usesCompactLayout = height <= TIMESERIES_CONSTANTS.compactChartHeight;
|
||||
const isLegendVisible = showLegend && !usesCompactLayout;
|
||||
const usesPrimaryAxisLegend = colorByPrimaryAxis && groupBy.length === 0;
|
||||
const resolvedLegendData = usesPrimaryAxisLegend
|
||||
? colorByPrimaryAxisLegendData
|
||||
: sortedLegendData;
|
||||
const resolvedLegendNames = (
|
||||
usesPrimaryAxisLegend ? legendData : sortedLegendData
|
||||
).map(String);
|
||||
const usesCustomLegend =
|
||||
isLegendVisible &&
|
||||
legendType === LegendType.Plain &&
|
||||
(legendOrientation === LegendOrientation.Top ||
|
||||
legendOrientation === LegendOrientation.Bottom);
|
||||
const nativeLegendVisible = isLegendVisible && !usesCustomLegend;
|
||||
// Use the exact final ordering ECharts receives. Forecast components share
|
||||
// a legend name, and ECharts takes the first matching series as its visual.
|
||||
const renderedSeries = dedupSeries(
|
||||
reorderForecastSeries([...series]) as SeriesOption[],
|
||||
);
|
||||
const getLegendLayout = (candidateLegendMargin?: string | number | null) => {
|
||||
const padding = getPadding(
|
||||
showLegend,
|
||||
nativeLegendVisible,
|
||||
legendOrientation,
|
||||
addYAxisLabelOffset,
|
||||
zoomable,
|
||||
@@ -1165,20 +1261,18 @@ export default function transformProps(
|
||||
: undefined,
|
||||
chartHeight: height,
|
||||
chartWidth: width,
|
||||
legendItems:
|
||||
colorByPrimaryAxis && groupBy.length === 0
|
||||
? colorByPrimaryAxisLegendData
|
||||
: sortedLegendData,
|
||||
legendItems: resolvedLegendData,
|
||||
legendMargin: candidateLegendMargin,
|
||||
orientation: legendOrientation,
|
||||
show: showLegend,
|
||||
showSelectors: !(colorByPrimaryAxis && groupBy.length === 0),
|
||||
show: nativeLegendVisible,
|
||||
showSelectors: !usesPrimaryAxisLegend,
|
||||
theme,
|
||||
type: legendType,
|
||||
});
|
||||
};
|
||||
const initialLegendLayout = getLegendLayout(legendMargin);
|
||||
const legendLayout =
|
||||
nativeLegendVisible &&
|
||||
isHorizontal &&
|
||||
legendOrientation === LegendOrientation.Bottom &&
|
||||
initialLegendLayout.effectiveLegendType === LegendType.Plain
|
||||
@@ -1192,7 +1286,7 @@ export default function transformProps(
|
||||
? legendMargin
|
||||
: legendLayout.effectiveLegendMargin;
|
||||
const padding = getPadding(
|
||||
showLegend,
|
||||
nativeLegendVisible,
|
||||
legendOrientation,
|
||||
addYAxisLabelOffset,
|
||||
zoomable,
|
||||
@@ -1207,7 +1301,7 @@ export default function transformProps(
|
||||
// Reduce grid padding for small charts to maximize the drawing area.
|
||||
// Keep enough top padding so the max label doesn't clip against the cell border.
|
||||
// Preserve bottom padding when zoomable, since getPadding() reserves space for the dataZoom slider.
|
||||
if (height < TIMESERIES_CONSTANTS.compactChartHeight) {
|
||||
if (usesCompactLayout) {
|
||||
padding.top = Math.min(padding.top, 12);
|
||||
if (!zoomable) {
|
||||
padding.bottom = Math.min(padding.bottom, 5);
|
||||
@@ -1301,7 +1395,6 @@ export default function transformProps(
|
||||
// >= 100px: full axis with proportional tick count
|
||||
// 60-99px: show only min/max boundary labels (splitNumber=1), hide lines/ticks
|
||||
// < 60px: hide all axis decorations, show line only
|
||||
const isSmallChart = height < TIMESERIES_CONSTANTS.compactChartHeight;
|
||||
const isMicroChart = height < TIMESERIES_CONSTANTS.microChartHeight;
|
||||
const yAxisSplitNumber = isMicroChart
|
||||
? undefined
|
||||
@@ -1378,6 +1471,9 @@ export default function transformProps(
|
||||
grid: {
|
||||
...defaultGrid,
|
||||
...padding,
|
||||
// Compact charts prioritize a viable coordinate system over keeping
|
||||
// axis labels inside an already constrained grid rectangle.
|
||||
containLabel: !usesCompactLayout,
|
||||
},
|
||||
xAxis,
|
||||
yAxis,
|
||||
@@ -1504,27 +1600,23 @@ export default function transformProps(
|
||||
...getLegendProps(
|
||||
effectiveLegendType,
|
||||
legendOrientation,
|
||||
// Hide legend on compact charts — not enough vertical space
|
||||
isSmallChart ? false : showLegend,
|
||||
nativeLegendVisible,
|
||||
theme,
|
||||
zoomable,
|
||||
legendState,
|
||||
padding,
|
||||
),
|
||||
scrollDataIndex: legendIndex || 0,
|
||||
data:
|
||||
colorByPrimaryAxis && groupBy.length === 0
|
||||
? colorByPrimaryAxisLegendData
|
||||
: sortedLegendData,
|
||||
data: resolvedLegendData,
|
||||
// Disable legend selection and buttons when colorByPrimaryAxis is enabled
|
||||
...(colorByPrimaryAxis && groupBy.length === 0
|
||||
...(usesPrimaryAxisLegend
|
||||
? {
|
||||
selectedMode: false, // Disable clicking legend items
|
||||
selector: false, // Hide All/Invert buttons
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
series: dedupSeries(reorderForecastSeries(series) as SeriesOption[]),
|
||||
series: renderedSeries,
|
||||
toolbox: {
|
||||
show: zoomable,
|
||||
top: TIMESERIES_CONSTANTS.toolboxTop,
|
||||
@@ -1581,9 +1673,57 @@ export default function transformProps(
|
||||
const mergedEchartOptions = customEchartOptions
|
||||
? mergeCustomEChartOptions(echartOptions, customEchartOptions)
|
||||
: echartOptions;
|
||||
const viableEchartOptions = getViableTimeseriesEchartOptions(
|
||||
mergedEchartOptions,
|
||||
height,
|
||||
zoomable,
|
||||
);
|
||||
const mergedSeries = viableEchartOptions.series;
|
||||
const finalSeries = Array.isArray(mergedSeries)
|
||||
? (mergedSeries as SeriesOption[])
|
||||
: mergedSeries && typeof mergedSeries === 'object'
|
||||
? [mergedSeries as SeriesOption]
|
||||
: renderedSeries;
|
||||
const mergedGrid = Array.isArray(viableEchartOptions.grid)
|
||||
? viableEchartOptions.grid[0]
|
||||
: viableEchartOptions.grid;
|
||||
const finalGrid =
|
||||
mergedGrid && typeof mergedGrid === 'object' ? mergedGrid : padding;
|
||||
const customLegend = usesCustomLegend
|
||||
? buildTimeseriesCustomLegend({
|
||||
fallbackColor: theme.colorTextSecondary,
|
||||
grid: {
|
||||
bottom:
|
||||
typeof finalGrid.bottom === 'number' ||
|
||||
typeof finalGrid.bottom === 'string'
|
||||
? finalGrid.bottom
|
||||
: padding.bottom,
|
||||
top:
|
||||
typeof finalGrid.top === 'number' ||
|
||||
typeof finalGrid.top === 'string'
|
||||
? finalGrid.top
|
||||
: padding.top,
|
||||
},
|
||||
interactive: !usesPrimaryAxisLegend,
|
||||
legendNames: resolvedLegendNames,
|
||||
legendState,
|
||||
orientation: legendOrientation,
|
||||
series: finalSeries,
|
||||
})
|
||||
: undefined;
|
||||
const finalEchartOptions = usesCustomLegend
|
||||
? {
|
||||
...viableEchartOptions,
|
||||
legend: {
|
||||
...(viableEchartOptions.legend as Record<string, unknown>),
|
||||
show: false,
|
||||
},
|
||||
}
|
||||
: viableEchartOptions;
|
||||
|
||||
return {
|
||||
echartOptions: mergedEchartOptions,
|
||||
customLegend,
|
||||
echartOptions: finalEchartOptions,
|
||||
emitCrossFilters,
|
||||
formData,
|
||||
groupby: groupBy,
|
||||
|
||||
@@ -1002,3 +1002,76 @@ export function getPadding(
|
||||
isHorizontal,
|
||||
);
|
||||
}
|
||||
|
||||
const MIN_ECHARTS_GRID_HEIGHT = 1;
|
||||
|
||||
export function resolveTimeseriesGridOffset(
|
||||
offset: unknown,
|
||||
chartHeight: number,
|
||||
) {
|
||||
if (typeof offset === 'number') {
|
||||
return Number.isFinite(offset) ? Math.max(offset, 0) : 0;
|
||||
}
|
||||
if (typeof offset !== 'string') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const percentage = offset.match(/^\s*(-?\d+(?:\.\d+)?)%\s*$/);
|
||||
const pixels = percentage
|
||||
? (Number(percentage[1]) / 100) * chartHeight
|
||||
: Number(offset);
|
||||
return Number.isFinite(pixels) ? Math.max(pixels, 0) : 0;
|
||||
}
|
||||
|
||||
export function getViableTimeseriesEchartOptions<Options extends object>(
|
||||
options: Options,
|
||||
chartHeight: number,
|
||||
zoomable: boolean,
|
||||
): Options {
|
||||
const optionWithGrid = options as Options & { grid?: unknown };
|
||||
const gridOption = Array.isArray(optionWithGrid.grid)
|
||||
? optionWithGrid.grid[0]
|
||||
: optionWithGrid.grid;
|
||||
if (!gridOption || typeof gridOption !== 'object') {
|
||||
return options;
|
||||
}
|
||||
|
||||
const grid = gridOption as Record<string, unknown>;
|
||||
const rawTop = resolveTimeseriesGridOffset(grid.top, chartHeight);
|
||||
const rawBottom = resolveTimeseriesGridOffset(grid.bottom, chartHeight);
|
||||
const isCompact = chartHeight <= TIMESERIES_CONSTANTS.compactChartHeight;
|
||||
const requestedTop = isCompact ? Math.min(rawTop, 12) : rawTop;
|
||||
const requestedBottom =
|
||||
isCompact && !zoomable ? Math.min(rawBottom, 5) : rawBottom;
|
||||
// Cap both reservations so even a tiny canvas retains a coordinate region.
|
||||
const reservationBudget = Math.max(chartHeight - MIN_ECHARTS_GRID_HEIGHT, 0);
|
||||
const top = Math.min(requestedTop, reservationBudget);
|
||||
const bottom = Math.min(
|
||||
requestedBottom,
|
||||
Math.max(reservationBudget - top, 0),
|
||||
);
|
||||
const mustDisableContainLabel =
|
||||
isCompact || requestedTop + requestedBottom > reservationBudget;
|
||||
|
||||
if (
|
||||
top === rawTop &&
|
||||
bottom === rawBottom &&
|
||||
(!mustDisableContainLabel || grid.containLabel === false)
|
||||
) {
|
||||
return options;
|
||||
}
|
||||
|
||||
const viableGrid = {
|
||||
...grid,
|
||||
bottom,
|
||||
...(mustDisableContainLabel ? { containLabel: false } : {}),
|
||||
top,
|
||||
};
|
||||
|
||||
return {
|
||||
...options,
|
||||
grid: Array.isArray(optionWithGrid.grid)
|
||||
? [viableGrid, ...optionWithGrid.grid.slice(1)]
|
||||
: viableGrid,
|
||||
} as Options;
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
CrossFilterTransformedProps,
|
||||
LabelPositionEnum,
|
||||
LegendFormData,
|
||||
LegendOrientation,
|
||||
StackType,
|
||||
TitleFormData,
|
||||
} from '../types';
|
||||
@@ -133,10 +134,28 @@ export interface EchartsTimeseriesChartProps extends BaseChartProps<EchartsTimes
|
||||
formData: EchartsTimeseriesFormData;
|
||||
}
|
||||
|
||||
export type TimeseriesLegendItem = {
|
||||
color: string;
|
||||
interactive: boolean;
|
||||
name: string;
|
||||
selected: boolean;
|
||||
};
|
||||
|
||||
export type TimeseriesCustomLegend = {
|
||||
grid: {
|
||||
bottom: number | string;
|
||||
top: number | string;
|
||||
};
|
||||
items: TimeseriesLegendItem[];
|
||||
orientation: LegendOrientation.Top | LegendOrientation.Bottom;
|
||||
showSelectors: boolean;
|
||||
};
|
||||
|
||||
export type TimeseriesChartTransformedProps =
|
||||
BaseTransformedProps<EchartsTimeseriesFormData> &
|
||||
ContextMenuTransformedProps &
|
||||
CrossFilterTransformedProps & {
|
||||
customLegend?: TimeseriesCustomLegend;
|
||||
legendData?: OptionName[];
|
||||
isRefreshing?: boolean;
|
||||
xValueFormatter: TimeFormatter | StringConstructor;
|
||||
|
||||
+52
-86
@@ -43,11 +43,6 @@ import {
|
||||
OrientationType,
|
||||
EchartsTimeseriesSeriesType,
|
||||
} from '../../../src/Timeseries/types';
|
||||
import { getPadding } from '../../../src/Timeseries/transformers';
|
||||
import {
|
||||
getHorizontalLegendAvailableWidth,
|
||||
getLegendLayoutResult,
|
||||
} from '../../../src/utils/series';
|
||||
import { createEchartsTimeseriesTestChartProps } from '../../helpers';
|
||||
|
||||
function createTestQueryData(
|
||||
@@ -646,6 +641,41 @@ describe('Bar Chart X-axis Time Formatting', () => {
|
||||
expect(legend.selector).toBe(false);
|
||||
});
|
||||
|
||||
test('marks custom Plain legend items non-interactive when color by x-axis is enabled', () => {
|
||||
const chartProps = new ChartProps({
|
||||
...baseChartPropsConfig,
|
||||
formData: {
|
||||
...baseFormData,
|
||||
colorByPrimaryAxis: true,
|
||||
groupby: [],
|
||||
legendOrientation: LegendOrientation.Top,
|
||||
legendType: LegendType.Plain,
|
||||
metric: 'value',
|
||||
showLegend: true,
|
||||
x_axis: 'category',
|
||||
},
|
||||
queriesData: categoricalData,
|
||||
});
|
||||
|
||||
const transformedProps = transformProps(
|
||||
chartProps as unknown as EchartsTimeseriesChartProps,
|
||||
);
|
||||
const { customLegend } = transformedProps as unknown as {
|
||||
customLegend?: {
|
||||
items: { interactive: boolean; name: string }[];
|
||||
showSelectors: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
expect(customLegend?.showSelectors).toBe(false);
|
||||
expect(customLegend?.items.map(item => item.name)).toEqual([
|
||||
'A',
|
||||
'B',
|
||||
'C',
|
||||
]);
|
||||
expect(customLegend?.items.every(item => !item.interactive)).toBe(true);
|
||||
});
|
||||
|
||||
test('should work without stacking enabled', () => {
|
||||
const formData = {
|
||||
...baseFormData,
|
||||
@@ -922,39 +952,7 @@ describe('Bar Chart X-axis Time Formatting', () => {
|
||||
});
|
||||
|
||||
describe('Legend layout regressions', () => {
|
||||
const getBottomLegendLayout = (
|
||||
chartWidth: number,
|
||||
legendItems: string[],
|
||||
legendMargin?: string | number | null,
|
||||
) =>
|
||||
getLegendLayoutResult({
|
||||
availableWidth: getHorizontalLegendAvailableWidth({
|
||||
chartWidth,
|
||||
orientation: LegendOrientation.Bottom,
|
||||
padding: getPadding(
|
||||
true,
|
||||
LegendOrientation.Bottom,
|
||||
false,
|
||||
false,
|
||||
legendMargin,
|
||||
false,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
true,
|
||||
),
|
||||
}),
|
||||
chartHeight: baseChartPropsConfig.height,
|
||||
chartWidth,
|
||||
legendItems,
|
||||
legendMargin,
|
||||
orientation: LegendOrientation.Bottom,
|
||||
show: true,
|
||||
theme: supersetTheme,
|
||||
type: LegendType.Plain,
|
||||
});
|
||||
|
||||
test('honors an explicit List selection for horizontal bottom legends and reserves margin', () => {
|
||||
test('honors an explicit List selection with a custom horizontal bottom legend', () => {
|
||||
const legendLabels = [
|
||||
'This is a long sales legend',
|
||||
'This is a long marketing legend',
|
||||
@@ -1019,58 +1017,26 @@ describe('Bar Chart X-axis Time Formatting', () => {
|
||||
const legend = transformedProps.echartOptions
|
||||
.legend as LegendComponentOption;
|
||||
const grid = transformedProps.echartOptions.grid as GridComponentOption;
|
||||
const legendItems = (legend.data as Array<string | { name: string }>).map(
|
||||
item => (typeof item === 'string' ? item : item.name),
|
||||
);
|
||||
|
||||
const layout = getBottomLegendLayout(chartWidth, legendItems, null);
|
||||
const basePadding = getPadding(
|
||||
true,
|
||||
LegendOrientation.Bottom,
|
||||
false,
|
||||
false,
|
||||
null,
|
||||
false,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
[basePadding.bottom, basePadding.left] = [
|
||||
basePadding.left,
|
||||
basePadding.bottom,
|
||||
];
|
||||
const { customLegend } = transformedProps as unknown as {
|
||||
customLegend?: {
|
||||
items: { name: string }[];
|
||||
orientation: LegendOrientation;
|
||||
};
|
||||
};
|
||||
const resolvedLegendItems = (
|
||||
legend.data as Array<string | { name: string }>
|
||||
).map(item => (typeof item === 'string' ? item : item.name));
|
||||
|
||||
// The explicit List selection is honored end-to-end (never flips).
|
||||
expect(legend.type).toBe(LegendType.Plain);
|
||||
expect(layout.effectiveType).toBe(LegendType.Plain);
|
||||
|
||||
// #38675's margin reservation is retained: the wrapped rows reserve a
|
||||
// finite margin beyond the single-row baseline, so the grid shrinks to
|
||||
// reduce clipping instead of the legend flipping to scroll.
|
||||
expect(Number.isFinite(layout.effectiveMargin)).toBe(true);
|
||||
|
||||
const reservedPadding = getPadding(
|
||||
true,
|
||||
LegendOrientation.Bottom,
|
||||
false,
|
||||
false,
|
||||
layout.effectiveMargin,
|
||||
false,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
[reservedPadding.bottom, reservedPadding.left] = [
|
||||
reservedPadding.left,
|
||||
reservedPadding.bottom,
|
||||
];
|
||||
|
||||
expect(grid.bottom).toBe(reservedPadding.bottom);
|
||||
expect(grid.bottom as number).toBeGreaterThan(
|
||||
basePadding.bottom as number,
|
||||
expect(legend.show).toBe(false);
|
||||
expect(customLegend?.orientation).toBe(LegendOrientation.Bottom);
|
||||
expect(customLegend?.items.map(item => item.name)).toEqual(
|
||||
resolvedLegendItems,
|
||||
);
|
||||
// The plot canvas no longer reserves native legend rows; the independently
|
||||
// scrolling HTML legend consumes space outside the ECharts grid.
|
||||
expect(grid.bottom).toBe(20);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+221
-2
@@ -16,7 +16,8 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { render, waitFor } from '@testing-library/react';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom';
|
||||
import { AxisType, DTTM_ALIAS, TimeGranularity } from '@superset-ui/core';
|
||||
import { supersetTheme, ThemeProvider } from '@apache-superset/core/theme';
|
||||
import { logging } from '@apache-superset/core/utils';
|
||||
@@ -27,6 +28,49 @@ import {
|
||||
TimeseriesChartTransformedProps,
|
||||
} from '../../src/Timeseries/types';
|
||||
import type { EchartsProps } from '../../src/types';
|
||||
import { LegendOrientation } from '../../src/types';
|
||||
|
||||
jest.mock('@visx/responsive', () => ({
|
||||
ParentSize: ({
|
||||
children,
|
||||
}: {
|
||||
children: (size: object) => React.ReactNode;
|
||||
}) => {
|
||||
const React = jest.requireActual<typeof import('react')>('react');
|
||||
const hostRef = React.useRef<HTMLDivElement>(null);
|
||||
const [size, setSize] = React.useState<{
|
||||
height: number;
|
||||
width: number;
|
||||
}>();
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
const frame = hostRef.current?.closest<HTMLElement>('.with-legend');
|
||||
const legend = frame?.querySelector<HTMLElement>(
|
||||
'[data-test="timeseries-custom-legend"]',
|
||||
);
|
||||
if (!frame || !legend) {
|
||||
return;
|
||||
}
|
||||
|
||||
const frameHeight = Number.parseFloat(frame.style.height);
|
||||
const frameWidth = Number.parseFloat(frame.style.width);
|
||||
const maxHeight = Number.parseFloat(
|
||||
globalThis.getComputedStyle(legend).maxHeight,
|
||||
);
|
||||
const itemCount = legend.querySelectorAll('[aria-pressed]').length;
|
||||
const selectorHeight = legend.querySelectorAll('[aria-pressed]').length
|
||||
? 20
|
||||
: 0;
|
||||
const naturalHeight = selectorHeight + Math.ceil(itemCount / 4) * 20;
|
||||
setSize({
|
||||
height: frameHeight - Math.min(maxHeight, naturalHeight),
|
||||
width: frameWidth,
|
||||
});
|
||||
}, []);
|
||||
|
||||
return <div ref={hostRef}>{size ? children(size) : null}</div>;
|
||||
},
|
||||
}));
|
||||
|
||||
// Percent-change draggable baseline: this is the one piece of the ECharts
|
||||
// rebuilds with zero prior test coverage despite six separate production
|
||||
@@ -40,6 +84,7 @@ import type { EchartsProps } from '../../src/types';
|
||||
// mockImplementation afterward) because forwardRef() returns a React
|
||||
// element descriptor, not a plain function a jest mock can invoke.
|
||||
let mockChart: {
|
||||
dispatchAction: jest.Mock;
|
||||
setOption: jest.Mock;
|
||||
getHeight: jest.Mock;
|
||||
convertToPixel: jest.Mock;
|
||||
@@ -57,7 +102,8 @@ jest.mock('../../src/components/Echart', () => {
|
||||
useImperativeHandle(ref, () => ({
|
||||
getEchartInstance: () => mockChart,
|
||||
}));
|
||||
return null;
|
||||
const { height } = props as { height: number };
|
||||
return <div data-height={height} data-test="mock-echart" />;
|
||||
}),
|
||||
};
|
||||
});
|
||||
@@ -66,6 +112,7 @@ const PX_PER_UNIT = 100;
|
||||
|
||||
function setupChartMock() {
|
||||
mockChart = {
|
||||
dispatchAction: jest.fn(),
|
||||
setOption: jest.fn(),
|
||||
getHeight: jest.fn(() => 400),
|
||||
// A trivial, invertible mapping so drag pixel deltas translate to
|
||||
@@ -82,6 +129,24 @@ function setupChartMock() {
|
||||
};
|
||||
}
|
||||
|
||||
function getCustomLegend(
|
||||
itemCount: number,
|
||||
overrides: Record<string, unknown> = {},
|
||||
) {
|
||||
return {
|
||||
items: Array.from({ length: itemCount }, (_, index) => ({
|
||||
color: `rgb(${index % 255}, 0, 0)`,
|
||||
interactive: true,
|
||||
name: `Series ${index + 1}`,
|
||||
selected: true,
|
||||
})),
|
||||
orientation: LegendOrientation.Top,
|
||||
grid: { bottom: 20, top: 20 },
|
||||
showSelectors: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const BASE_SERIES_DATA: [number, number][] = [
|
||||
[0, 10],
|
||||
[1, 20],
|
||||
@@ -158,6 +223,160 @@ afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
test('renders fitting content at the allocated height without a scroll viewport', () => {
|
||||
const { container } = renderTimeseries();
|
||||
|
||||
expect(screen.getByTestId('mock-echart')).toHaveAttribute(
|
||||
'data-height',
|
||||
'400',
|
||||
);
|
||||
expect(container.querySelector('[style*="overflow-y"]')).toBeNull();
|
||||
});
|
||||
|
||||
test('caps a dense custom Plain legend while keeping scrolling inside the legend region', () => {
|
||||
renderTimeseries({
|
||||
...({ customLegend: getCustomLegend(200) } as any),
|
||||
formData: { rebasePercentChange: false } as any,
|
||||
});
|
||||
|
||||
const legend = screen.getByTestId('timeseries-custom-legend');
|
||||
expect(legend).toHaveStyle({
|
||||
maxHeight: '120px',
|
||||
overflowY: 'auto',
|
||||
});
|
||||
expect(legend.style.height).toBe('');
|
||||
expect(screen.getAllByRole('button')).toHaveLength(202);
|
||||
expect(screen.getByTestId('mock-echart')).toHaveAttribute(
|
||||
'data-height',
|
||||
'280',
|
||||
);
|
||||
expect(legend.closest('.with-legend')).toHaveStyle({
|
||||
height: '400px',
|
||||
width: '800px',
|
||||
});
|
||||
});
|
||||
|
||||
test('lets a short custom Plain legend use its natural height', () => {
|
||||
renderTimeseries({
|
||||
...({ customLegend: getCustomLegend(2) } as any),
|
||||
formData: { rebasePercentChange: false } as any,
|
||||
});
|
||||
|
||||
const legend = screen.getByTestId('timeseries-custom-legend');
|
||||
expect(legend).toHaveStyle({ maxHeight: '120px' });
|
||||
expect(legend.style.height).toBe('');
|
||||
expect(screen.getAllByRole('button')).toHaveLength(4);
|
||||
expect(screen.getByTestId('mock-echart')).toHaveAttribute(
|
||||
'data-height',
|
||||
'360',
|
||||
);
|
||||
});
|
||||
|
||||
test.each([
|
||||
[99, 99],
|
||||
[100, 100],
|
||||
[120, 120],
|
||||
])(
|
||||
'does not allocate a custom legend when a %ipx zoomable chart has no usable grid space',
|
||||
(height, expectedChartHeight) => {
|
||||
renderTimeseries({
|
||||
...({
|
||||
customLegend: getCustomLegend(200, {
|
||||
grid: { bottom: 80, top: 20 },
|
||||
}),
|
||||
} as any),
|
||||
formData: { rebasePercentChange: false, zoomable: true } as any,
|
||||
height,
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('timeseries-custom-legend'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('mock-echart')).toHaveAttribute(
|
||||
'data-height',
|
||||
String(expectedChartHeight),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('accounts for axis-title grid reservations when bounding the custom legend', () => {
|
||||
renderTimeseries({
|
||||
...({
|
||||
customLegend: getCustomLegend(200, {
|
||||
grid: { bottom: 80, top: 60 },
|
||||
}),
|
||||
} as any),
|
||||
formData: { rebasePercentChange: false } as any,
|
||||
height: 240,
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('timeseries-custom-legend')).toHaveStyle({
|
||||
maxHeight: '20px',
|
||||
});
|
||||
expect(screen.getByTestId('mock-echart')).toHaveAttribute(
|
||||
'data-height',
|
||||
'220',
|
||||
);
|
||||
});
|
||||
|
||||
test.each([
|
||||
[LegendOrientation.Top, 'column'],
|
||||
[LegendOrientation.Bottom, 'column-reverse'],
|
||||
])('places a custom Plain legend at %s', (orientation, flexDirection) => {
|
||||
renderTimeseries({
|
||||
...({
|
||||
customLegend: getCustomLegend(2, { orientation }),
|
||||
} as any),
|
||||
formData: { rebasePercentChange: false } as any,
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getByTestId('timeseries-custom-legend').closest('.with-legend'),
|
||||
).toHaveStyle({ flexDirection });
|
||||
});
|
||||
|
||||
test('dispatches the native ECharts toggle, All, and Inverse legend actions', () => {
|
||||
renderTimeseries({
|
||||
...({ customLegend: getCustomLegend(2) } as any),
|
||||
formData: { rebasePercentChange: false } as any,
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Series 1' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'All' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Inverse' }));
|
||||
|
||||
expect(mockChart.dispatchAction.mock.calls).toEqual([
|
||||
[{ name: 'Series 1', type: 'legendToggleSelect' }],
|
||||
[{ type: 'legendAllSelect' }],
|
||||
[{ type: 'legendInverseSelect' }],
|
||||
]);
|
||||
});
|
||||
|
||||
test('does not dispatch legend actions for non-interactive color-by-primary-axis items', () => {
|
||||
renderTimeseries({
|
||||
...({
|
||||
customLegend: getCustomLegend(2, {
|
||||
items: getCustomLegend(2).items.map(item => ({
|
||||
...item,
|
||||
interactive: false,
|
||||
})),
|
||||
showSelectors: false,
|
||||
}),
|
||||
} as any),
|
||||
formData: { rebasePercentChange: false } as any,
|
||||
});
|
||||
|
||||
const item = screen.getByRole('button', { name: 'Series 1' });
|
||||
expect(item).toBeDisabled();
|
||||
fireEvent.click(item);
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'All' })).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Inverse' }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(mockChart.dispatchAction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('draws the baseline handle at the first x value on mount', () => {
|
||||
renderTimeseries();
|
||||
|
||||
|
||||
+367
-3
@@ -37,7 +37,8 @@ import {
|
||||
} from '@superset-ui/core';
|
||||
import { GenericDataType } from '@apache-superset/core/common';
|
||||
import { supersetTheme } from '@apache-superset/core/theme';
|
||||
import type { SeriesOption } from 'echarts';
|
||||
import { init, type SeriesOption } from 'echarts';
|
||||
import type { GridComponentOption } from 'echarts/components';
|
||||
import transformProps from '../../src/Timeseries/transformProps';
|
||||
import {
|
||||
EchartsTimeseriesSeriesType,
|
||||
@@ -46,6 +47,7 @@ import {
|
||||
} from '../../src/Timeseries/types';
|
||||
import { StackControlsValue, TIMESERIES_CONSTANTS } from '../../src/constants';
|
||||
import {
|
||||
ForecastSeriesEnum,
|
||||
LegendOrientation,
|
||||
LegendType,
|
||||
EchartsTimeseriesChartProps,
|
||||
@@ -160,6 +162,27 @@ const formData: SqlaFormData = {
|
||||
viz_type: 'my_viz',
|
||||
};
|
||||
|
||||
type CustomLegendResult = {
|
||||
customLegend?: {
|
||||
grid: {
|
||||
bottom: number | string;
|
||||
top: number | string;
|
||||
};
|
||||
items: {
|
||||
color: string;
|
||||
interactive: boolean;
|
||||
name: string;
|
||||
selected: boolean;
|
||||
}[];
|
||||
orientation: LegendOrientation.Top | LegendOrientation.Bottom;
|
||||
showSelectors: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
function getCustomLegend(transformed: ReturnType<typeof transformProps>) {
|
||||
return (transformed as unknown as CustomLegendResult).customLegend;
|
||||
}
|
||||
|
||||
describe('EchartsTimeseries transformProps', () => {
|
||||
test('should transform chart props for viz', () => {
|
||||
const chartProps = createTestChartProps({});
|
||||
@@ -1177,6 +1200,345 @@ test('honors an explicit List selection for zoomable top legends even when toolb
|
||||
expect((transformed.echartOptions.legend as any).type).toBe(LegendType.Plain);
|
||||
});
|
||||
|
||||
test('moves a visible horizontal Plain legend into a custom HTML legend and restores normal plot padding', () => {
|
||||
const chartProps = createTestChartProps({
|
||||
width: 800,
|
||||
height: 400,
|
||||
formData: {
|
||||
...formData,
|
||||
legendOrientation: LegendOrientation.Top,
|
||||
legendType: LegendType.Plain,
|
||||
showLegend: true,
|
||||
yAxisTitleMargin: 0,
|
||||
yAxisTitlePosition: 'Left',
|
||||
},
|
||||
});
|
||||
|
||||
const transformed = transformProps(chartProps);
|
||||
const legend = transformed.echartOptions.legend as {
|
||||
show?: boolean;
|
||||
type?: LegendType;
|
||||
};
|
||||
const grid = transformed.echartOptions.grid as GridComponentOption;
|
||||
const customLegend = getCustomLegend(transformed);
|
||||
|
||||
expect(legend).toMatchObject({ show: false, type: LegendType.Plain });
|
||||
expect(grid).toMatchObject({ top: 20, bottom: 20 });
|
||||
expect(customLegend).toMatchObject({
|
||||
orientation: LegendOrientation.Top,
|
||||
showSelectors: true,
|
||||
});
|
||||
expect(customLegend?.items.map(item => item.name)).toEqual([
|
||||
'San Francisco',
|
||||
'New York',
|
||||
]);
|
||||
expect(customLegend?.items.every(item => item.interactive)).toBe(true);
|
||||
expect(customLegend?.items.every(item => item.selected)).toBe(true);
|
||||
expect(customLegend?.items.every(item => Boolean(item.color))).toBe(true);
|
||||
expect('contentHeight' in transformed).toBe(false);
|
||||
});
|
||||
|
||||
test.each([LegendOrientation.Top, LegendOrientation.Bottom])(
|
||||
'uses the custom HTML legend for a %s-oriented Plain legend',
|
||||
legendOrientation => {
|
||||
const transformed = transformProps(
|
||||
createTestChartProps({
|
||||
formData: {
|
||||
...formData,
|
||||
legendOrientation,
|
||||
legendType: LegendType.Plain,
|
||||
showLegend: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(getCustomLegend(transformed)?.orientation).toBe(legendOrientation);
|
||||
expect((transformed.echartOptions.legend as { show?: boolean }).show).toBe(
|
||||
false,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test.each([
|
||||
[LegendType.Scroll, LegendOrientation.Top],
|
||||
[LegendType.Plain, LegendOrientation.Left],
|
||||
[LegendType.Plain, LegendOrientation.Right],
|
||||
] as const)(
|
||||
'keeps %s/%s legends on the native ECharts path',
|
||||
(legendType, legendOrientation) => {
|
||||
const transformed = transformProps(
|
||||
createTestChartProps({
|
||||
formData: {
|
||||
...formData,
|
||||
legendOrientation,
|
||||
legendType,
|
||||
showLegend: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(getCustomLegend(transformed)).toBeUndefined();
|
||||
expect((transformed.echartOptions.legend as { show?: boolean }).show).toBe(
|
||||
true,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('keeps the custom legend absent when a compact chart hides the Plain legend', () => {
|
||||
const transformed = transformProps(
|
||||
createTestChartProps({
|
||||
height: 80,
|
||||
formData: {
|
||||
...formData,
|
||||
legendOrientation: LegendOrientation.Top,
|
||||
legendType: LegendType.Plain,
|
||||
showLegend: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
const grid = transformed.echartOptions.grid as GridComponentOption;
|
||||
|
||||
expect(getCustomLegend(transformed)).toBeUndefined();
|
||||
expect((transformed.echartOptions.legend as { show?: boolean }).show).toBe(
|
||||
false,
|
||||
);
|
||||
expect(grid).toMatchObject({ top: 12, bottom: 5 });
|
||||
expect(80 - Number(grid.top) - Number(grid.bottom)).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test.each([
|
||||
[10, 9, 0.25, 9, 0],
|
||||
[13, 12, 0.25, 12, 0],
|
||||
[20, 12, 0.25, 12, 7],
|
||||
[30, 12, 1, 12, 17],
|
||||
[99, 12, 7, 12, 80],
|
||||
[100, 12, 8, 12, 80],
|
||||
])(
|
||||
'keeps the hidden-legend zoomable ECharts grid within a %ipx canvas',
|
||||
(height, expectedGridY, expectedGridHeight, expectedTop, expectedBottom) => {
|
||||
const getContext = jest
|
||||
.spyOn(HTMLCanvasElement.prototype, 'getContext')
|
||||
.mockReturnValue({
|
||||
measureText: (text: string) => ({ width: text.length * 7 }),
|
||||
} as never);
|
||||
const transformed = transformProps(
|
||||
createTestChartProps({
|
||||
height,
|
||||
formData: {
|
||||
...formData,
|
||||
legendOrientation: LegendOrientation.Top,
|
||||
legendType: LegendType.Plain,
|
||||
showLegend: true,
|
||||
zoomable: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
const chart = init(null, null, {
|
||||
height,
|
||||
renderer: 'svg',
|
||||
ssr: true,
|
||||
width: transformed.width,
|
||||
});
|
||||
|
||||
try {
|
||||
chart.setOption(transformed.echartOptions);
|
||||
const gridModel = (
|
||||
chart as unknown as {
|
||||
getModel: () => {
|
||||
getComponent: (component: string) => {
|
||||
coordinateSystem: {
|
||||
getRect: () => { height: number; y: number };
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
)
|
||||
.getModel()
|
||||
.getComponent('grid');
|
||||
|
||||
expect(getCustomLegend(transformed)).toBeUndefined();
|
||||
expect(transformed.echartOptions.grid).toMatchObject({
|
||||
bottom: expectedBottom,
|
||||
top: expectedTop,
|
||||
});
|
||||
const gridRect = gridModel.coordinateSystem.getRect();
|
||||
expect(gridRect).toMatchObject({
|
||||
height: expectedGridHeight,
|
||||
y: expectedGridY,
|
||||
});
|
||||
expect(gridRect.y).toBeGreaterThanOrEqual(0);
|
||||
expect(gridRect.y + gridRect.height).toBeLessThanOrEqual(height);
|
||||
} finally {
|
||||
chart.dispose();
|
||||
getContext.mockRestore();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test('passes final axis-title grid reservations to the custom legend', () => {
|
||||
const transformed = transformProps(
|
||||
createTestChartProps({
|
||||
height: 300,
|
||||
formData: {
|
||||
...formData,
|
||||
legendOrientation: LegendOrientation.Top,
|
||||
legendType: LegendType.Plain,
|
||||
showLegend: true,
|
||||
xAxisTitle: 'Time',
|
||||
xAxisTitleMargin: 60,
|
||||
yAxisTitle: 'Value',
|
||||
yAxisTitleMargin: 40,
|
||||
yAxisTitlePosition: 'Top',
|
||||
zoomable: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
const grid = transformed.echartOptions.grid as GridComponentOption;
|
||||
|
||||
expect(getCustomLegend(transformed)?.grid).toEqual({
|
||||
bottom: grid.bottom,
|
||||
top: grid.top,
|
||||
});
|
||||
});
|
||||
|
||||
test('derives custom legend items from a single-object custom series override', () => {
|
||||
const transformed = transformProps(
|
||||
createTestChartProps({
|
||||
formData: {
|
||||
...formData,
|
||||
echartOptions: `{
|
||||
series: {
|
||||
name: 'San Francisco',
|
||||
type: 'line',
|
||||
data: [[0, 9]],
|
||||
itemStyle: { color: '#123456' }
|
||||
}
|
||||
}`,
|
||||
legendOrientation: LegendOrientation.Top,
|
||||
legendType: LegendType.Plain,
|
||||
showLegend: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(getCustomLegend(transformed)?.items).toEqual([
|
||||
expect.objectContaining({
|
||||
color: '#123456',
|
||||
name: 'San Francisco',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
test('keeps a hidden native legend model active for custom legend dispatch actions', () => {
|
||||
const transformed = transformProps(
|
||||
createTestChartProps({
|
||||
formData: {
|
||||
...formData,
|
||||
legendOrientation: LegendOrientation.Top,
|
||||
legendType: LegendType.Plain,
|
||||
showLegend: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
const chart = init(null, null, {
|
||||
height: transformed.height,
|
||||
renderer: 'svg',
|
||||
ssr: true,
|
||||
width: transformed.width,
|
||||
});
|
||||
|
||||
try {
|
||||
chart.setOption(transformed.echartOptions);
|
||||
const toggled = jest.fn();
|
||||
const inverted = jest.fn();
|
||||
const selectedAll = jest.fn();
|
||||
chart.on('legendselectchanged', toggled);
|
||||
chart.on('legendinverseselect', inverted);
|
||||
chart.on('legendselectall', selectedAll);
|
||||
chart.dispatchAction({
|
||||
name: 'San Francisco',
|
||||
type: 'legendToggleSelect',
|
||||
});
|
||||
|
||||
expect((transformed.echartOptions.legend as { show?: boolean }).show).toBe(
|
||||
false,
|
||||
);
|
||||
expect(toggled).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
selected: expect.objectContaining({
|
||||
'New York': true,
|
||||
'San Francisco': false,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
chart.dispatchAction({ type: 'legendInverseSelect' });
|
||||
expect(inverted).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
selected: expect.objectContaining({
|
||||
'New York': false,
|
||||
'San Francisco': true,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
chart.dispatchAction({ type: 'legendAllSelect' });
|
||||
expect(selectedAll).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
selected: expect.objectContaining({
|
||||
'New York': true,
|
||||
'San Francisco': true,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
chart.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test('derives custom legend visuals from the final reordered Forecast series', () => {
|
||||
const legendNames = ['Forecast Alpha', 'Forecast Beta'];
|
||||
const forecastValues = Object.fromEntries(
|
||||
legendNames.flatMap((name, index) => [
|
||||
[name, index + 1],
|
||||
[`${name}${ForecastSeriesEnum.ForecastLower}`, index],
|
||||
[`${name}${ForecastSeriesEnum.ForecastUpper}`, index + 2],
|
||||
[`${name}${ForecastSeriesEnum.ForecastTrend}`, index + 1.5],
|
||||
]),
|
||||
);
|
||||
const transformed = transformProps(
|
||||
createTestChartProps({
|
||||
formData: {
|
||||
...formData,
|
||||
forecastEnabled: true,
|
||||
legendOrientation: LegendOrientation.Top,
|
||||
legendType: LegendType.Plain,
|
||||
showLegend: true,
|
||||
},
|
||||
queriesData: [
|
||||
createTestQueryData(
|
||||
createTestData([forecastValues], { intervalMs: 300000000 }),
|
||||
),
|
||||
],
|
||||
}),
|
||||
);
|
||||
const renderedSeries = transformed.echartOptions.series as SeriesOption[];
|
||||
const customLegend = getCustomLegend(transformed);
|
||||
|
||||
legendNames.forEach(name => {
|
||||
const representative = renderedSeries.find(series => series.name === name);
|
||||
const item = customLegend?.items.find(candidate => candidate.name === name);
|
||||
|
||||
expect(representative?.id).toBe(
|
||||
`${name}${ForecastSeriesEnum.ForecastLower}`,
|
||||
);
|
||||
expect(item?.color).toBe(
|
||||
(representative as { itemStyle?: { color?: string } } | undefined)
|
||||
?.itemStyle?.color,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('honors user-selected plain legend type for top orientation when space allows (#39540)', () => {
|
||||
// Regression test for issue #39540: switching the legend type control from
|
||||
// scroll to plain must reach the rendered ECharts config. Horizontal legends
|
||||
@@ -1195,8 +1557,9 @@ test('honors user-selected plain legend type for top orientation when space allo
|
||||
legend: { show?: boolean; type?: LegendType };
|
||||
};
|
||||
|
||||
expect(legend.show).toBe(true);
|
||||
expect(legend.show).toBe(false);
|
||||
expect(legend.type).toBe(LegendType.Plain);
|
||||
expect(getCustomLegend(transformProps(chartProps))).toBeDefined();
|
||||
});
|
||||
|
||||
test('honors user-selected plain legend type for bottom orientation when space allows (#39540)', () => {
|
||||
@@ -1213,8 +1576,9 @@ test('honors user-selected plain legend type for bottom orientation when space a
|
||||
legend: { show?: boolean; type?: LegendType };
|
||||
};
|
||||
|
||||
expect(legend.show).toBe(true);
|
||||
expect(legend.show).toBe(false);
|
||||
expect(legend.type).toBe(LegendType.Plain);
|
||||
expect(getCustomLegend(transformProps(chartProps))).toBeDefined();
|
||||
});
|
||||
|
||||
const timeCompareFormData: SqlaFormData = {
|
||||
|
||||
+31
-2
@@ -20,6 +20,10 @@ import {
|
||||
memo,
|
||||
ComponentType,
|
||||
ChangeEventHandler,
|
||||
CompositionEvent,
|
||||
CompositionEventHandler,
|
||||
FocusEvent,
|
||||
FocusEventHandler,
|
||||
useRef,
|
||||
useEffect,
|
||||
Ref,
|
||||
@@ -33,7 +37,9 @@ export interface SearchInputProps {
|
||||
count: number;
|
||||
value: string;
|
||||
onChange: ChangeEventHandler<HTMLInputElement>;
|
||||
onBlur?: () => void;
|
||||
onBlur?: FocusEventHandler<HTMLInputElement>;
|
||||
onCompositionStart?: CompositionEventHandler<HTMLInputElement>;
|
||||
onCompositionEnd?: CompositionEventHandler<HTMLInputElement>;
|
||||
inputRef?: Ref<InputRef>;
|
||||
}
|
||||
|
||||
@@ -56,6 +62,8 @@ function DefaultSearchInput({
|
||||
value,
|
||||
onChange,
|
||||
onBlur,
|
||||
onCompositionStart,
|
||||
onCompositionEnd,
|
||||
inputRef,
|
||||
}: SearchInputProps) {
|
||||
return (
|
||||
@@ -68,6 +76,8 @@ function DefaultSearchInput({
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onBlur={onBlur}
|
||||
onCompositionStart={onCompositionStart}
|
||||
onCompositionEnd={onCompositionEnd}
|
||||
className="form-control input-sm"
|
||||
/>
|
||||
</Space>
|
||||
@@ -87,10 +97,14 @@ export default (memo as <T>(fn: T) => T)(function GlobalFilter<
|
||||
}: GlobalFilterProps<D>) {
|
||||
const count = serverPagination ? rowCount : preGlobalFilteredRows.length;
|
||||
const inputRef = useRef<InputRef>(null);
|
||||
const isComposingRef = useRef(false);
|
||||
|
||||
const [value, setValue] = useAsyncState(
|
||||
filterValue,
|
||||
(newValue: string) => {
|
||||
if (isComposingRef.current) {
|
||||
return;
|
||||
}
|
||||
setGlobalFilter(newValue || undefined);
|
||||
},
|
||||
200,
|
||||
@@ -114,8 +128,21 @@ export default (memo as <T>(fn: T) => T)(function GlobalFilter<
|
||||
setValue(target.value);
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
const handleBlur = (e: FocusEvent<HTMLInputElement>) => {
|
||||
isSearchFocused.set(id, false);
|
||||
if (isComposingRef.current) {
|
||||
isComposingRef.current = false;
|
||||
setValue(e.currentTarget.value);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCompositionStart = () => {
|
||||
isComposingRef.current = true;
|
||||
};
|
||||
|
||||
const handleCompositionEnd = (e: CompositionEvent<HTMLInputElement>) => {
|
||||
isComposingRef.current = false;
|
||||
setValue(e.currentTarget.value);
|
||||
};
|
||||
|
||||
const SearchInput = searchInput || DefaultSearchInput;
|
||||
@@ -127,6 +154,8 @@ export default (memo as <T>(fn: T) => T)(function GlobalFilter<
|
||||
inputRef={inputRef}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
onCompositionStart={handleCompositionStart}
|
||||
onCompositionEnd={handleCompositionEnd}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -264,7 +264,14 @@ const VisuallyHidden = styled.label`
|
||||
border: 0;
|
||||
`;
|
||||
|
||||
function SearchInput({ value, onChange, onBlur, inputRef }: SearchInputProps) {
|
||||
function SearchInput({
|
||||
value,
|
||||
onChange,
|
||||
onBlur,
|
||||
onCompositionStart,
|
||||
onCompositionEnd,
|
||||
inputRef,
|
||||
}: SearchInputProps) {
|
||||
return (
|
||||
<Space direction="vertical" size={4} className="dt-global-filter">
|
||||
<span aria-hidden="true">{t('Search')}</span>
|
||||
@@ -275,6 +282,8 @@ function SearchInput({ value, onChange, onBlur, inputRef }: SearchInputProps) {
|
||||
size="small"
|
||||
onChange={onChange}
|
||||
onBlur={onBlur}
|
||||
onCompositionStart={onCompositionStart}
|
||||
onCompositionEnd={onCompositionEnd}
|
||||
ref={inputRef}
|
||||
/>
|
||||
</Space>
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
} from '@superset-ui/chart-controls';
|
||||
import { supersetTheme } from '@apache-superset/core/theme';
|
||||
import {
|
||||
act,
|
||||
render,
|
||||
screen,
|
||||
fireEvent,
|
||||
@@ -2687,6 +2688,253 @@ describe('plugin-chart-table', () => {
|
||||
expect(screen.queryByText('Search by')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test.each([
|
||||
{
|
||||
eventOrder: 'change before compositionend',
|
||||
commitComposition: (searchInput: HTMLElement) => {
|
||||
fireEvent.change(searchInput, { target: { value: '你好' } });
|
||||
fireEvent.compositionEnd(searchInput);
|
||||
},
|
||||
},
|
||||
{
|
||||
eventOrder: 'compositionend carrying the committed value',
|
||||
commitComposition: (searchInput: HTMLElement) => {
|
||||
fireEvent.compositionEnd(searchInput, {
|
||||
target: { value: '你好' },
|
||||
});
|
||||
},
|
||||
},
|
||||
])(
|
||||
'defers server-side search until IME composition ends ($eventOrder)',
|
||||
async ({ commitComposition }) => {
|
||||
jest.useFakeTimers();
|
||||
try {
|
||||
const setDataMask = jest.fn();
|
||||
const props = transformProps({
|
||||
...testData.raw,
|
||||
rawFormData: {
|
||||
...testData.raw.rawFormData,
|
||||
server_pagination: true,
|
||||
include_search: true,
|
||||
},
|
||||
hooks: { setDataMask },
|
||||
queriesData: [
|
||||
{
|
||||
...testData.raw.queriesData[0],
|
||||
colnames: ['name'],
|
||||
coltypes: [GenericDataType.String],
|
||||
data: [{ name: 'Michael' }, { name: 'John' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
render(
|
||||
ProviderWrapper({
|
||||
children: (
|
||||
<TableChart {...props} setDataMask={setDataMask} sticky={false} />
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
const searchInput = screen.getByRole('textbox');
|
||||
const searchCalls = () =>
|
||||
setDataMask.mock.calls.filter(([mask]) =>
|
||||
Object.prototype.hasOwnProperty.call(
|
||||
mask?.ownState ?? {},
|
||||
'searchText',
|
||||
),
|
||||
);
|
||||
|
||||
fireEvent.compositionStart(searchInput);
|
||||
fireEvent.change(searchInput, { target: { value: 'nihao' } });
|
||||
|
||||
await act(async () => {
|
||||
jest.advanceTimersByTime(300);
|
||||
});
|
||||
await act(async () => {
|
||||
jest.advanceTimersByTime(900);
|
||||
});
|
||||
expect(searchInput).toHaveValue('nihao');
|
||||
expect(searchCalls()).toHaveLength(0);
|
||||
|
||||
commitComposition(searchInput);
|
||||
|
||||
await act(async () => {
|
||||
jest.advanceTimersByTime(300);
|
||||
});
|
||||
await act(async () => {
|
||||
jest.advanceTimersByTime(900);
|
||||
});
|
||||
|
||||
const calls = searchCalls();
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0][0].ownState.searchText).toBe('你好');
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test('restores server-side search after composition is interrupted by blur', async () => {
|
||||
jest.useFakeTimers();
|
||||
try {
|
||||
const setDataMask = jest.fn();
|
||||
const props = transformProps({
|
||||
...testData.raw,
|
||||
rawFormData: {
|
||||
...testData.raw.rawFormData,
|
||||
server_pagination: true,
|
||||
include_search: true,
|
||||
},
|
||||
hooks: { setDataMask },
|
||||
queriesData: [
|
||||
{
|
||||
...testData.raw.queriesData[0],
|
||||
colnames: ['name'],
|
||||
coltypes: [GenericDataType.String],
|
||||
data: [{ name: 'Michael' }, { name: 'John' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
render(
|
||||
ProviderWrapper({
|
||||
children: (
|
||||
<TableChart {...props} setDataMask={setDataMask} sticky={false} />
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
const searchInput = screen.getByRole('textbox');
|
||||
const searchCalls = () =>
|
||||
setDataMask.mock.calls.filter(([mask]) =>
|
||||
Object.prototype.hasOwnProperty.call(
|
||||
mask?.ownState ?? {},
|
||||
'searchText',
|
||||
),
|
||||
);
|
||||
|
||||
fireEvent.compositionStart(searchInput);
|
||||
fireEvent.change(searchInput, { target: { value: 'nihao' } });
|
||||
|
||||
await act(async () => {
|
||||
jest.advanceTimersByTime(300);
|
||||
});
|
||||
await act(async () => {
|
||||
jest.advanceTimersByTime(900);
|
||||
});
|
||||
expect(searchInput).toHaveValue('nihao');
|
||||
expect(searchCalls()).toHaveLength(0);
|
||||
|
||||
fireEvent.blur(searchInput);
|
||||
expect(searchCalls()).toHaveLength(0);
|
||||
|
||||
fireEvent.change(searchInput, { target: { value: 'hello' } });
|
||||
|
||||
await act(async () => {
|
||||
jest.advanceTimersByTime(300);
|
||||
});
|
||||
await act(async () => {
|
||||
jest.advanceTimersByTime(900);
|
||||
});
|
||||
|
||||
const calls = searchCalls();
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0][0].ownState.searchText).toBe('hello');
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
test.each([
|
||||
{
|
||||
eventOrder: 'compositionend before blur',
|
||||
pauseBeforeLeaving: 50,
|
||||
leaveInput: (searchInput: HTMLElement) => {
|
||||
fireEvent.compositionEnd(searchInput, {
|
||||
target: { value: 'nihao' },
|
||||
});
|
||||
fireEvent.blur(searchInput);
|
||||
},
|
||||
},
|
||||
{
|
||||
eventOrder: 'blur without compositionend',
|
||||
pauseBeforeLeaving: 50,
|
||||
leaveInput: (searchInput: HTMLElement) => {
|
||||
fireEvent.blur(searchInput);
|
||||
},
|
||||
},
|
||||
{
|
||||
eventOrder: 'blur without compositionend after the debounce fired',
|
||||
pauseBeforeLeaving: 300,
|
||||
leaveInput: (searchInput: HTMLElement) => {
|
||||
fireEvent.blur(searchInput);
|
||||
},
|
||||
},
|
||||
])(
|
||||
'searches the input value after blur mid-composition ($eventOrder)',
|
||||
async ({ pauseBeforeLeaving, leaveInput }) => {
|
||||
jest.useFakeTimers();
|
||||
try {
|
||||
const setDataMask = jest.fn();
|
||||
const props = transformProps({
|
||||
...testData.raw,
|
||||
rawFormData: {
|
||||
...testData.raw.rawFormData,
|
||||
server_pagination: true,
|
||||
include_search: true,
|
||||
},
|
||||
hooks: { setDataMask },
|
||||
queriesData: [
|
||||
{
|
||||
...testData.raw.queriesData[0],
|
||||
colnames: ['name'],
|
||||
coltypes: [GenericDataType.String],
|
||||
data: [{ name: 'Michael' }, { name: 'John' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
render(
|
||||
ProviderWrapper({
|
||||
children: (
|
||||
<TableChart {...props} setDataMask={setDataMask} sticky={false} />
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
const searchInput = screen.getByRole('textbox');
|
||||
const searchCalls = () =>
|
||||
setDataMask.mock.calls.filter(([mask]) =>
|
||||
Object.prototype.hasOwnProperty.call(
|
||||
mask?.ownState ?? {},
|
||||
'searchText',
|
||||
),
|
||||
);
|
||||
|
||||
fireEvent.compositionStart(searchInput);
|
||||
fireEvent.change(searchInput, { target: { value: 'nihao' } });
|
||||
|
||||
await act(async () => {
|
||||
jest.advanceTimersByTime(pauseBeforeLeaving);
|
||||
});
|
||||
leaveInput(searchInput);
|
||||
expect(searchCalls()).toHaveLength(0);
|
||||
|
||||
await act(async () => {
|
||||
jest.advanceTimersByTime(300);
|
||||
});
|
||||
await act(async () => {
|
||||
jest.advanceTimersByTime(900);
|
||||
});
|
||||
|
||||
expect(searchInput).toHaveValue('nihao');
|
||||
const calls = searchCalls();
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0][0].ownState.searchText).toBe('nihao');
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'should read the totals row from the correct query when percent metrics ' +
|
||||
'use the "all records" calculation mode',
|
||||
|
||||
@@ -104,6 +104,7 @@ const dashboard: Dashboard = {
|
||||
charts: [],
|
||||
editors: [editorSubject],
|
||||
viewers: [],
|
||||
is_managed_externally: false,
|
||||
};
|
||||
|
||||
jest.mock('src/utils/getBootstrapData', () => ({
|
||||
|
||||
@@ -27,6 +27,10 @@ import {
|
||||
within,
|
||||
} from 'spec/helpers/testing-library';
|
||||
import fetchMock from 'fetch-mock';
|
||||
import type {
|
||||
SelectValue,
|
||||
SelectOptionsPagePromise,
|
||||
} from '@superset-ui/core/components/Select';
|
||||
|
||||
import * as saveModalActions from 'src/explore/actions/saveModalActions';
|
||||
import SaveModal, {
|
||||
@@ -45,14 +49,34 @@ jest.mock('src/utils/getBootstrapData', () => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
// Captures the AsyncSelect `options` loader (SaveModal's loadDashboards) so
|
||||
// tests can invoke it directly and assert on the request it issues.
|
||||
let mockLoadDashboards: SelectOptionsPagePromise | undefined;
|
||||
|
||||
jest.mock('@superset-ui/core/components/Select', () => ({
|
||||
...jest.requireActual('@superset-ui/core/components/Select/AsyncSelect'),
|
||||
AsyncSelect: ({ onChange }: { onChange: (val: any) => void }) => (
|
||||
<input
|
||||
data-test="mock-async-select"
|
||||
onChange={({ target: { value } }) => onChange({ label: value, value })}
|
||||
/>
|
||||
),
|
||||
AsyncSelect: ({
|
||||
onChange,
|
||||
options,
|
||||
value,
|
||||
}: {
|
||||
onChange: (val: SelectValue) => void;
|
||||
options?: SelectOptionsPagePromise;
|
||||
value?: { label?: string } | null;
|
||||
}) => {
|
||||
mockLoadDashboards = options;
|
||||
return (
|
||||
<input
|
||||
data-test="mock-async-select"
|
||||
// Surfaces the currently selected label so tests can assert on what
|
||||
// the user actually sees, rather than only on side-effect requests.
|
||||
value={value?.label ?? ''}
|
||||
onChange={({ target: { value: newValue } }) =>
|
||||
onChange({ label: newValue, value: newValue })
|
||||
}
|
||||
/>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('@superset-ui/core/components/TreeSelect', () => ({
|
||||
@@ -133,14 +157,24 @@ const queryStore = mockStore({
|
||||
const fetchChartEndpoint = `glob:*/api/v1/chart/${1}*`;
|
||||
const fetchDashboardEndpoint = `glob:*/api/v1/dashboard/*`;
|
||||
|
||||
beforeAll(() => {
|
||||
const registerDefaultRoutes = () => {
|
||||
fetchMock.get(fetchChartEndpoint, { id: 1, dashboards: [1] });
|
||||
fetchMock.get(fetchDashboardEndpoint, {
|
||||
result: [{ id: 'id', dashboard_title: 'dashboard title' }],
|
||||
});
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
registerDefaultRoutes();
|
||||
});
|
||||
|
||||
afterAll(() => fetchMock.clearHistory());
|
||||
// Guaranteed teardown so per-test route overrides can never leak into later
|
||||
// tests, even if an assertion fails before any inline cleanup would run.
|
||||
afterEach(() => {
|
||||
fetchMock.removeRoutes();
|
||||
fetchMock.clearHistory();
|
||||
mockLoadDashboards = undefined;
|
||||
});
|
||||
|
||||
const setup = (
|
||||
props: Record<string, any> = defaultProps,
|
||||
@@ -268,6 +302,78 @@ test('renders a message when saving as with new dashboard', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('does not preselect an externally managed dashboard on mount', async () => {
|
||||
const dashboardId = 1;
|
||||
fetchMock.removeRoutes();
|
||||
fetchMock.get(fetchChartEndpoint, { id: 1, dashboards: [1] });
|
||||
fetchMock.get(`glob:*/api/v1/dashboard/${dashboardId}`, {
|
||||
result: {
|
||||
id: dashboardId,
|
||||
dashboard_title: 'Managed Dashboard',
|
||||
owners: [{ id: 1 }],
|
||||
is_managed_externally: true,
|
||||
},
|
||||
});
|
||||
|
||||
const store = mockStore({
|
||||
...initialState,
|
||||
explore: {
|
||||
...initialState.explore,
|
||||
slice: {
|
||||
...initialState.explore.slice,
|
||||
dashboards: [dashboardId],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { queryByTestId } = setup(
|
||||
{
|
||||
...defaultProps,
|
||||
dashboardId,
|
||||
},
|
||||
store,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
fetchMock.callHistory.calls(`glob:*/api/v1/dashboard/${dashboardId}`),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
const selectInput = queryByTestId('mock-async-select') as HTMLInputElement;
|
||||
expect(selectInput).toBeInTheDocument();
|
||||
// Assert on what the user actually sees: the externally managed dashboard
|
||||
// must never appear as the selected value.
|
||||
expect(selectInput.value).toBe('');
|
||||
expect(
|
||||
fetchMock.callHistory.calls(`glob:*/api/v1/dashboard/${dashboardId}/tabs`),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('loadDashboards includes is_managed_externally filter', async () => {
|
||||
const dashboardListEndpoint = `glob:*/api/v1/dashboard/?q=*`;
|
||||
|
||||
fetchMock.removeRoutes();
|
||||
fetchMock.clearHistory();
|
||||
fetchMock.get(fetchChartEndpoint, { id: 1, dashboards: [1] });
|
||||
fetchMock.get(dashboardListEndpoint, {
|
||||
result: [{ id: 1, dashboard_title: 'Test' }],
|
||||
count: 1,
|
||||
});
|
||||
fetchMock.get(fetchDashboardEndpoint, {
|
||||
result: [{ id: 'id', dashboard_title: 'dashboard title' }],
|
||||
});
|
||||
|
||||
setup();
|
||||
|
||||
await waitFor(() => expect(mockLoadDashboards).toBeDefined());
|
||||
await mockLoadDashboards!('test', 0, 25);
|
||||
|
||||
const calls = fetchMock.callHistory.calls(dashboardListEndpoint);
|
||||
const lastCall = calls[calls.length - 1];
|
||||
expect(lastCall.url).toContain('is_managed_externally');
|
||||
});
|
||||
|
||||
test('disables overwrite option for new slice', () => {
|
||||
const { getByRole } = setup(
|
||||
{},
|
||||
|
||||
@@ -341,7 +341,10 @@ const SaveModal = ({
|
||||
if (dashboardId) {
|
||||
try {
|
||||
const result = (await loadDashboard(dashboardId)) as Dashboard;
|
||||
if (canUserEditDashboard(result, user)) {
|
||||
if (
|
||||
canUserEditDashboard(result, user) &&
|
||||
!result.is_managed_externally
|
||||
) {
|
||||
setDashboard({ label: result.dashboard_title, value: result.id });
|
||||
await loadTabs(dashboardId);
|
||||
}
|
||||
@@ -362,7 +365,11 @@ const SaveModal = ({
|
||||
for (const { id } of metadataDashboards) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const result = await loadDashboard(id).catch(() => null);
|
||||
if (result && canUserEditDashboard(result, user)) {
|
||||
if (
|
||||
result &&
|
||||
canUserEditDashboard(result, user) &&
|
||||
!result.is_managed_externally
|
||||
) {
|
||||
editable = result as Dashboard;
|
||||
break;
|
||||
}
|
||||
@@ -641,6 +648,11 @@ const SaveModal = ({
|
||||
opr: 'is_editable',
|
||||
value: true,
|
||||
},
|
||||
{
|
||||
col: 'is_managed_externally',
|
||||
opr: 'eq',
|
||||
value: false,
|
||||
},
|
||||
],
|
||||
page,
|
||||
page_size: pageSize,
|
||||
|
||||
@@ -39,6 +39,7 @@ export interface Dashboard {
|
||||
// `columns` projection. Bare ids, not Subjects.
|
||||
extra_editors?: number[];
|
||||
viewers?: Subject[];
|
||||
is_managed_externally: boolean;
|
||||
theme?: {
|
||||
id: number;
|
||||
theme_name: string;
|
||||
|
||||
@@ -778,3 +778,158 @@ describe('navigateTo duplicate-assign suppression', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('openBlankTab / navigateOpenedTab / closeOpenedTab', () => {
|
||||
let openSpy: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
// Default to the blocked-popup return so a test that forgets to set one
|
||||
// does not fall through to jsdom's unimplemented window.open.
|
||||
openSpy = jest.spyOn(window, 'open').mockImplementation(() => null);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
openSpy.mockRestore();
|
||||
});
|
||||
|
||||
const makeTab = () =>
|
||||
({
|
||||
closed: false,
|
||||
close: jest.fn(),
|
||||
location: { replace: jest.fn() },
|
||||
}) as unknown as Window;
|
||||
|
||||
test('openBlankTab opens the placeholder without noopener so the handle is usable', async () => {
|
||||
// Regression: opening a version as new stranded a blank about:blank tab
|
||||
// because window.open(..., 'noopener') returns null, so openBlankTab handed
|
||||
// the caller no window handle to navigate.
|
||||
const tab = makeTab();
|
||||
openSpy.mockReturnValue(tab);
|
||||
const { openBlankTab } = await import('src/utils/navigationUtils');
|
||||
|
||||
const result = openBlankTab();
|
||||
|
||||
expect(result).toBe(tab);
|
||||
// Assert the exact call, not merely the absence of the `noopener` token:
|
||||
// `noreferrer` also forces window.open to return null, so a features arg of
|
||||
// any kind would reship the stranded-tab bug. Only a two-arg call keeps the
|
||||
// handle.
|
||||
expect(openSpy).toHaveBeenCalledTimes(1);
|
||||
expect(openSpy).toHaveBeenCalledWith('', '_blank');
|
||||
});
|
||||
|
||||
test('openBlankTab returns null when the browser blocks the popup', async () => {
|
||||
openSpy.mockReturnValue(null);
|
||||
const { openBlankTab } = await import('src/utils/navigationUtils');
|
||||
|
||||
// The null is the contract callers rely on to detect a blocked popup.
|
||||
expect(openBlankTab()).toBeNull();
|
||||
});
|
||||
|
||||
test('navigateOpenedTab points a live claimed tab at the resolved URL', async () => {
|
||||
await withApplicationRoot('', async () => {
|
||||
const tab = makeTab();
|
||||
const { navigateOpenedTab } = await import('src/utils/navigationUtils');
|
||||
|
||||
navigateOpenedTab(tab, '/dashboard/9/');
|
||||
|
||||
expect(tab.location.replace as jest.Mock).toHaveBeenCalledWith(
|
||||
'/dashboard/9/',
|
||||
);
|
||||
// The live-handle branch must NOT fall through to a second window.open,
|
||||
// which by the time it runs has lost user activation and is popup-blocked.
|
||||
expect(openSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
test('navigateOpenedTab prefixes the app root before replacing', async () => {
|
||||
await withApplicationRoot('/superset/', async () => {
|
||||
const tab = makeTab();
|
||||
const { navigateOpenedTab } = await import('src/utils/navigationUtils');
|
||||
|
||||
navigateOpenedTab(tab, '/dashboard/9/');
|
||||
|
||||
expect(tab.location.replace as jest.Mock).toHaveBeenCalledWith(
|
||||
'/superset/dashboard/9/',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('navigateOpenedTab falls back to a fresh window.open when the tab is null', async () => {
|
||||
await withApplicationRoot('', async () => {
|
||||
openSpy.mockReturnValue(null);
|
||||
const { navigateOpenedTab } = await import('src/utils/navigationUtils');
|
||||
|
||||
navigateOpenedTab(null, '/explore/?slice_id=1');
|
||||
|
||||
expect(openSpy).toHaveBeenCalledWith(
|
||||
'/explore/?slice_id=1',
|
||||
'_blank',
|
||||
'noopener noreferrer',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('navigateOpenedTab falls back when the claimed tab was already closed', async () => {
|
||||
await withApplicationRoot('', async () => {
|
||||
const tab = { ...makeTab(), closed: true } as unknown as Window;
|
||||
openSpy.mockReturnValue(null);
|
||||
const { navigateOpenedTab } = await import('src/utils/navigationUtils');
|
||||
|
||||
navigateOpenedTab(tab, '/explore/?slice_id=1');
|
||||
|
||||
expect(tab.location.replace as jest.Mock).not.toHaveBeenCalled();
|
||||
expect(openSpy).toHaveBeenCalledWith(
|
||||
'/explore/?slice_id=1',
|
||||
'_blank',
|
||||
'noopener noreferrer',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('navigateOpenedTab does not expose the opener-connected tab to an external URL', async () => {
|
||||
await withApplicationRoot('', async () => {
|
||||
const tab = makeTab();
|
||||
const { navigateOpenedTab } = await import('src/utils/navigationUtils');
|
||||
|
||||
// A safe-but-absolute URL is permitted by assertSafeNavigationUrl, but
|
||||
// must not be navigated on the noopener-less claimed tab: it is closed
|
||||
// and reopened through the noopener fallback instead.
|
||||
navigateOpenedTab(tab, 'https://example.com/');
|
||||
|
||||
expect(tab.location.replace as jest.Mock).not.toHaveBeenCalled();
|
||||
expect(tab.close as jest.Mock).toHaveBeenCalledTimes(1);
|
||||
expect(openSpy).toHaveBeenCalledWith(
|
||||
'https://example.com/',
|
||||
'_blank',
|
||||
'noopener noreferrer',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('navigateOpenedTab validates the URL before touching a claimed tab', async () => {
|
||||
await withApplicationRoot('', async () => {
|
||||
const tab = makeTab();
|
||||
const { navigateOpenedTab } = await import('src/utils/navigationUtils');
|
||||
|
||||
expect(() => navigateOpenedTab(tab, '//evil.com')).toThrow();
|
||||
expect(tab.location.replace as jest.Mock).not.toHaveBeenCalled();
|
||||
expect(tab.close as jest.Mock).not.toHaveBeenCalled();
|
||||
expect(openSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
test('closeOpenedTab closes a live tab and no-ops on null or already-closed', async () => {
|
||||
const { closeOpenedTab } = await import('src/utils/navigationUtils');
|
||||
const tab = makeTab();
|
||||
|
||||
closeOpenedTab(tab);
|
||||
expect(tab.close as jest.Mock).toHaveBeenCalledTimes(1);
|
||||
|
||||
const closed = { ...makeTab(), closed: true } as unknown as Window;
|
||||
closeOpenedTab(closed);
|
||||
expect(closed.close as jest.Mock).not.toHaveBeenCalled();
|
||||
|
||||
expect(() => closeOpenedTab(null)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -466,9 +466,18 @@ export function openInNewTab(path: string): void {
|
||||
* refusal is silent -- `window.open` just returns null -- so a caller that
|
||||
* awaits first appears to do nothing at all. Call this synchronously in the
|
||||
* handler and hand the result to `navigateOpenedTab` when the URL is known.
|
||||
*
|
||||
* The placeholder is opened WITHOUT `noopener`: per the HTML standard,
|
||||
* `window.open(..., 'noopener')` always returns null, which would discard the
|
||||
* very handle this function exists to return and leave the caller with a
|
||||
* stranded `about:blank` tab it can never navigate. The destination is always
|
||||
* a same-origin app route (`ensureAppRoot` in `navigateOpenedTab`), so the
|
||||
* opener relationship carries no cross-origin tabnabbing risk. The one-shot
|
||||
* `window.open(url, ...)` fallback in `navigateOpenedTab` keeps `noopener`,
|
||||
* since it passes the real URL and never needs the handle.
|
||||
*/
|
||||
export function openBlankTab(): Window | null {
|
||||
return window.open('', '_blank', NEW_TAB_FEATURES);
|
||||
return window.open('', '_blank');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -477,13 +486,31 @@ export function openBlankTab(): Window | null {
|
||||
*
|
||||
* The URL is validated before either branch, so an unsafe path cannot reach
|
||||
* a pre-opened tab any more than it could reach `openInNewTab`.
|
||||
*
|
||||
* The external-URL branch (a safe absolute URL on a claimed tab) is defensive:
|
||||
* no current caller passes one, and by the time it would run the click's
|
||||
* activation has lapsed, so the reopen is popup-blocked and the placeholder
|
||||
* closes with nothing opening. It exists only to keep an absolute URL off the
|
||||
* opener-connected tab; a caller needing an external target after an await
|
||||
* should not claim a tab up front.
|
||||
*/
|
||||
export function navigateOpenedTab(tab: Window | null, path: string): void {
|
||||
const url = assertSafeNavigationUrl(ensureAppRoot(path));
|
||||
if (tab && !tab.closed) {
|
||||
// Only a same-origin app route may reuse the opener-connected placeholder
|
||||
// from `openBlankTab` (which drops `noopener` to keep its handle).
|
||||
// `ensureAppRoot` leaves an absolute URL untouched, so anything not starting
|
||||
// with a single `/` is external: it must not ride the opener chain. Route it
|
||||
// through the `noopener` fallback instead — closing the claimed tab first so
|
||||
// no `about:blank` is stranded — which makes the same-origin guarantee
|
||||
// structural rather than a caller convention.
|
||||
const isSameOriginRoute = url.startsWith('/') && !url.startsWith('//');
|
||||
if (isSameOriginRoute && tab && !tab.closed) {
|
||||
tab.location.replace(url);
|
||||
return;
|
||||
}
|
||||
if (tab && !tab.closed) {
|
||||
tab.close();
|
||||
}
|
||||
window.open(url, '_blank', NEW_TAB_FEATURES);
|
||||
}
|
||||
|
||||
|
||||
+10
-7
@@ -92,13 +92,16 @@ def update_api_docs() -> None:
|
||||
if isinstance(base_api, BaseApi) and base_api.version == api_version:
|
||||
base_api.add_api_spec(api_spec)
|
||||
version_found = True
|
||||
if version_found:
|
||||
click.secho("Generating openapi.json", fg="green")
|
||||
with open(openapi_json, "w") as outfile:
|
||||
json.dump(api_spec.to_dict(), outfile, sort_keys=True, indent=2)
|
||||
outfile.write("\n")
|
||||
else:
|
||||
click.secho("API version not found", err=True)
|
||||
if not version_found:
|
||||
# Exiting zero here would leave the stale file in place and report
|
||||
# success, which reads as "the spec is current" to any caller diffing
|
||||
# the result.
|
||||
raise click.ClickException(f"No {api_version} API found to document")
|
||||
|
||||
click.secho("Generating openapi.json", fg="green")
|
||||
with open(openapi_json, "w") as outfile:
|
||||
json.dump(api_spec.to_dict(), outfile, sort_keys=True, indent=2)
|
||||
outfile.write("\n")
|
||||
|
||||
|
||||
@click.command()
|
||||
|
||||
@@ -96,7 +96,7 @@ class CreateChartCommand(CreateMixin, BaseCommand):
|
||||
if len(dashboards) != len(dashboard_ids):
|
||||
exceptions.append(DashboardsNotFoundValidationError())
|
||||
for dash in dashboards:
|
||||
if not security_manager.is_editor(dash):
|
||||
if dash.is_managed_externally or not security_manager.is_editor(dash):
|
||||
raise DashboardsForbiddenError()
|
||||
self._properties["dashboards"] = dashboards
|
||||
|
||||
|
||||
@@ -115,7 +115,8 @@ class UpdateChartCommand(UpdateMixin, BaseCommand):
|
||||
requested_dashboard_ids = {d.id for d in requested_dashboards}
|
||||
|
||||
if new_dashboard_ids := requested_dashboard_ids - existing_dashboard_ids:
|
||||
# For NEW dashboard relationships, verify user has editorship
|
||||
# For NEW dashboard relationships, verify user has access first
|
||||
# to avoid leaking information about inaccessible dashboards
|
||||
accessible_dashboards = DashboardDAO.find_by_ids(list(new_dashboard_ids))
|
||||
unauthorized_dashboard_ids = new_dashboard_ids - {
|
||||
d.id for d in accessible_dashboards
|
||||
@@ -123,10 +124,10 @@ class UpdateChartCommand(UpdateMixin, BaseCommand):
|
||||
|
||||
if unauthorized_dashboard_ids:
|
||||
exceptions.append(DashboardsNotFoundValidationError())
|
||||
return
|
||||
|
||||
# Additional editorship check - must match CreateChartCommand behavior
|
||||
for dash in accessible_dashboards:
|
||||
if not security_manager.is_editor(dash):
|
||||
if dash.is_managed_externally or not security_manager.is_editor(dash):
|
||||
raise DashboardsForbiddenError()
|
||||
|
||||
def _validate_query_context_datasource(
|
||||
|
||||
@@ -479,6 +479,7 @@ class DashboardRestApi(
|
||||
"changed_by",
|
||||
"dashboard_title",
|
||||
"id",
|
||||
"is_managed_externally",
|
||||
"uuid",
|
||||
"editors",
|
||||
"viewers",
|
||||
|
||||
@@ -172,17 +172,18 @@ def create_slices(tbl: SqlaTable) -> tuple[list[Slice], list[Slice]]:
|
||||
]
|
||||
metric = "sum__num"
|
||||
|
||||
defaults = {
|
||||
shared_defaults = {
|
||||
"compare_lag": "10",
|
||||
"compare_suffix": "o10Y",
|
||||
"limit": "25",
|
||||
"granularity": "ds",
|
||||
"groupby": [],
|
||||
"row_limit": current_app.config["ROW_LIMIT"],
|
||||
"time_range": "100 years ago : now",
|
||||
"viz_type": "table",
|
||||
"markup_type": "markdown",
|
||||
}
|
||||
non_echarts_defaults = {**shared_defaults, "granularity": "ds"}
|
||||
echarts_x_axis_defaults = {**shared_defaults, "x_axis": "ds"}
|
||||
|
||||
default_query_context = {
|
||||
"result_format": "json",
|
||||
@@ -211,7 +212,7 @@ def create_slices(tbl: SqlaTable) -> tuple[list[Slice], list[Slice]]:
|
||||
slice_name="Participants",
|
||||
viz_type="big_number",
|
||||
params=get_slice_json(
|
||||
defaults,
|
||||
non_echarts_defaults,
|
||||
viz_type="big_number",
|
||||
granularity="ds",
|
||||
compare_lag="5",
|
||||
@@ -225,7 +226,10 @@ def create_slices(tbl: SqlaTable) -> tuple[list[Slice], list[Slice]]:
|
||||
slice_name="Genders",
|
||||
viz_type="pie",
|
||||
params=get_slice_json(
|
||||
defaults, viz_type="pie", groupby=["gender"], metric=metric
|
||||
non_echarts_defaults,
|
||||
viz_type="pie",
|
||||
groupby=["gender"],
|
||||
metric=metric,
|
||||
),
|
||||
editors=[],
|
||||
),
|
||||
@@ -234,10 +238,9 @@ def create_slices(tbl: SqlaTable) -> tuple[list[Slice], list[Slice]]:
|
||||
slice_name="Trends",
|
||||
viz_type="echarts_timeseries_line",
|
||||
params=get_slice_json(
|
||||
defaults,
|
||||
echarts_x_axis_defaults,
|
||||
viz_type="echarts_timeseries_line",
|
||||
groupby=["name"],
|
||||
granularity="ds",
|
||||
rich_tooltip=True,
|
||||
show_legend=True,
|
||||
metrics=metrics,
|
||||
@@ -249,7 +252,7 @@ def create_slices(tbl: SqlaTable) -> tuple[list[Slice], list[Slice]]:
|
||||
slice_name="Genders by State",
|
||||
viz_type="echarts_timeseries_bar",
|
||||
params=get_slice_json(
|
||||
defaults,
|
||||
echarts_x_axis_defaults,
|
||||
adhoc_filters=[
|
||||
{
|
||||
"clause": "WHERE",
|
||||
@@ -286,7 +289,7 @@ def create_slices(tbl: SqlaTable) -> tuple[list[Slice], list[Slice]]:
|
||||
slice_name="Girls",
|
||||
viz_type="table",
|
||||
params=get_slice_json(
|
||||
defaults,
|
||||
non_echarts_defaults,
|
||||
groupby=["name"],
|
||||
adhoc_filters=[gen_filter("gender", "girl")],
|
||||
row_limit=50,
|
||||
@@ -300,7 +303,7 @@ def create_slices(tbl: SqlaTable) -> tuple[list[Slice], list[Slice]]:
|
||||
slice_name="Girl Name Cloud",
|
||||
viz_type="word_cloud",
|
||||
params=get_slice_json(
|
||||
defaults,
|
||||
non_echarts_defaults,
|
||||
viz_type="word_cloud",
|
||||
size_from="10",
|
||||
series="name",
|
||||
@@ -317,7 +320,7 @@ def create_slices(tbl: SqlaTable) -> tuple[list[Slice], list[Slice]]:
|
||||
slice_name="Boys",
|
||||
viz_type="table",
|
||||
params=get_slice_json(
|
||||
defaults,
|
||||
non_echarts_defaults,
|
||||
groupby=["name"],
|
||||
adhoc_filters=[gen_filter("gender", "boy")],
|
||||
row_limit=50,
|
||||
@@ -331,7 +334,7 @@ def create_slices(tbl: SqlaTable) -> tuple[list[Slice], list[Slice]]:
|
||||
slice_name="Boy Name Cloud",
|
||||
viz_type="word_cloud",
|
||||
params=get_slice_json(
|
||||
defaults,
|
||||
non_echarts_defaults,
|
||||
viz_type="word_cloud",
|
||||
size_from="10",
|
||||
series="name",
|
||||
@@ -348,7 +351,7 @@ def create_slices(tbl: SqlaTable) -> tuple[list[Slice], list[Slice]]:
|
||||
slice_name="Top 10 Girl Name Share",
|
||||
viz_type="echarts_area",
|
||||
params=get_slice_json(
|
||||
defaults,
|
||||
echarts_x_axis_defaults,
|
||||
adhoc_filters=[gen_filter("gender", "girl")],
|
||||
comparison_type="values",
|
||||
groupby=["name"],
|
||||
@@ -356,7 +359,7 @@ def create_slices(tbl: SqlaTable) -> tuple[list[Slice], list[Slice]]:
|
||||
stacked_style="expand",
|
||||
time_grain_sqla="P1D",
|
||||
viz_type="echarts_area",
|
||||
x_axis_forma="smart_date",
|
||||
x_axis_time_format="smart_date",
|
||||
metrics=metrics,
|
||||
),
|
||||
editors=[],
|
||||
@@ -366,7 +369,7 @@ def create_slices(tbl: SqlaTable) -> tuple[list[Slice], list[Slice]]:
|
||||
slice_name="Top 10 Boy Name Share",
|
||||
viz_type="echarts_area",
|
||||
params=get_slice_json(
|
||||
defaults,
|
||||
echarts_x_axis_defaults,
|
||||
adhoc_filters=[gen_filter("gender", "boy")],
|
||||
comparison_type="values",
|
||||
groupby=["name"],
|
||||
@@ -374,7 +377,7 @@ def create_slices(tbl: SqlaTable) -> tuple[list[Slice], list[Slice]]:
|
||||
stacked_style="expand",
|
||||
time_grain_sqla="P1D",
|
||||
viz_type="echarts_area",
|
||||
x_axis_forma="smart_date",
|
||||
x_axis_time_format="smart_date",
|
||||
metrics=metrics,
|
||||
),
|
||||
editors=[],
|
||||
@@ -384,7 +387,7 @@ def create_slices(tbl: SqlaTable) -> tuple[list[Slice], list[Slice]]:
|
||||
slice_name="Pivot Table v2",
|
||||
viz_type="pivot_table_v2",
|
||||
params=get_slice_json(
|
||||
defaults,
|
||||
non_echarts_defaults,
|
||||
viz_type="pivot_table_v2",
|
||||
groupbyRows=["name"],
|
||||
groupbyColumns=["state"],
|
||||
@@ -408,7 +411,7 @@ def create_slices(tbl: SqlaTable) -> tuple[list[Slice], list[Slice]]:
|
||||
slice_name="Average and Sum Trends",
|
||||
viz_type="mixed_timeseries",
|
||||
params=get_slice_json(
|
||||
defaults,
|
||||
echarts_x_axis_defaults,
|
||||
viz_type="mixed_timeseries",
|
||||
metrics=[
|
||||
{
|
||||
@@ -420,7 +423,6 @@ def create_slices(tbl: SqlaTable) -> tuple[list[Slice], list[Slice]]:
|
||||
}
|
||||
],
|
||||
metrics_b=["sum__num"],
|
||||
granularity="ds",
|
||||
yAxisIndex=0,
|
||||
yAxisIndexB=1,
|
||||
),
|
||||
@@ -431,7 +433,9 @@ def create_slices(tbl: SqlaTable) -> tuple[list[Slice], list[Slice]]:
|
||||
slice_name="Num Births Trend",
|
||||
viz_type="echarts_timeseries_line",
|
||||
params=get_slice_json(
|
||||
defaults, viz_type="echarts_timeseries_line", metrics=metrics
|
||||
echarts_x_axis_defaults,
|
||||
viz_type="echarts_timeseries_line",
|
||||
metrics=metrics,
|
||||
),
|
||||
editors=[],
|
||||
),
|
||||
@@ -440,7 +444,7 @@ def create_slices(tbl: SqlaTable) -> tuple[list[Slice], list[Slice]]:
|
||||
slice_name="Daily Totals",
|
||||
viz_type="table",
|
||||
params=get_slice_json(
|
||||
defaults,
|
||||
non_echarts_defaults,
|
||||
groupby=["ds"],
|
||||
time_range="1983 : 2023",
|
||||
viz_type="table",
|
||||
@@ -463,7 +467,7 @@ def create_slices(tbl: SqlaTable) -> tuple[list[Slice], list[Slice]]:
|
||||
slice_name="Number of California Births",
|
||||
viz_type="big_number_total",
|
||||
params=get_slice_json(
|
||||
defaults,
|
||||
non_echarts_defaults,
|
||||
metric={
|
||||
"expressionType": "SIMPLE",
|
||||
"column": {
|
||||
@@ -483,7 +487,7 @@ def create_slices(tbl: SqlaTable) -> tuple[list[Slice], list[Slice]]:
|
||||
slice_name="Top 10 California Names Timeseries",
|
||||
viz_type="echarts_timeseries_line",
|
||||
params=get_slice_json(
|
||||
defaults,
|
||||
echarts_x_axis_defaults,
|
||||
metrics=[
|
||||
{
|
||||
"expressionType": "SIMPLE",
|
||||
@@ -496,7 +500,6 @@ def create_slices(tbl: SqlaTable) -> tuple[list[Slice], list[Slice]]:
|
||||
}
|
||||
],
|
||||
viz_type="echarts_timeseries_line",
|
||||
granularity="ds",
|
||||
groupby=["name"],
|
||||
series_limit_metric={
|
||||
"expressionType": "SIMPLE",
|
||||
@@ -518,7 +521,7 @@ def create_slices(tbl: SqlaTable) -> tuple[list[Slice], list[Slice]]:
|
||||
slice_name="Names Sorted by Num in California",
|
||||
viz_type="table",
|
||||
params=get_slice_json(
|
||||
defaults,
|
||||
non_echarts_defaults,
|
||||
metrics=metrics,
|
||||
groupby=["name"],
|
||||
row_limit=50,
|
||||
@@ -539,7 +542,7 @@ def create_slices(tbl: SqlaTable) -> tuple[list[Slice], list[Slice]]:
|
||||
slice_name="Number of Girls",
|
||||
viz_type="big_number_total",
|
||||
params=get_slice_json(
|
||||
defaults,
|
||||
non_echarts_defaults,
|
||||
metric=metric,
|
||||
viz_type="big_number_total",
|
||||
granularity="ds",
|
||||
@@ -553,7 +556,7 @@ def create_slices(tbl: SqlaTable) -> tuple[list[Slice], list[Slice]]:
|
||||
slice_name="Pivot Table",
|
||||
viz_type="pivot_table_v2",
|
||||
params=get_slice_json(
|
||||
defaults,
|
||||
non_echarts_defaults,
|
||||
viz_type="pivot_table_v2",
|
||||
groupbyRows=["name"],
|
||||
groupbyColumns=["state"],
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
# under the License.
|
||||
always_filter_main_dttm: false
|
||||
cache_timeout: null
|
||||
catalog: examples
|
||||
catalog: null
|
||||
columns:
|
||||
- advanced_data_type: null
|
||||
column_name: start_time
|
||||
|
||||
@@ -27,7 +27,7 @@ params:
|
||||
color_scheme: supersetColors
|
||||
comparison_type: null
|
||||
datasource: 23__table
|
||||
granularity_sqla: order_date
|
||||
x_axis: order_date
|
||||
groupby:
|
||||
- product_line
|
||||
label_colors:
|
||||
|
||||
@@ -28,7 +28,7 @@ params:
|
||||
comparison_type: values
|
||||
contribution: false
|
||||
datasource: 23__table
|
||||
granularity_sqla: order_date
|
||||
x_axis: order_date
|
||||
groupby:
|
||||
- deal_size
|
||||
label_colors: {}
|
||||
|
||||
@@ -35,7 +35,7 @@ params:
|
||||
color_scheme: supersetColors
|
||||
comparison_type: values
|
||||
datasource: 56__table
|
||||
granularity_sqla: ts
|
||||
x_axis: ts
|
||||
groupby:
|
||||
- name
|
||||
label_colors:
|
||||
|
||||
@@ -30,7 +30,7 @@ params:
|
||||
subject: state
|
||||
compare_lag: '10'
|
||||
compare_suffix: o10Y
|
||||
granularity: ds
|
||||
x_axis: ds
|
||||
groupby:
|
||||
- state
|
||||
limit: '25'
|
||||
|
||||
@@ -29,7 +29,7 @@ params:
|
||||
compare_lag: '10'
|
||||
compare_suffix: o10Y
|
||||
comparison_type: values
|
||||
granularity: ds
|
||||
x_axis: ds
|
||||
groupby:
|
||||
- name
|
||||
limit: 10
|
||||
@@ -47,7 +47,7 @@ params:
|
||||
time_grain_sqla: P1D
|
||||
time_range: '100 years ago : now'
|
||||
viz_type: echarts_area
|
||||
x_axis_forma: smart_date
|
||||
x_axis_time_format: smart_date
|
||||
query_context: null
|
||||
slice_name: Top 10 Boy Name Share
|
||||
uuid: 26a9bde8-eb06-4de3-91f0-5e04d448403a
|
||||
|
||||
@@ -29,7 +29,7 @@ params:
|
||||
compare_lag: '10'
|
||||
compare_suffix: o10Y
|
||||
comparison_type: values
|
||||
granularity: ds
|
||||
x_axis: ds
|
||||
groupby:
|
||||
- name
|
||||
limit: 10
|
||||
@@ -47,7 +47,7 @@ params:
|
||||
time_grain_sqla: P1D
|
||||
time_range: '100 years ago : now'
|
||||
viz_type: echarts_area
|
||||
x_axis_forma: smart_date
|
||||
x_axis_time_format: smart_date
|
||||
query_context: null
|
||||
slice_name: Top 10 Girl Name Share
|
||||
uuid: 44c4c16f-216d-44c8-b033-6876b8c51fd2
|
||||
|
||||
@@ -22,7 +22,7 @@ description: null
|
||||
params:
|
||||
compare_lag: '10'
|
||||
compare_suffix: o10Y
|
||||
granularity: ds
|
||||
x_axis: ds
|
||||
groupby:
|
||||
- name
|
||||
limit: '25'
|
||||
|
||||
+1
-1
@@ -37,7 +37,7 @@ params:
|
||||
columns: []
|
||||
contribution: true
|
||||
datasource: 21__table
|
||||
granularity_sqla: year
|
||||
x_axis: year
|
||||
groupby:
|
||||
- name
|
||||
label_colors: {}
|
||||
|
||||
+1
-1
@@ -28,7 +28,7 @@ params:
|
||||
columns: []
|
||||
contribution: false
|
||||
datasource: 21__table
|
||||
granularity_sqla: year
|
||||
x_axis: year
|
||||
groupby:
|
||||
- genre
|
||||
label_colors:
|
||||
|
||||
@@ -150,11 +150,10 @@ def create_slices(tbl: BaseDatasource) -> list[Slice]:
|
||||
"hasCustomLabel": True,
|
||||
"label": "Rural Population",
|
||||
}
|
||||
defaults = {
|
||||
shared_defaults = {
|
||||
"compare_lag": "10",
|
||||
"compare_suffix": "o10Y",
|
||||
"limit": "25",
|
||||
"granularity": "year",
|
||||
"groupby": [],
|
||||
"row_limit": current_app.config["ROW_LIMIT"],
|
||||
"since": "2014-01-01",
|
||||
@@ -165,6 +164,8 @@ def create_slices(tbl: BaseDatasource) -> list[Slice]:
|
||||
"entity": "country_code",
|
||||
"show_bubbles": True,
|
||||
}
|
||||
non_echarts_defaults = {**shared_defaults, "granularity": "year"}
|
||||
echarts_x_axis_defaults = {**shared_defaults, "x_axis": "year"}
|
||||
|
||||
return [
|
||||
Slice(
|
||||
@@ -173,7 +174,7 @@ def create_slices(tbl: BaseDatasource) -> list[Slice]:
|
||||
datasource_type=DatasourceType.TABLE,
|
||||
datasource_id=tbl.id,
|
||||
params=get_slice_json(
|
||||
defaults,
|
||||
non_echarts_defaults,
|
||||
since="2000",
|
||||
viz_type="big_number",
|
||||
compare_lag="10",
|
||||
@@ -187,7 +188,7 @@ def create_slices(tbl: BaseDatasource) -> list[Slice]:
|
||||
datasource_type=DatasourceType.TABLE,
|
||||
datasource_id=tbl.id,
|
||||
params=get_slice_json(
|
||||
defaults,
|
||||
non_echarts_defaults,
|
||||
viz_type="table",
|
||||
metrics=["sum__SP_POP_TOTL"],
|
||||
groupby=["country_name"],
|
||||
@@ -199,7 +200,7 @@ def create_slices(tbl: BaseDatasource) -> list[Slice]:
|
||||
datasource_type=DatasourceType.TABLE,
|
||||
datasource_id=tbl.id,
|
||||
params=get_slice_json(
|
||||
defaults,
|
||||
echarts_x_axis_defaults,
|
||||
viz_type="echarts_timeseries_line",
|
||||
since="1960-01-01",
|
||||
metrics=["sum__SP_POP_TOTL"],
|
||||
@@ -213,7 +214,7 @@ def create_slices(tbl: BaseDatasource) -> list[Slice]:
|
||||
datasource_type=DatasourceType.TABLE,
|
||||
datasource_id=tbl.id,
|
||||
params=get_slice_json(
|
||||
defaults,
|
||||
non_echarts_defaults,
|
||||
viz_type="world_map",
|
||||
metric="sum__SP_RUR_TOTL_ZS",
|
||||
num_period_compare="10",
|
||||
@@ -226,7 +227,7 @@ def create_slices(tbl: BaseDatasource) -> list[Slice]:
|
||||
datasource_type=DatasourceType.TABLE,
|
||||
datasource_id=tbl.id,
|
||||
params=get_slice_json(
|
||||
defaults,
|
||||
non_echarts_defaults,
|
||||
viz_type="bubble_v2",
|
||||
since="2011-01-01",
|
||||
until="2011-01-02",
|
||||
@@ -270,7 +271,7 @@ def create_slices(tbl: BaseDatasource) -> list[Slice]:
|
||||
datasource_type=DatasourceType.TABLE,
|
||||
datasource_id=tbl.id,
|
||||
params=get_slice_json(
|
||||
defaults,
|
||||
non_echarts_defaults,
|
||||
viz_type="sunburst_v2",
|
||||
columns=["region", "country_name"],
|
||||
since="2011-01-01",
|
||||
@@ -285,7 +286,7 @@ def create_slices(tbl: BaseDatasource) -> list[Slice]:
|
||||
datasource_type=DatasourceType.TABLE,
|
||||
datasource_id=tbl.id,
|
||||
params=get_slice_json(
|
||||
defaults,
|
||||
echarts_x_axis_defaults,
|
||||
since="1960-01-01",
|
||||
until="now",
|
||||
viz_type="echarts_area",
|
||||
@@ -299,7 +300,7 @@ def create_slices(tbl: BaseDatasource) -> list[Slice]:
|
||||
datasource_type=DatasourceType.TABLE,
|
||||
datasource_id=tbl.id,
|
||||
params=get_slice_json(
|
||||
defaults,
|
||||
non_echarts_defaults,
|
||||
since="1960-01-01",
|
||||
until="now",
|
||||
whisker_options="Min/max (no outliers)",
|
||||
@@ -315,7 +316,7 @@ def create_slices(tbl: BaseDatasource) -> list[Slice]:
|
||||
datasource_type=DatasourceType.TABLE,
|
||||
datasource_id=tbl.id,
|
||||
params=get_slice_json(
|
||||
defaults,
|
||||
non_echarts_defaults,
|
||||
since="1960-01-01",
|
||||
until="now",
|
||||
viz_type="treemap_v2",
|
||||
@@ -329,7 +330,7 @@ def create_slices(tbl: BaseDatasource) -> list[Slice]:
|
||||
datasource_type=DatasourceType.TABLE,
|
||||
datasource_id=tbl.id,
|
||||
params=get_slice_json(
|
||||
defaults,
|
||||
non_echarts_defaults,
|
||||
since="2011-01-01",
|
||||
until="2012-01-01",
|
||||
viz_type="para",
|
||||
|
||||
@@ -24,7 +24,7 @@ params:
|
||||
compare_suffix: o10Y
|
||||
country_fieldtype: cca3
|
||||
entity: country_code
|
||||
granularity: year
|
||||
x_axis: year
|
||||
groupby:
|
||||
- country_name
|
||||
limit: '25'
|
||||
|
||||
@@ -24,7 +24,7 @@ params:
|
||||
compare_suffix: o10Y
|
||||
country_fieldtype: cca3
|
||||
entity: country_code
|
||||
granularity: year
|
||||
x_axis: year
|
||||
groupby:
|
||||
- region
|
||||
limit: '25'
|
||||
|
||||
@@ -21,6 +21,7 @@ from .firebolt import Firebolt, FireboltOld
|
||||
from .hana import Hana
|
||||
from .opensearch import OpenSearch
|
||||
from .pinot import Pinot
|
||||
from .starrocks import StarRocks
|
||||
from .vertica import Vertica
|
||||
|
||||
__all__ = [
|
||||
@@ -31,5 +32,6 @@ __all__ = [
|
||||
"Hana",
|
||||
"OpenSearch",
|
||||
"Pinot",
|
||||
"StarRocks",
|
||||
"Vertica",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,813 @@
|
||||
# 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.
|
||||
|
||||
"""
|
||||
StarRocks dialect for Superset, extending sqlglot's built-in StarRocks dialect.
|
||||
|
||||
sqlglot's StarRocks parser inherits almost all of its grammar from MySQL, which
|
||||
doesn't model a long tail of StarRocks-only syntax: catalog-qualified schema
|
||||
references, aggregate/primary-key column shorthand, admin/ops statements, and
|
||||
several ALTER/CREATE/SHOW clause variants. Each override below closes one gap
|
||||
found while auditing StarRocks' SQL reference against this dialect; see the
|
||||
docstring on each method for the specific construct it fixes.
|
||||
|
||||
The Generator override closes a related gap: sqlglot's stock StarRocks
|
||||
generator round-trips some of the AST shapes built above (REFRESH
|
||||
CONNECTIONS, ALTER TABLE ADD ROLLUP, dual-bound range partitions, TIME_SLICE)
|
||||
incorrectly. That matters beyond cosmetics -- SQL Lab regenerates SQL from
|
||||
this AST via `format()` for every statement it executes, so an incorrect
|
||||
round-trip sends malformed or semantically wrong SQL to the database.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlglot import exp
|
||||
from sqlglot.dialects.starrocks import StarRocks as _StarRocks
|
||||
from sqlglot.errors import ParseError
|
||||
from sqlglot.generators.starrocks import StarRocksGenerator as _StarRocksGenerator
|
||||
from sqlglot.helper import seq_get
|
||||
from sqlglot.parsers.starrocks import StarRocksParser as _StarRocksParser
|
||||
from sqlglot.tokens import TokenType
|
||||
|
||||
# Head keywords for StarRocks statements that sqlglot's MySQL-derived grammar
|
||||
# doesn't model at all (no dedicated TokenType, no STATEMENT_PARSERS entry).
|
||||
# Without this, the parser tries to read them as a generic expression/alias
|
||||
# and raises a ParseError. Mapping them to TokenType.COMMAND opts them into
|
||||
# the same generic "slurp the rest of the statement as an opaque command"
|
||||
# fallback already used by CALL/EXPLAIN/OPTIMIZE/PREPARE/VACUUM, producing a
|
||||
# structured-enough `exp.Command` instead of crashing. This is safe only for
|
||||
# words that have no other meaning elsewhere in the grammar -- ADD and DELETE
|
||||
# are handled separately below because they're already meaningful keywords.
|
||||
_STARROCKS_COMMAND_KEYWORDS = (
|
||||
"ADMIN",
|
||||
"BACKUP",
|
||||
"RESTORE",
|
||||
"RECOVER",
|
||||
"CANCEL",
|
||||
"EXPORT",
|
||||
"SUBMIT",
|
||||
"PAUSE",
|
||||
"RESUME",
|
||||
"STOP",
|
||||
"DEALLOCATE",
|
||||
)
|
||||
|
||||
# Column-level aggregate-function markers on AGGREGATE KEY / UNIQUE KEY table
|
||||
# columns, e.g. `v2 INT SUM`. See
|
||||
# https://docs.starrocks.io/docs/table_design/table_types/aggregate_table/
|
||||
_STARROCKS_AGGREGATE_COLUMN_CONSTRAINTS = (
|
||||
"SUM",
|
||||
"MAX",
|
||||
"MIN",
|
||||
"REPLACE",
|
||||
"REPLACE_IF_NOT_NULL",
|
||||
"BITMAP_UNION",
|
||||
"HLL_UNION",
|
||||
)
|
||||
|
||||
|
||||
class StarRocksMaterializedViewRefresh(exp.Refresh):
|
||||
"""
|
||||
`REFRESH MATERIALIZED VIEW mv [PARTITION START (...) END (...)] [FORCE]
|
||||
[WITH {SYNC|ASYNC} MODE]` -- sqlglot's generic `exp.Refresh(this, kind)`
|
||||
shape has no room for these StarRocks-only clauses, so the base parser
|
||||
only consumes (without modeling) them; a subclass with its own args is
|
||||
used instead of adding to `exp.Refresh` directly, since Superset can't
|
||||
change the arg_types of a class owned by the installed sqlglot package.
|
||||
https://docs.starrocks.io/docs/sql-reference/sql-statements/table_bucket_part_index/REFRESH_MATERIALIZED_VIEW/
|
||||
"""
|
||||
|
||||
arg_types = {
|
||||
**exp.Refresh.arg_types,
|
||||
"force": False,
|
||||
"partition_start": False,
|
||||
"partition_end": False,
|
||||
"mode": False,
|
||||
}
|
||||
|
||||
|
||||
class StarRocksParser(_StarRocksParser):
|
||||
# StarRocks accepts a bare `AS <expr>` generated-column definition, not
|
||||
# just the parenthesized `AS (<expr>)` form MySQL requires.
|
||||
# https://docs.starrocks.io/docs/sql-reference/sql-statements/generated_columns/
|
||||
WRAPPED_TRANSFORM_COLUMN_CONSTRAINT = False
|
||||
|
||||
FUNCTIONS = {
|
||||
**_StarRocksParser.FUNCTIONS,
|
||||
# StarRocks' 2-arg `time_slice(dt, INTERVAL n unit [, boundary])` packs
|
||||
# the interval into a single argument, unlike sqlglot's generic
|
||||
# TimeSlice(this, expression, unit, kind) shape, which expects the
|
||||
# numeric amount and unit as separate positional arguments.
|
||||
"TIME_SLICE": lambda args: exp.TimeSlice(
|
||||
this=seq_get(args, 0),
|
||||
expression=seq_get(args, 1).this
|
||||
if isinstance(seq_get(args, 1), exp.Interval)
|
||||
else seq_get(args, 1),
|
||||
unit=seq_get(args, 1).args.get("unit")
|
||||
if isinstance(seq_get(args, 1), exp.Interval)
|
||||
else seq_get(args, 2),
|
||||
kind=seq_get(args, 2)
|
||||
if isinstance(seq_get(args, 1), exp.Interval)
|
||||
else seq_get(args, 3),
|
||||
),
|
||||
}
|
||||
|
||||
CONSTRAINT_PARSERS = {
|
||||
**_StarRocksParser.CONSTRAINT_PARSERS,
|
||||
**{
|
||||
keyword: (lambda keyword: lambda self: exp.var(keyword))(keyword)
|
||||
for keyword in _STARROCKS_AGGREGATE_COLUMN_CONSTRAINTS
|
||||
},
|
||||
# Overrides MySQL's "KEY" (always a named inline secondary index) to
|
||||
# also accept StarRocks' bare `KEY` column attribute, which marks the
|
||||
# column as part of the primary/duplicate key with no name or column
|
||||
# list, e.g. `ADD COLUMN c INT KEY DEFAULT '0' FIRST`.
|
||||
"KEY": lambda self: self._parse_starrocks_key_constraint(),
|
||||
}
|
||||
|
||||
def _parse_starrocks_key_constraint(self) -> exp.Expr:
|
||||
index = self._index
|
||||
|
||||
is_index_def = self._match(TokenType.L_PAREN, advance=False)
|
||||
if not is_index_def and self._match_set(self.ID_VAR_TOKENS, advance=False):
|
||||
self._advance()
|
||||
self._match(TokenType.USING) and self._advance_any()
|
||||
is_index_def = self._match(TokenType.L_PAREN, advance=False)
|
||||
self._retreat(index)
|
||||
|
||||
if is_index_def:
|
||||
return self._parse_index_constraint()
|
||||
|
||||
return exp.var("KEY")
|
||||
|
||||
def _parse_kill(self) -> exp.Kill:
|
||||
# StarRocks additionally supports `KILL ANALYZE <task_id>` to cancel a
|
||||
# running ANALYZE job, alongside MySQL's CONNECTION/QUERY forms.
|
||||
# https://docs.starrocks.io/docs/sql-reference/sql-statements/cbo_stats/KILL_ANALYZE/
|
||||
kind = (
|
||||
exp.var(self._prev.text)
|
||||
if self._match_texts(("CONNECTION", "QUERY", "ANALYZE"))
|
||||
else None
|
||||
)
|
||||
return self.expression(exp.Kill(this=self._parse_primary(), kind=kind))
|
||||
|
||||
def _parse_refresh(self) -> exp.Refresh | exp.Command:
|
||||
# Extends sqlglot's generic REFRESH (EXTERNAL TABLE | TABLE |
|
||||
# MATERIALIZED VIEW) with StarRocks' DICTIONARY and CONNECTIONS forms,
|
||||
# and models the optional FORCE / PARTITION START(...) END(...) /
|
||||
# WITH {SYNC|ASYNC} MODE clauses on REFRESH MATERIALIZED VIEW via
|
||||
# `StarRocksMaterializedViewRefresh`'s dedicated args, rather than
|
||||
# just consuming them, so a regenerated statement doesn't silently
|
||||
# drop them. Only the MATERIALIZED VIEW target is parsed with
|
||||
# `_parse_table_parts` instead of `_parse_table`: the latter also
|
||||
# tries to parse a trailing `FORCE`/`PARTITION` as a MySQL index
|
||||
# hint and raises before this method ever sees those tokens, whereas
|
||||
# REFRESH EXTERNAL TABLE's own `PARTITION(...)` clause is meant to be
|
||||
# parsed by `_parse_table` as usual.
|
||||
# https://docs.starrocks.io/docs/sql-reference/sql-statements/table_bucket_part_index/REFRESH_MATERIALIZED_VIEW/
|
||||
if self._match_text_seq("DICTIONARY"):
|
||||
return self.expression(
|
||||
exp.Refresh(this=self._parse_table_parts(), kind="DICTIONARY")
|
||||
)
|
||||
if self._match_text_seq("CONNECTIONS"):
|
||||
return self.expression(
|
||||
exp.Refresh(this=exp.var("CONNECTIONS"), kind="CONNECTIONS")
|
||||
)
|
||||
|
||||
if self._match_text_seq("EXTERNAL", "TABLE"):
|
||||
kind = "EXTERNAL TABLE"
|
||||
elif self._match(TokenType.TABLE):
|
||||
kind = "TABLE"
|
||||
elif self._match_text_seq("MATERIALIZED", "VIEW"):
|
||||
kind = "MATERIALIZED VIEW"
|
||||
else:
|
||||
kind = ""
|
||||
|
||||
if kind == "MATERIALIZED VIEW":
|
||||
this = self._parse_string() or self._parse_table_parts()
|
||||
else:
|
||||
this = self._parse_string() or self._parse_table()
|
||||
|
||||
if not kind and not isinstance(this, exp.Literal):
|
||||
return self._parse_as_command(self._prev)
|
||||
|
||||
if kind != "MATERIALIZED VIEW":
|
||||
return self.expression(exp.Refresh(this=this, kind=kind))
|
||||
|
||||
return self.expression(
|
||||
self._parse_materialized_view_refresh_clauses(this, kind)
|
||||
)
|
||||
|
||||
def _parse_materialized_view_refresh_clauses(
|
||||
self, this: exp.Expr | None, kind: str
|
||||
) -> StarRocksMaterializedViewRefresh:
|
||||
force = self._match_text_seq("FORCE")
|
||||
partition_start = None
|
||||
partition_end = None
|
||||
if self._match_text_seq("PARTITION", "START"):
|
||||
partition_start = self._parse_wrapped(self._parse_string)
|
||||
self._match_text_seq("END")
|
||||
partition_end = self._parse_wrapped(self._parse_string)
|
||||
force = self._match_text_seq("FORCE") or force
|
||||
|
||||
mode = None
|
||||
if self._match_text_seq("WITH"):
|
||||
mode = self._match_texts(("SYNC", "ASYNC")) and self._prev.text.upper()
|
||||
self._match_text_seq("MODE")
|
||||
|
||||
return StarRocksMaterializedViewRefresh(
|
||||
this=this,
|
||||
kind=kind,
|
||||
force=force or None,
|
||||
partition_start=partition_start,
|
||||
partition_end=partition_end,
|
||||
mode=mode,
|
||||
)
|
||||
|
||||
def _parse_show_mysql(
|
||||
self,
|
||||
this: str,
|
||||
target: bool | str = False,
|
||||
full: bool | None = None,
|
||||
global_: bool | None = None,
|
||||
) -> exp.Show:
|
||||
json = self._match_text_seq("JSON")
|
||||
|
||||
if target:
|
||||
if isinstance(target, str):
|
||||
self._match_text_seq(*target.split(" "))
|
||||
target_id = self._parse_id_var()
|
||||
else:
|
||||
target_id = None
|
||||
|
||||
index = self._index
|
||||
if self._match_text_seq("IN"):
|
||||
log = self._parse_string()
|
||||
if log is None:
|
||||
self._retreat(index)
|
||||
else:
|
||||
log = None
|
||||
|
||||
if this in ("BINLOG EVENTS", "RELAYLOG EVENTS"):
|
||||
position = self._parse_number() if self._match_text_seq("FROM") else None
|
||||
db = None
|
||||
else:
|
||||
position = None
|
||||
db = None
|
||||
|
||||
if self._match(TokenType.FROM) or self._match_text_seq("IN"):
|
||||
db = self._parse_table_parts(is_db_reference=True)
|
||||
elif self._match(TokenType.DOT):
|
||||
db = target_id
|
||||
target_id = self._parse_id_var()
|
||||
|
||||
# `SHOW CREATE FUNCTION`/`SHOW CREATE PROCEDURE` require a
|
||||
# parenthesized argument-type list to disambiguate overloads,
|
||||
# e.g. `SHOW CREATE FUNCTION db.my_add(BIGINT)`.
|
||||
# https://docs.starrocks.io/docs/sql-reference/sql-statements/Function/SHOW_CREATE_FUNCTION/
|
||||
if this in ("CREATE FUNCTION", "CREATE PROCEDURE") and self._match(
|
||||
TokenType.L_PAREN, advance=False
|
||||
):
|
||||
self._parse_wrapped_csv(self._parse_types)
|
||||
|
||||
channel = (
|
||||
self._parse_id_var() if self._match_text_seq("FOR", "CHANNEL") else None
|
||||
)
|
||||
|
||||
like = self._parse_string() if self._match_text_seq("LIKE") else None
|
||||
where = self._parse_where()
|
||||
|
||||
if this == "PROFILE":
|
||||
types = self._parse_csv(
|
||||
lambda: self._parse_var_from_options(self.PROFILE_TYPES)
|
||||
)
|
||||
query = (
|
||||
self._parse_number() if self._match_text_seq("FOR", "QUERY") else None
|
||||
)
|
||||
offset = self._parse_number() if self._match_text_seq("OFFSET") else None
|
||||
limit = self._parse_number() if self._match_text_seq("LIMIT") else None
|
||||
else:
|
||||
types, query = None, None
|
||||
offset, limit = self._parse_oldstyle_limit()
|
||||
|
||||
mutex = True if self._match_text_seq("MUTEX") else None
|
||||
mutex = False if self._match_text_seq("STATUS") else mutex
|
||||
|
||||
for_table = (
|
||||
self._parse_id_var() if self._match_text_seq("FOR", "TABLE") else None
|
||||
)
|
||||
for_group = (
|
||||
self._parse_string() if self._match_text_seq("FOR", "GROUP") else None
|
||||
)
|
||||
for_user = self._parse_string() if self._match_text_seq("FOR", "USER") else None
|
||||
for_role = self._parse_string() if self._match_text_seq("FOR", "ROLE") else None
|
||||
into_outfile = (
|
||||
self._parse_string() if self._match_text_seq("INTO", "OUTFILE") else None
|
||||
)
|
||||
|
||||
return self.expression(
|
||||
exp.Show(
|
||||
this=this,
|
||||
target=target_id,
|
||||
full=full,
|
||||
log=log,
|
||||
position=position,
|
||||
db=db,
|
||||
channel=channel,
|
||||
like=like,
|
||||
where=where,
|
||||
types=types,
|
||||
query=query,
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
mutex=mutex,
|
||||
for_table=for_table,
|
||||
for_group=for_group,
|
||||
for_user=for_user,
|
||||
for_role=for_role,
|
||||
into_outfile=into_outfile,
|
||||
json=json,
|
||||
global_=global_,
|
||||
)
|
||||
)
|
||||
|
||||
def _parse_index_constraint( # noqa: C901
|
||||
self, kind: str | None = None
|
||||
) -> exp.IndexColumnConstraint:
|
||||
if kind:
|
||||
self._match_texts(("INDEX", "KEY"))
|
||||
|
||||
this = self._parse_id_var(any_token=False)
|
||||
index_type = (
|
||||
self._match(TokenType.USING) and self._advance_any() and self._prev.text
|
||||
)
|
||||
expressions = self._parse_wrapped_csv(self._parse_ordered)
|
||||
|
||||
options = []
|
||||
while True:
|
||||
if self._match_text_seq("KEY_BLOCK_SIZE"):
|
||||
self._match(TokenType.EQ)
|
||||
opt = exp.IndexConstraintOption(key_block_size=self._parse_number())
|
||||
elif self._match(TokenType.USING):
|
||||
opt = exp.IndexConstraintOption(
|
||||
using=self._advance_any() and self._prev.text
|
||||
)
|
||||
# StarRocks' GIN/NGRAM full-text indexes take an inline
|
||||
# properties list after the index type, which MySQL's
|
||||
# grammar doesn't expect: `USING GIN ('parser' = 'english')`.
|
||||
# https://docs.starrocks.io/docs/sql-reference/sql-statements/table_bucket_part_index/CREATE_INDEX/
|
||||
if self._match(TokenType.L_PAREN, advance=False):
|
||||
self._parse_wrapped_properties()
|
||||
elif self._match_text_seq("WITH", "PARSER"):
|
||||
opt = exp.IndexConstraintOption(parser=self._parse_var(any_token=True))
|
||||
elif self._match(TokenType.COMMENT):
|
||||
opt = exp.IndexConstraintOption(comment=self._parse_string())
|
||||
elif self._match_text_seq("VISIBLE"):
|
||||
opt = exp.IndexConstraintOption(visible=True)
|
||||
elif self._match_text_seq("INVISIBLE"):
|
||||
opt = exp.IndexConstraintOption(visible=False)
|
||||
elif self._match_text_seq("ENGINE_ATTRIBUTE"):
|
||||
self._match(TokenType.EQ)
|
||||
opt = exp.IndexConstraintOption(engine_attr=self._parse_string())
|
||||
elif self._match_text_seq("SECONDARY_ENGINE_ATTRIBUTE"):
|
||||
self._match(TokenType.EQ)
|
||||
opt = exp.IndexConstraintOption(
|
||||
secondary_engine_attr=self._parse_string()
|
||||
)
|
||||
else:
|
||||
opt = None
|
||||
|
||||
if not opt:
|
||||
break
|
||||
|
||||
options.append(opt)
|
||||
|
||||
return self.expression(
|
||||
exp.IndexColumnConstraint(
|
||||
this=this,
|
||||
expressions=expressions,
|
||||
kind=kind,
|
||||
index_type=index_type,
|
||||
options=options,
|
||||
)
|
||||
)
|
||||
|
||||
def _parse_drop(self, exists: bool = False) -> exp.Drop | exp.Command:
|
||||
start = self._prev
|
||||
temporary = self._match(TokenType.TEMPORARY)
|
||||
materialized = self._match_text_seq("MATERIALIZED")
|
||||
iceberg = self._match_text_seq("ICEBERG")
|
||||
|
||||
kind = self._match_set(self.CREATABLES) and self._prev.text.upper()
|
||||
if not kind or (iceberg and kind and kind != "TABLE"):
|
||||
return self._parse_as_command(start)
|
||||
|
||||
concurrently = self._match_text_seq("CONCURRENTLY")
|
||||
if_exists = exists or self._parse_exists()
|
||||
|
||||
if kind == "COLUMN":
|
||||
this = self._parse_column()
|
||||
else:
|
||||
this = self._parse_table_parts(
|
||||
schema=True, is_db_reference=kind == "SCHEMA"
|
||||
)
|
||||
|
||||
if kind == "INDEX" and self._match(TokenType.ON):
|
||||
# MySQL's grammar treats `ON` after DROP INDEX as an "ON CLUSTER"
|
||||
# style property (a bare id, optionally with a column list), but
|
||||
# StarRocks' `DROP INDEX idx ON db.table` names a dotted table.
|
||||
# https://docs.starrocks.io/docs/sql-reference/sql-statements/table_bucket_part_index/DROP_INDEX/
|
||||
cluster = self.expression(exp.OnProperty(this=self._parse_table_parts()))
|
||||
else:
|
||||
cluster = self._parse_on_property() if self._match(TokenType.ON) else None
|
||||
|
||||
if self._match(TokenType.L_PAREN, advance=False):
|
||||
expressions = self._parse_wrapped_csv(self._parse_types)
|
||||
else:
|
||||
expressions = None
|
||||
|
||||
cascade_or_restrict = (
|
||||
self._match_texts(("CASCADE", "RESTRICT")) and self._prev.text.upper()
|
||||
)
|
||||
|
||||
return self.expression(
|
||||
exp.Drop(
|
||||
exists=if_exists,
|
||||
this=this,
|
||||
expressions=expressions,
|
||||
kind=self.dialect.CREATABLE_KIND_MAPPING.get(kind) or kind,
|
||||
temporary=temporary,
|
||||
materialized=materialized,
|
||||
cascade=cascade_or_restrict == "CASCADE",
|
||||
restrict=cascade_or_restrict == "RESTRICT",
|
||||
constraints=self._match_text_seq("CONSTRAINTS"),
|
||||
purge=self._match_text_seq("PURGE"),
|
||||
cluster=cluster,
|
||||
concurrently=concurrently,
|
||||
sync=self._match_text_seq("SYNC"),
|
||||
iceberg=iceberg,
|
||||
force=self._match_text_seq("FORCE"),
|
||||
)
|
||||
)
|
||||
|
||||
def _parse_delete(self) -> exp.Delete:
|
||||
# `DELETE FROM t PARTITION p1 WHERE ...` -- StarRocks allows scoping a
|
||||
# DELETE to a partition, which the base parser's DELETE target
|
||||
# doesn't request (unlike ALTER TABLE, it doesn't pass
|
||||
# parse_partition=True).
|
||||
# https://docs.starrocks.io/docs/sql-reference/sql-statements/data-manipulation/DELETE/
|
||||
hint = self._parse_hint()
|
||||
|
||||
tables = None
|
||||
if not self._match(TokenType.FROM, advance=False):
|
||||
tables = self._parse_csv(self._parse_table) or None
|
||||
|
||||
returning = self._parse_returning()
|
||||
|
||||
return self.expression(
|
||||
exp.Delete(
|
||||
hint=hint,
|
||||
tables=tables,
|
||||
this=self._match(TokenType.FROM)
|
||||
and self._parse_table(joins=True, parse_partition=True),
|
||||
using=self._match(TokenType.USING)
|
||||
and self._parse_csv(lambda: self._parse_table(joins=True)),
|
||||
cluster=self._match(TokenType.ON) and self._parse_on_property(),
|
||||
where=self._parse_where(),
|
||||
returning=returning or self._parse_returning(),
|
||||
order=self._parse_order(),
|
||||
limit=self._parse_limit(),
|
||||
)
|
||||
)
|
||||
|
||||
def _parse_insert_table(self) -> exp.Expr | None:
|
||||
# StarRocks' `WITH LABEL <name>` names the load job for an INSERT and
|
||||
# can appear before an explicit target column list, which the base
|
||||
# parser doesn't expect anywhere in the INSERT grammar:
|
||||
# INSERT OVERWRITE t PARTITION(p1) WITH LABEL `l1` SELECT ...
|
||||
# INSERT OVERWRITE t WITH LABEL `l1` (c1, c2) SELECT ...
|
||||
# `schema=True` (the base default) is tried first since it already
|
||||
# correctly resolves the common `t (c1, c2)` column-list form; it
|
||||
# only fails for a table-function target like `INSERT INTO
|
||||
# FILES(...)`, whose key=value call arguments don't fit a
|
||||
# column-schema list. That's caught and retried with schema=False,
|
||||
# parsing the target the same way a FROM-clause table reference
|
||||
# would.
|
||||
# https://docs.starrocks.io/docs/sql-reference/sql-statements/loading_unloading/INSERT/
|
||||
index = self._index
|
||||
try:
|
||||
this = self._parse_table(schema=True, parse_partition=True)
|
||||
except ParseError:
|
||||
self._retreat(index)
|
||||
this = self._parse_table(schema=False, parse_partition=True)
|
||||
|
||||
# Unlike a FROM-clause table reference, `schema=True` doesn't parse a
|
||||
# trailing alias itself (it would be ambiguous with the column-schema
|
||||
# list), so it's handled explicitly here instead.
|
||||
if isinstance(this, exp.Table) and self._match(TokenType.ALIAS, advance=False):
|
||||
this.set("alias", self._parse_table_alias())
|
||||
|
||||
if self._match_text_seq("WITH", "LABEL"):
|
||||
self._parse_id_var()
|
||||
|
||||
if isinstance(this, exp.Table) and self._match(
|
||||
TokenType.L_PAREN, advance=False
|
||||
):
|
||||
columns = self._parse_wrapped_id_vars()
|
||||
this = self.expression(exp.Schema(this=this, expressions=columns))
|
||||
|
||||
return this
|
||||
|
||||
def _parse_alter_table_add(self) -> list[exp.Expr]:
|
||||
# `ALTER TABLE t ADD ROLLUP r1(col1, col2) [FROM base_index] [PROPERTIES (...)]`
|
||||
# is a distinct ALTER action from the CREATE-TABLE-level ROLLUP
|
||||
# property (already handled by `_parse_rollup_property`).
|
||||
# https://docs.starrocks.io/docs/sql-reference/sql-statements/table_bucket_part_index/ALTER_TABLE/#rollup
|
||||
if self._match_text_seq("ROLLUP"):
|
||||
return self._parse_csv(self._parse_add_rollup_index)
|
||||
|
||||
# StarRocks accepts a parenthesized multi-column list after the
|
||||
# singular `ADD COLUMN` (MySQL requires the plural `ADD COLUMNS` for
|
||||
# that form): `ADD COLUMN (c1 INT DEFAULT '0', c2 INT DEFAULT '0')`.
|
||||
index = self._index
|
||||
if self._match_text_seq("COLUMN") and self._match(
|
||||
TokenType.L_PAREN, advance=False
|
||||
):
|
||||
schema = self._parse_schema()
|
||||
if schema:
|
||||
return [schema]
|
||||
self._retreat(index)
|
||||
|
||||
return super()._parse_alter_table_add()
|
||||
|
||||
def _parse_add_rollup_index(self) -> exp.RollupIndex:
|
||||
return self.expression(
|
||||
exp.RollupIndex(
|
||||
this=self._parse_id_var(),
|
||||
expressions=self._parse_wrapped_id_vars(),
|
||||
from_index=self._parse_id_var()
|
||||
if self._match_text_seq("FROM")
|
||||
else None,
|
||||
properties=self.expression(
|
||||
exp.Properties(expressions=self._parse_wrapped_properties())
|
||||
)
|
||||
if self._match_text_seq("PROPERTIES")
|
||||
else None,
|
||||
)
|
||||
)
|
||||
|
||||
def _parse_partition(self) -> exp.Partition | None:
|
||||
# `ALTER TABLE t DROP PARTITION p1` / `DELETE FROM t PARTITION p1 ...`
|
||||
# -- StarRocks also accepts a bare, unparenthesized single partition
|
||||
# name, not just the parenthesized `PARTITION (p1, p2)` list form.
|
||||
# https://docs.starrocks.io/docs/sql-reference/sql-statements/table_bucket_part_index/ALTER_TABLE/#drop-partition
|
||||
if not self._match_texts(self.PARTITION_KEYWORDS):
|
||||
return None
|
||||
|
||||
subpartition = self._prev.text.upper() == "SUBPARTITION"
|
||||
|
||||
if self._match(TokenType.L_PAREN, advance=False):
|
||||
expressions = self._parse_wrapped_csv(self._parse_disjunction)
|
||||
else:
|
||||
expressions = [self._parse_disjunction()]
|
||||
|
||||
return self.expression(
|
||||
exp.Partition(subpartition=subpartition, expressions=expressions)
|
||||
)
|
||||
|
||||
def _parse_partition_range_value(self) -> exp.Expr | None:
|
||||
self._match_text_seq("PARTITION")
|
||||
name = self._parse_id_var()
|
||||
|
||||
if self._match_text_seq("VALUES", "LESS", "THAN"):
|
||||
if self._match_text_seq("MAXVALUE"):
|
||||
values: list[exp.Expr] = [exp.var("MAXVALUE")]
|
||||
else:
|
||||
values = self._parse_wrapped_csv(self._parse_expression)
|
||||
if (
|
||||
len(values) == 1
|
||||
and isinstance(values[0], exp.Column)
|
||||
and values[0].name.upper() == "MAXVALUE"
|
||||
):
|
||||
values = [exp.var("MAXVALUE")]
|
||||
|
||||
part_range = self.expression(
|
||||
exp.PartitionRange(this=name, expressions=values)
|
||||
)
|
||||
return self.expression(exp.Partition(expressions=[part_range]))
|
||||
|
||||
if self._match_text_seq("VALUES") and self._match(TokenType.L_BRACKET):
|
||||
# Dual-bound range partition, e.g.
|
||||
# `PARTITION p1 VALUES [("2021-01-01"), ("2021-01-31"))` -- the
|
||||
# mismatched `[ ... )` denotes an inclusive-lower/exclusive-upper
|
||||
# bound; both bounds are still ordinary parenthesized tuples.
|
||||
# https://docs.starrocks.io/docs/table_design/data_distribution/#range-partitioning
|
||||
lower = self._parse_wrapped_csv(self._parse_expression)
|
||||
self._match(TokenType.COMMA)
|
||||
upper = self._parse_wrapped_csv(self._parse_expression)
|
||||
self._match(TokenType.R_PAREN)
|
||||
part_range = self.expression(
|
||||
exp.PartitionRange(
|
||||
this=name,
|
||||
expressions=[
|
||||
exp.Tuple(expressions=lower),
|
||||
exp.Tuple(expressions=upper),
|
||||
],
|
||||
)
|
||||
)
|
||||
return self.expression(exp.Partition(expressions=[part_range]))
|
||||
|
||||
return name
|
||||
|
||||
def _parse_refresh_property(self) -> exp.RefreshTriggerProperty:
|
||||
method = (
|
||||
self._match_texts(("DEFERRED", "IMMEDIATE")) and self._prev.text.upper()
|
||||
)
|
||||
# StarRocks also allows a cron-style `SCHEDULE START (...) EVERY (...)`
|
||||
# trigger alongside ASYNC/MANUAL; the START/EVERY clauses below are
|
||||
# already parsed unconditionally, so recognizing the keyword is
|
||||
# enough to keep the rest of the CREATE MATERIALIZED VIEW parseable.
|
||||
# https://docs.starrocks.io/docs/sql-reference/sql-statements/table_bucket_part_index/CREATE_MATERIALIZED_VIEW/
|
||||
kind = (
|
||||
self._match_texts(("ASYNC", "MANUAL", "SCHEDULE"))
|
||||
and self._prev.text.upper()
|
||||
)
|
||||
start = self._match_text_seq("START") and self._parse_wrapped(
|
||||
self._parse_string
|
||||
)
|
||||
if self._match_text_seq("EVERY"):
|
||||
self._match_l_paren()
|
||||
self._match_text_seq("INTERVAL")
|
||||
every = self._parse_number()
|
||||
unit = self._parse_var(any_token=True)
|
||||
self._match_r_paren()
|
||||
else:
|
||||
every = None
|
||||
unit = None
|
||||
return self.expression(
|
||||
exp.RefreshTriggerProperty(
|
||||
method=method, kind=kind, starts=start, every=every, unit=unit
|
||||
)
|
||||
)
|
||||
|
||||
def _parse_statement(self) -> exp.Expr | None:
|
||||
# `ADD SQLBLACKLIST|BACKEND BLACKLIST|COMPUTE NODE BLACKLIST` and the
|
||||
# matching `DELETE ...` forms manage cluster-wide denylists. ADD and
|
||||
# DELETE already have dedicated meanings elsewhere in the grammar
|
||||
# (ALTER TABLE ADD ..., the DML DELETE statement), so -- unlike the
|
||||
# keywords in _STARROCKS_COMMAND_KEYWORDS -- they can't be remapped to
|
||||
# TokenType.COMMAND outright; this peeks for the specific StarRocks
|
||||
# phrasing instead and only then treats it as an opaque command.
|
||||
# https://docs.starrocks.io/docs/sql-reference/sql-statements/cluster-management/sql_blacklist/
|
||||
# https://docs.starrocks.io/docs/administration/management/BE_blacklist/
|
||||
if self._match_texts(("ADD", "DELETE"), advance=False):
|
||||
index = self._index
|
||||
start = self._curr
|
||||
self._advance()
|
||||
is_blacklist_command = (
|
||||
self._match_text_seq("SQLBLACKLIST")
|
||||
or self._match_text_seq("BACKEND", "BLACKLIST")
|
||||
or self._match_text_seq("COMPUTE", "NODE", "BLACKLIST")
|
||||
)
|
||||
self._retreat(index)
|
||||
|
||||
if is_blacklist_command:
|
||||
self._advance()
|
||||
return self._parse_as_command(start)
|
||||
|
||||
# `TRANSLATE TRINO <select_statement>` translates a Trino SELECT into
|
||||
# StarRocks SQL and returns it as a result set -- a read, not a
|
||||
# mutation. Like ADD/DELETE above, this can't be remapped to
|
||||
# TokenType.COMMAND outright: TRANSLATE is also the ordinary
|
||||
# TRANSLATE(string, from, to) scalar function, and mapping the
|
||||
# keyword globally would corrupt every call to it anywhere in a
|
||||
# query, not just at statement start. Peeking for the literal
|
||||
# two-word phrase is safe because a bare `TRANSLATE(...)` call can
|
||||
# never be the first token of a top-level statement on its own --
|
||||
# it only ever appears nested inside an expression.
|
||||
# https://docs.starrocks.io/docs/sql-reference/sql-statements/TRANSLATE_TRINO/
|
||||
if self._match_text_seq("TRANSLATE", "TRINO", advance=False):
|
||||
return self._parse_as_command(self._curr)
|
||||
|
||||
return super()._parse_statement()
|
||||
|
||||
|
||||
class StarRocksGenerator(_StarRocksGenerator):
|
||||
# sqlglot's SQLStatement.format()/SQLScript.format() -- used both for
|
||||
# SQL Lab's Jinja-template-comment-stripping validation step and, for
|
||||
# every engine, to build the actual statement text sent to the DB-API
|
||||
# cursor -- regenerate SQL from the AST built by `StarRocksParser`
|
||||
# above. The overrides below fix five constructs the parser produces an
|
||||
# AST for that sqlglot's stock StarRocks generator round-trips
|
||||
# incorrectly, which would otherwise send malformed or semantically
|
||||
# wrong SQL to StarRocks for anything routed through SQL Lab.
|
||||
|
||||
TRANSFORMS = {
|
||||
**_StarRocksGenerator.TRANSFORMS,
|
||||
# `StarRocksMaterializedViewRefresh` isn't registered under sqlglot's
|
||||
# own class-name-to-method dispatch convention (it isn't a class
|
||||
# sqlglot itself defines), so it's routed to `refresh_sql` below
|
||||
# explicitly.
|
||||
StarRocksMaterializedViewRefresh: lambda self, e: self.refresh_sql(e),
|
||||
}
|
||||
|
||||
def refresh_sql(self, expression: exp.Refresh) -> str:
|
||||
# `REFRESH CONNECTIONS` has no separate target name -- `this` is only
|
||||
# set (to a placeholder Var) because the base Refresh expression
|
||||
# requires it -- so the generic `REFRESH {kind} {this}` rendering
|
||||
# would otherwise duplicate the word twice.
|
||||
if expression.args.get("kind") == "CONNECTIONS":
|
||||
return "REFRESH CONNECTIONS"
|
||||
|
||||
sql = super().refresh_sql(expression)
|
||||
|
||||
# REFRESH MATERIALIZED VIEW's own FORCE / PARTITION START(...)
|
||||
# END(...) / WITH {SYNC|ASYNC} MODE clauses, set by `_parse_refresh`
|
||||
# on a `StarRocksMaterializedViewRefresh`. FORCE always renders after
|
||||
# PARTITION, regardless of which side of the partition clause it
|
||||
# appeared on in the source -- StarRocks accepts either position,
|
||||
# but only one is worth preserving.
|
||||
# https://docs.starrocks.io/docs/sql-reference/sql-statements/table_bucket_part_index/REFRESH_MATERIALIZED_VIEW/
|
||||
partition_start = expression.args.get("partition_start")
|
||||
partition_end = expression.args.get("partition_end")
|
||||
if partition_start and partition_end:
|
||||
sql += (
|
||||
f" PARTITION START ({self.sql(partition_start)}) "
|
||||
f"END ({self.sql(partition_end)})"
|
||||
)
|
||||
|
||||
if expression.args.get("force"):
|
||||
sql += " FORCE"
|
||||
|
||||
if mode := expression.args.get("mode"):
|
||||
sql += f" WITH {mode} MODE"
|
||||
|
||||
return sql
|
||||
|
||||
def rollupindex_sql(self, expression: exp.RollupIndex) -> str:
|
||||
sql = super().rollupindex_sql(expression)
|
||||
# As a standalone `ALTER TABLE ... ADD ROLLUP r1(...)` action (as
|
||||
# opposed to an item inside a CREATE TABLE ROLLUP (...) property
|
||||
# list), the ADD ROLLUP keywords live on the RollupIndex node itself
|
||||
# rather than being added by the enclosing property/action.
|
||||
# https://docs.starrocks.io/docs/sql-reference/sql-statements/table_bucket_part_index/ALTER_TABLE/#rollup
|
||||
if isinstance(expression.parent, exp.Alter):
|
||||
return f"ADD ROLLUP {sql}"
|
||||
return sql
|
||||
|
||||
def partitionrange_sql(self, expression: exp.PartitionRange) -> str:
|
||||
# Dual-bound `VALUES [(...), (...))` range partition -- `expressions`
|
||||
# holds exactly the two bound tuples built by
|
||||
# `_parse_partition_range_value` above.
|
||||
# https://docs.starrocks.io/docs/table_design/data_distribution/#range-partitioning
|
||||
name = self.sql(expression, "this")
|
||||
values = expression.expressions
|
||||
|
||||
if (
|
||||
len(values) == 2
|
||||
and isinstance(values[0], exp.Tuple)
|
||||
and isinstance(values[1], exp.Tuple)
|
||||
):
|
||||
bounds = ", ".join(self.sql(v) for v in values)
|
||||
return f"PARTITION {name} VALUES [{bounds})"
|
||||
|
||||
return super().partitionrange_sql(expression)
|
||||
|
||||
def timeslice_sql(self, expression: exp.TimeSlice) -> str:
|
||||
# StarRocks' `time_slice(dt, INTERVAL n unit [, boundary])` packs the
|
||||
# amount/unit into a single INTERVAL argument, unlike the generic
|
||||
# TimeSlice(this, expression, unit, kind) shape's separate positional
|
||||
# this/expression/unit/kind arguments.
|
||||
# https://docs.starrocks.io/docs/sql-reference/sql-functions/date-time-functions/time_slice/
|
||||
interval = exp.Interval(
|
||||
this=expression.args.get("expression"), unit=expression.args.get("unit")
|
||||
)
|
||||
args = [expression.this, interval]
|
||||
if kind := expression.args.get("kind"):
|
||||
args.append(kind)
|
||||
return self.func("TIME_SLICE", *args)
|
||||
|
||||
|
||||
class StarRocks(_StarRocks):
|
||||
Parser = StarRocksParser
|
||||
Generator = StarRocksGenerator
|
||||
|
||||
class Tokenizer(_StarRocks.Tokenizer):
|
||||
KEYWORDS = {
|
||||
**_StarRocks.Tokenizer.KEYWORDS,
|
||||
**dict.fromkeys(_STARROCKS_COMMAND_KEYWORDS, TokenType.COMMAND),
|
||||
}
|
||||
+92
-12
@@ -54,6 +54,7 @@ from superset.sql.dialects import (
|
||||
Hana,
|
||||
OpenSearch,
|
||||
Pinot,
|
||||
StarRocks,
|
||||
Vertica,
|
||||
)
|
||||
|
||||
@@ -156,7 +157,7 @@ SQLGLOT_DIALECTS = {
|
||||
# "solr": ???
|
||||
"spark": Dialects.SPARK,
|
||||
"sqlite": Dialects.SQLITE,
|
||||
"starrocks": Dialects.STARROCKS,
|
||||
"starrocks": StarRocks,
|
||||
"superset": Dialects.SQLITE,
|
||||
# "taosws": ???
|
||||
"teradatasql": Dialects.TERADATA,
|
||||
@@ -785,6 +786,30 @@ class SQLStatement(BaseSQLStatement[exp.Expression]):
|
||||
"REFRESH", # REFRESH MATERIALIZED VIEW
|
||||
"REINDEX",
|
||||
"VACUUM",
|
||||
# StarRocks/MySQL-family admin and job-control commands that
|
||||
# sqlglot has no structured node for, so every form always falls
|
||||
# back to an opaque exp.Command with one of these heads:
|
||||
# ADMIN SET/REPAIR/CHECK/SKIP, BACKUP/RESTORE SNAPSHOT,
|
||||
# CANCEL BACKUP/RESTORE/LOAD/EXPORT/ALTER TABLE/REFRESH/DECOMMISSION/REPAIR,
|
||||
# EXPORT TABLE, SUBMIT TASK, PAUSE/RESUME/STOP ROUTINE LOAD, and
|
||||
# RECOVER TABLE/PARTITION/DATABASE.
|
||||
"ADMIN",
|
||||
"BACKUP",
|
||||
"RESTORE",
|
||||
"CANCEL",
|
||||
"EXPORT",
|
||||
"SUBMIT",
|
||||
"PAUSE",
|
||||
"RESUME",
|
||||
"STOP",
|
||||
"RECOVER",
|
||||
# StarRocks blacklist management (ADD/DELETE SQLBLACKLIST,
|
||||
# ADD/DELETE BACKEND|COMPUTE NODE BLACKLIST) is the only case
|
||||
# that reaches this opaque-Command path with an ADD/DELETE head;
|
||||
# ordinary ALTER TABLE ADD ... and the DML DELETE statement
|
||||
# always parse into their own structured node instead.
|
||||
"ADD",
|
||||
"DELETE",
|
||||
# DDL head-tokens that sqlglot falls back to exp.Command for
|
||||
# whenever the body uses syntax it does not model
|
||||
# (CREATE EXTENSION/FUNCTION...LANGUAGE C/PUBLICATION/etc.,
|
||||
@@ -807,19 +832,28 @@ class SQLStatement(BaseSQLStatement[exp.Expression]):
|
||||
}
|
||||
)
|
||||
|
||||
# PostgreSQL-only command-fallback heads. Only the command-fallback
|
||||
# forms (e.g. SET ROLE / SET SESSION AUTHORIZATION, which change the
|
||||
# effective user) reach here as an exp.Command; structured
|
||||
# `SET search_path = ...` / `SET statement_timeout = ...` parse as
|
||||
# exp.Set and are NOT matched by this path. On other dialects the `SET`
|
||||
# fallback covers session variables (e.g. Hive `SET hivevar:x=1`),
|
||||
# which do not mutate data, so these heads stay dialect-gated.
|
||||
_POSTGRES_MUTATING_COMMAND_NAMES: frozenset[str] = frozenset(
|
||||
# Command-fallback heads that are only mutating on dialects where the
|
||||
# structured form (`exp.Set`) is reserved for benign session variables,
|
||||
# so the opaque-Command fallback is reached exclusively by the dangerous
|
||||
# forms. On PostgreSQL that's SET ROLE / SET SESSION AUTHORIZATION /
|
||||
# RESET ROLE (`SET search_path = ...` parses as exp.Set and never reaches
|
||||
# here). On StarRocks (and MySQL, which shares the same parser) it's SET
|
||||
# PASSWORD FOR .../SET ROLE/SET DEFAULT ROLE/SET DEFAULT STORAGE VOLUME
|
||||
# -- every ordinary `SET var = value` there also parses as exp.Set, so
|
||||
# widening this dialect-by-dialect is safe: it only ever matches forms
|
||||
# sqlglot couldn't model as a session variable in the first place.
|
||||
_SET_RESET_COMMAND_NAMES: frozenset[str] = frozenset(
|
||||
{
|
||||
"SET",
|
||||
"RESET", # RESET ROLE / RESET ALL reverts SET; same class as SET
|
||||
}
|
||||
)
|
||||
_SET_RESET_MUTATING_DIALECTS: frozenset[Dialects] = frozenset(
|
||||
{
|
||||
Dialects.POSTGRES,
|
||||
Dialects.STARROCKS,
|
||||
}
|
||||
)
|
||||
|
||||
# Dialects where `SELECT ... INTO target` is CTAS (creates a table, and so
|
||||
# mutates schema). Elsewhere the same syntax assigns into a variable and is
|
||||
@@ -984,6 +1018,21 @@ class SQLStatement(BaseSQLStatement[exp.Expression]):
|
||||
# rather than an opaque exp.Command, so treat it as mutating here
|
||||
# too.
|
||||
exp.Execute,
|
||||
# ANALYZE (re)computes and persists CBO statistics server-side,
|
||||
# including dropping/updating histograms -- structured on
|
||||
# MySQL-family dialects, so the exp.Command fallback below never
|
||||
# sees it there.
|
||||
exp.Analyze,
|
||||
# KILL terminates another session's connection or running query.
|
||||
# Structured on MySQL-family dialects (never falls back to
|
||||
# exp.Command), so without this it reads as a safe no-op.
|
||||
exp.Kill,
|
||||
# REFRESH MATERIALIZED VIEW / REFRESH EXTERNAL TABLE kick off a
|
||||
# real data-rewrite job. Structured on MySQL-family dialects; the
|
||||
# "REFRESH" entry in _MUTATING_COMMAND_NAMES below only ever
|
||||
# fires for dialects where this instead falls back to
|
||||
# exp.Command.
|
||||
exp.Refresh,
|
||||
)
|
||||
|
||||
if self._parsed.find(*mutating_nodes):
|
||||
@@ -1000,6 +1049,22 @@ class SQLStatement(BaseSQLStatement[exp.Expression]):
|
||||
):
|
||||
return True
|
||||
|
||||
# `SET PASSWORD = ...` (changing the current session's own password)
|
||||
# parses as an ordinary structured `exp.Set` on StarRocks/MySQL --
|
||||
# the same node type as a benign `SET time_zone = 'UTC'` -- so it
|
||||
# can't be distinguished by node type or command name the way
|
||||
# `SET PASSWORD FOR other_user = ...` is (that form has no structured
|
||||
# representation and falls back to exp.Command, caught above). This
|
||||
# walks the assignment targets looking specifically for the
|
||||
# `PASSWORD` pseudo-variable.
|
||||
if isinstance(self._parsed, exp.Set) and any(
|
||||
isinstance((assignment := set_item.this), exp.EQ)
|
||||
and isinstance(assignment.this, exp.Column)
|
||||
and assignment.this.name.upper() == "PASSWORD"
|
||||
for set_item in self._parsed.expressions
|
||||
):
|
||||
return True
|
||||
|
||||
# Function calls that mutate server-side state without an enclosing
|
||||
# mutating AST node. Notable example: PostgreSQL large-object writers
|
||||
# (`lo_export` writes to the server filesystem, `lo_from_bytea`/
|
||||
@@ -1034,8 +1099,8 @@ class SQLStatement(BaseSQLStatement[exp.Expression]):
|
||||
return True
|
||||
|
||||
if (
|
||||
self._dialect == Dialects.POSTGRES
|
||||
and command_name in self._POSTGRES_MUTATING_COMMAND_NAMES
|
||||
self._dialect in self._SET_RESET_MUTATING_DIALECTS
|
||||
and command_name in self._SET_RESET_COMMAND_NAMES
|
||||
):
|
||||
return True
|
||||
|
||||
@@ -2192,13 +2257,28 @@ def _find_show_statement_tables(statement: exp.Show) -> set[Table]:
|
||||
source.catalog if source.catalog != "" else None,
|
||||
)
|
||||
for source in statement.find_all(exp.Table)
|
||||
# A `db` arg scoping the statement to a schema (e.g. the catalog.schema
|
||||
# target of `SHOW TABLES IN catalog.schema`) is itself an `exp.Table`
|
||||
# with no table part, so it is picked up by `find_all` above without
|
||||
# this guard -- as a phantom empty-name table, not a real reference.
|
||||
if source.name
|
||||
}
|
||||
if target := statement.args.get("target"):
|
||||
db = statement.args.get("db")
|
||||
if isinstance(db, exp.Table):
|
||||
# Also an artifact of the catalog.schema `db` arg above: unlike a
|
||||
# plain `Identifier`, its schema/catalog live in `.db`/`.catalog`,
|
||||
# not `.name` (which is empty, since it has no table part).
|
||||
db_name = db.db or None
|
||||
db_catalog = db.catalog or None
|
||||
else:
|
||||
db_name = db.name if isinstance(db, exp.Expression) else db
|
||||
db_catalog = None
|
||||
show_tables.add(
|
||||
Table(
|
||||
target.name if isinstance(target, exp.Expression) else str(target),
|
||||
db.name if isinstance(db, exp.Expression) else db,
|
||||
db_name,
|
||||
db_catalog,
|
||||
)
|
||||
)
|
||||
return show_tables
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
# under the License.
|
||||
import time
|
||||
from datetime import datetime
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
@@ -27,6 +27,7 @@ from superset.commands.chart.create import CreateChartCommand
|
||||
from superset.commands.chart.exceptions import (
|
||||
ChartForbiddenError,
|
||||
ChartNotFoundError,
|
||||
DashboardsForbiddenError,
|
||||
WarmUpCacheChartNotFoundError,
|
||||
)
|
||||
from superset.commands.chart.export import ExportChartsCommand
|
||||
@@ -394,6 +395,52 @@ class TestChartsCreateCommand(SupersetTestCase):
|
||||
db.session.delete(chart)
|
||||
db.session.commit()
|
||||
|
||||
@patch("superset.utils.core.g")
|
||||
@patch("superset.commands.chart.create.g")
|
||||
@patch("superset.security.manager.g")
|
||||
@pytest.mark.usefixtures("load_energy_table_with_slice")
|
||||
def test_create_chart_rejects_externally_managed_dashboard(
|
||||
self, mock_sm_g: MagicMock, mock_c_g: MagicMock, mock_u_g: MagicMock
|
||||
) -> None:
|
||||
"""
|
||||
Test that creating a chart fails when a selected dashboard is managed
|
||||
externally
|
||||
"""
|
||||
from superset.models.dashboard import Dashboard
|
||||
|
||||
user = security_manager.find_user(username="admin")
|
||||
mock_u_g.user = mock_c_g.user = mock_sm_g.user = user
|
||||
|
||||
# The acting user is an admin, so security_manager.is_editor() returns
|
||||
# True for this dashboard; the only reason the command should reject it
|
||||
# is that it is managed externally.
|
||||
managed_dashboard = Dashboard(
|
||||
dashboard_title="Externally Managed Dashboard",
|
||||
slug="externally-managed-dashboard",
|
||||
published=False,
|
||||
is_managed_externally=True,
|
||||
)
|
||||
db.session.add(managed_dashboard)
|
||||
db.session.commit()
|
||||
|
||||
chart_data = {
|
||||
"slice_name": "new chart",
|
||||
"description": "new description",
|
||||
"owners": [user.id],
|
||||
"viz_type": "new_viz_type",
|
||||
"params": json.dumps({"viz_type": "new_viz_type"}),
|
||||
"cache_timeout": 1000,
|
||||
"datasource_id": 1,
|
||||
"datasource_type": "table",
|
||||
"dashboards": [managed_dashboard.id],
|
||||
}
|
||||
command = CreateChartCommand(chart_data)
|
||||
with pytest.raises(DashboardsForbiddenError):
|
||||
command.run()
|
||||
|
||||
db.session.delete(managed_dashboard)
|
||||
db.session.commit()
|
||||
|
||||
|
||||
class TestChartsUpdateCommand(SupersetTestCase):
|
||||
@patch("superset.commands.chart.update.g")
|
||||
@@ -669,6 +716,45 @@ class TestChartsUpdateCommand(SupersetTestCase):
|
||||
db.session.delete(alpha_dashboard)
|
||||
db.session.commit()
|
||||
|
||||
@patch("superset.commands.chart.update.g")
|
||||
@patch("superset.utils.core.g")
|
||||
@patch("superset.security.manager.g")
|
||||
@pytest.mark.usefixtures("load_energy_table_with_slice")
|
||||
def test_update_chart_rejects_new_externally_managed_dashboard(
|
||||
self, mock_sm_g: MagicMock, mock_u_g: MagicMock, mock_c_g: MagicMock
|
||||
) -> None:
|
||||
"""Test that updating a chart to add an externally managed dashboard fails"""
|
||||
from superset.models.dashboard import Dashboard
|
||||
|
||||
admin = security_manager.find_user(username="admin")
|
||||
mock_u_g.user = mock_c_g.user = mock_sm_g.user = admin
|
||||
|
||||
chart = db.session.query(Slice).first()
|
||||
chart.owners = [admin]
|
||||
|
||||
# The acting user is an admin, so security_manager.is_editor() returns
|
||||
# True for this dashboard; the only reason the command should reject it
|
||||
# is that it is managed externally.
|
||||
managed_dashboard = Dashboard(
|
||||
dashboard_title="Externally Managed Dashboard",
|
||||
slug="externally-managed-dashboard",
|
||||
published=False,
|
||||
is_managed_externally=True,
|
||||
)
|
||||
db.session.add(managed_dashboard)
|
||||
db.session.commit()
|
||||
|
||||
json_obj = {
|
||||
"description": "Trying to add externally managed dashboard",
|
||||
"dashboards": [managed_dashboard.id],
|
||||
}
|
||||
command = UpdateChartCommand(chart.id, json_obj)
|
||||
with pytest.raises(DashboardsForbiddenError):
|
||||
command.run()
|
||||
|
||||
db.session.delete(managed_dashboard)
|
||||
db.session.commit()
|
||||
|
||||
|
||||
class TestChartWarmUpCacheCommand(SupersetTestCase):
|
||||
def test_warm_up_cache_command_chart_not_found(self):
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
# 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 click.testing import CliRunner
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
from superset.cli.update import update_api_docs
|
||||
|
||||
|
||||
def test_update_api_docs_fails_when_no_api_is_documented(
|
||||
mocker: MockerFixture, app_context: None
|
||||
) -> None:
|
||||
"""A registration regression must not report success.
|
||||
|
||||
Exiting zero here leaves the committed spec in place, so a caller diffing
|
||||
the result reads staleness as "up to date".
|
||||
"""
|
||||
mocker.patch("superset.cli.update.current_app.appbuilder.baseviews", [])
|
||||
write = mocker.patch("superset.cli.update.open")
|
||||
|
||||
result = CliRunner().invoke(update_api_docs, [])
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "No v1 API found to document" in result.output
|
||||
write.assert_not_called()
|
||||
@@ -587,6 +587,35 @@ def test_extract_tables_show_tables_from() -> None:
|
||||
).has_unparseable_statement
|
||||
|
||||
|
||||
def test_extract_tables_show_tables_starrocks_catalog_schema() -> None:
|
||||
"""
|
||||
Regression guard for the StarRocks catalog-qualified schema override.
|
||||
|
||||
Unlike MySQL, `db` there can itself be an ``exp.Table`` (built via
|
||||
``_parse_table_parts(is_db_reference=True)`` so a dotted
|
||||
``catalog.schema`` parses), which ``find_all(exp.Table)`` would
|
||||
otherwise also pick up as a phantom, empty-name table reference --
|
||||
breaking the invariant that a schema-only `SHOW TABLES` target extracts
|
||||
no tables and is flagged unparseable for authorization purposes.
|
||||
"""
|
||||
assert (
|
||||
extract_tables_from_sql("SHOW TABLES IN catalog_1.schema_a", "starrocks")
|
||||
== set()
|
||||
)
|
||||
assert extract_tables_from_sql("SHOW TABLES FROM schema_a", "starrocks") == set()
|
||||
assert SQLScript(
|
||||
"SHOW TABLES IN catalog_1.schema_a", "starrocks"
|
||||
).has_unparseable_statement
|
||||
|
||||
# A target-bearing SHOW must still resolve the real table, threading the
|
||||
# catalog.schema `db` scope through correctly rather than dropping it
|
||||
# (`exp.Table.name` is empty for a schema-only reference; the schema and
|
||||
# catalog live in `.db`/`.catalog` instead).
|
||||
assert extract_tables_from_sql(
|
||||
"SHOW COLUMNS FROM tbl FROM catalog_1.schema_a", "starrocks"
|
||||
) == {Table("tbl", "schema_a", "catalog_1")}
|
||||
|
||||
|
||||
def test_extract_tables_show_create_table() -> None:
|
||||
"""
|
||||
Test `SHOW CREATE TABLE`.
|
||||
@@ -2756,6 +2785,449 @@ def test_set_limit_value_leaves_show_statements_unchanged(
|
||||
assert "LIMIT" not in statement.format()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"sql, expected_catalog, expected_db",
|
||||
[
|
||||
("SHOW TABLES IN catalog_1.schema_a", "catalog_1", "schema_a"),
|
||||
("SHOW TABLES FROM catalog_1.schema_a", "catalog_1", "schema_a"),
|
||||
("SHOW TABLES IN schema_a", None, "schema_a"),
|
||||
("SHOW TABLES FROM schema_a", None, "schema_a"),
|
||||
("SHOW DATABASES IN catalog_1", None, "catalog_1"),
|
||||
],
|
||||
)
|
||||
def test_show_tables_in_catalog_qualified_schema(
|
||||
sql: str, expected_catalog: str | None, expected_db: str
|
||||
) -> None:
|
||||
"""
|
||||
StarRocks supports a catalog-qualified schema reference in
|
||||
``SHOW TABLES/DATABASES FROM|IN <schema>``, e.g.
|
||||
``SHOW TABLES IN catalog.schema``, which sqlglot's MySQL-derived parser
|
||||
doesn't support: the schema is parsed with ``_parse_id_var()``, which only
|
||||
ever consumes a single identifier, leaving the ``.schema`` part dangling
|
||||
and rejected as an unexpected token. The ``superset.sql.dialects.StarRocks``
|
||||
override reparses the schema with ``_parse_table_parts(is_db_reference=True)``
|
||||
so a dotted ``catalog.schema`` (or a plain schema) both parse correctly.
|
||||
"""
|
||||
show = SQLStatement(sql, "starrocks")._parsed
|
||||
assert isinstance(show, exp.Show)
|
||||
|
||||
db = show.args.get("db")
|
||||
assert isinstance(db, exp.Table)
|
||||
catalog = db.args.get("catalog")
|
||||
assert (catalog.name if catalog else None) == expected_catalog
|
||||
assert db.args.get("db").name == expected_db
|
||||
|
||||
|
||||
def test_show_binlog_events_in_log_name_still_parses() -> None:
|
||||
"""
|
||||
Regression guard: the override must not break the pre-existing meaning of
|
||||
``IN`` for ``SHOW BINLOG/RELAYLOG EVENTS IN 'log_name'``, where ``IN``
|
||||
introduces a string log name rather than a schema reference.
|
||||
"""
|
||||
show = SQLStatement(
|
||||
"SHOW BINLOG EVENTS IN 'log.000001' FROM 4", "starrocks"
|
||||
)._parsed
|
||||
assert isinstance(show, exp.Show)
|
||||
assert show.args.get("log").name == "log.000001"
|
||||
assert show.args.get("position").name == "4"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"sql",
|
||||
[
|
||||
# Admin / cluster / job-control statements sqlglot's MySQL-derived
|
||||
# grammar has no dedicated handling for, so it used to try (and
|
||||
# fail) to read the head keyword as a generic expression.
|
||||
'ADMIN SET FRONTEND CONFIG ("disable_balance" = "true")',
|
||||
'ADMIN CHECK TABLET (10000, 10001) PROPERTIES("type" = "consistency")',
|
||||
"ADMIN REPAIR TABLE tbl1 PARTITION (p1, p2)",
|
||||
"BACKUP SNAPSHOT example_db.snapshot_label1 TO example_repo "
|
||||
'PROPERTIES ("type" = "full")',
|
||||
"RESTORE SNAPSHOT example_db.snapshot_label1 FROM example_repo "
|
||||
'ON (backup_tbl) PROPERTIES("backup_timestamp"="2018-05-04-16-45-08")',
|
||||
"RECOVER DATABASE example_db",
|
||||
"RECOVER TABLE example_db.example_tbl",
|
||||
"RECOVER PARTITION p1 FROM example_tbl",
|
||||
"CANCEL BACKUP FROM example_db",
|
||||
"CANCEL RESTORE FROM example_db",
|
||||
'CANCEL LOAD WHERE LABEL = "example_label"',
|
||||
'CANCEL EXPORT WHERE queryid = "921d8f80-7c9d-11eb-9342-acde48001121"',
|
||||
"CANCEL ALTER TABLE COLUMN FROM example_db.my_table",
|
||||
'EXPORT TABLE testTbl TO "hdfs://h:9000/a/b/c/testTbl_" WITH BROKER',
|
||||
"PAUSE ROUTINE LOAD FOR example_db.example_tbl1_ordertest1",
|
||||
"RESUME ROUTINE LOAD FOR example_db.example_tbl1_ordertest1",
|
||||
"STOP ROUTINE LOAD FOR example_db.example_tbl1_ordertest1",
|
||||
"SUBMIT TASK etl0 AS CREATE TABLE tbl1 AS SELECT * FROM src_tbl",
|
||||
"SUBMIT TASK AS INSERT OVERWRITE tbl2 SELECT * FROM src_tbl",
|
||||
"DEALLOCATE PREPARE select_by_id_stmt",
|
||||
# StarRocks blacklist management. ADD/DELETE already mean something
|
||||
# else in the grammar (ALTER TABLE ADD ..., the DML DELETE
|
||||
# statement), so these need the specific-phrase peek in
|
||||
# `_parse_statement`, not a blanket keyword remap.
|
||||
'ADD SQLBLACKLIST "select count(*) from .+"',
|
||||
"DELETE SQLBLACKLIST 3, 4",
|
||||
"ADD BACKEND BLACKLIST 10001",
|
||||
"DELETE BACKEND BLACKLIST 10001",
|
||||
"ADD COMPUTE NODE BLACKLIST 10005",
|
||||
# Ordinary ADD/DELETE must be unaffected by the blacklist peek.
|
||||
"ALTER TABLE t ADD COLUMN c INT",
|
||||
"DELETE FROM my_table WHERE k1 = 3",
|
||||
# TRANSLATE TRINO translates a Trino SELECT into StarRocks SQL. Like
|
||||
# ADD/DELETE, TRANSLATE can't be remapped to TokenType.COMMAND
|
||||
# outright -- it also names the ordinary TRANSLATE(string, from, to)
|
||||
# scalar function -- so this needs the same specific-phrase peek.
|
||||
"TRANSLATE TRINO SELECT 1",
|
||||
"TRANSLATE TRINO SELECT id, name FROM products WHERE category = 'Electronics'",
|
||||
# Ordinary use of the scalar function must be unaffected by the peek.
|
||||
"SELECT TRANSLATE(col, 'a', 'b') FROM t",
|
||||
],
|
||||
)
|
||||
def test_starrocks_admin_and_job_control_statements_parse(sql: str) -> None:
|
||||
SQLStatement(sql, "starrocks")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"sql",
|
||||
[
|
||||
"KILL ANALYZE 266030",
|
||||
"KILL QUERY 5",
|
||||
"KILL 20",
|
||||
"REFRESH DICTIONARY dict_obj",
|
||||
"REFRESH CONNECTIONS",
|
||||
"REFRESH MATERIALIZED VIEW lo_mv1",
|
||||
"REFRESH MATERIALIZED VIEW lo_mv1 FORCE",
|
||||
'REFRESH MATERIALIZED VIEW lo_mv1 PARTITION START ("2020-02-01") '
|
||||
'END ("2020-03-01") FORCE',
|
||||
"REFRESH MATERIALIZED VIEW lo_mv1 WITH SYNC MODE",
|
||||
"CANCEL REFRESH MATERIALIZED VIEW lo_mv1",
|
||||
"CANCEL REFRESH MATERIALIZED VIEW lo_mv1 FORCE",
|
||||
"CANCEL REFRESH DICTIONARY dict_obj",
|
||||
"SHOW CREATE FUNCTION default_db.python_add(BIGINT)",
|
||||
"SHOW CREATE FUNCTION default_db.python_add",
|
||||
"CREATE MATERIALIZED VIEW lo_mv3 DISTRIBUTED BY HASH(`lo_orderkey`) "
|
||||
"REFRESH SCHEDULE START ('2023-07-01 10:00:00') EVERY (INTERVAL 1 DAY) "
|
||||
"AS SELECT lo_orderkey FROM lineorder",
|
||||
"SHOW COLUMNS FROM t1",
|
||||
"REFRESH TABLE t1",
|
||||
"SHOW PROFILE",
|
||||
# No REFRESH kind keyword matches; falls back to an opaque Command
|
||||
# rather than raising.
|
||||
"REFRESH foo",
|
||||
# No START/EVERY schedule at all.
|
||||
"CREATE MATERIALIZED VIEW mv1 DISTRIBUTED BY HASH(x) REFRESH MANUAL "
|
||||
"AS SELECT x FROM t",
|
||||
# Existing forms these overrides must not regress.
|
||||
"REFRESH EXTERNAL TABLE t1",
|
||||
# REFRESH EXTERNAL TABLE / TABLE's own PARTITION(...) clause -- using
|
||||
# `_parse_table_parts` unconditionally for the target would raise
|
||||
# before this clause is ever reached.
|
||||
"REFRESH EXTERNAL TABLE hudi1 PARTITION('date=2022-12-20', 'date=2022-12-21')",
|
||||
"REFRESH TABLE t1 PARTITION('p1')",
|
||||
"CREATE MATERIALIZED VIEW lo_mv1 DISTRIBUTED BY HASH(`lo_orderkey`) "
|
||||
"REFRESH ASYNC START ('2023-07-01 10:00:00') EVERY (INTERVAL 1 DAY) "
|
||||
"AS SELECT lo_orderkey FROM lineorder",
|
||||
],
|
||||
)
|
||||
def test_starrocks_kill_refresh_show_create_function_parse(sql: str) -> None:
|
||||
SQLStatement(sql, "starrocks")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("sql", "expected"),
|
||||
[
|
||||
# `this` is a placeholder Var required by the base Refresh expression,
|
||||
# not a real target name; the generic REFRESH {kind} {this} rendering
|
||||
# would otherwise duplicate the word.
|
||||
("REFRESH CONNECTIONS", "REFRESH CONNECTIONS"),
|
||||
# A standalone ALTER TABLE ADD ROLLUP action's own "ADD ROLLUP"
|
||||
# keywords live on the RollupIndex node, not on the enclosing ALTER.
|
||||
(
|
||||
"ALTER TABLE db.tbl ADD ROLLUP r1(col1, col2) FROM r0",
|
||||
"ALTER TABLE db.tbl\nADD ROLLUP r1(col1, col2) FROM r0",
|
||||
),
|
||||
# Dual-bound `VALUES [(...), (...))` range partition must round-trip
|
||||
# as the same half-open bound, not collapse into a single-bound
|
||||
# `VALUES LESS THAN (...)` partition with a different meaning.
|
||||
(
|
||||
"CREATE TABLE t(k1 INT) PARTITION BY RANGE (k1) "
|
||||
"(PARTITION p1 VALUES [('2021-01-01'), ('2021-01-31'))) "
|
||||
"DISTRIBUTED BY HASH(k1)",
|
||||
"CREATE TABLE t (\n k1 INT\n)\n"
|
||||
"PARTITION BY RANGE (k1) (PARTITION p1 VALUES "
|
||||
"[('2021-01-01'), ('2021-01-31')))\n"
|
||||
"DISTRIBUTED BY HASH (\n k1\n)",
|
||||
),
|
||||
# StarRocks' single-argument INTERVAL form must round-trip with the
|
||||
# INTERVAL keyword, not the generic positional Func rendering.
|
||||
(
|
||||
"CREATE TABLE t(dt DATETIME) PARTITION BY time_slice(dt, INTERVAL 7 day) "
|
||||
"DISTRIBUTED BY HASH(dt)",
|
||||
"CREATE TABLE t (\n dt DATETIME\n)\n"
|
||||
"PARTITION BY TIME_SLICE(dt, INTERVAL '7' DAY)\n"
|
||||
"DISTRIBUTED BY HASH (\n dt\n)",
|
||||
),
|
||||
# The 3-argument boundary form, and the non-CONNECTIONS/non-dual-bound/
|
||||
# non-ALTER-action fallback paths of each override above, must keep
|
||||
# deferring to the base StarRocks generator rather than always taking
|
||||
# the specialized branch.
|
||||
(
|
||||
"CREATE TABLE t(dt DATETIME) "
|
||||
"PARTITION BY TIME_SLICE(dt, INTERVAL 7 DAY, FLOOR) "
|
||||
"DISTRIBUTED BY HASH(dt)",
|
||||
"CREATE TABLE t (\n dt DATETIME\n)\n"
|
||||
"PARTITION BY TIME_SLICE(dt, INTERVAL '7' DAY, FLOOR)\n"
|
||||
"DISTRIBUTED BY HASH (\n dt\n)",
|
||||
),
|
||||
("REFRESH TABLE t1", "REFRESH TABLE t1"),
|
||||
("REFRESH DICTIONARY dict_obj", "REFRESH DICTIONARY dict_obj"),
|
||||
(
|
||||
"CREATE TABLE t (k1 INT, k2 INT) DUPLICATE KEY (k1) "
|
||||
"DISTRIBUTED BY HASH (k1) ROLLUP (r1 (k1) FROM t)",
|
||||
"CREATE TABLE t (\n k1 INT,\n k2 INT\n)\n"
|
||||
"DUPLICATE KEY (k1)\n"
|
||||
"DISTRIBUTED BY HASH (\n k1\n)\n"
|
||||
"ROLLUP (r1(k1) FROM t)",
|
||||
),
|
||||
(
|
||||
"CREATE TABLE t(k1 INT) PARTITION BY RANGE (k1) "
|
||||
'(PARTITION p1 VALUES LESS THAN ("10")) DISTRIBUTED BY HASH(k1)',
|
||||
"CREATE TABLE t (\n k1 INT\n)\n"
|
||||
"PARTITION BY RANGE (k1) (PARTITION p1 VALUES LESS THAN ('10'))\n"
|
||||
"DISTRIBUTED BY HASH (\n k1\n)",
|
||||
),
|
||||
# REFRESH MATERIALIZED VIEW's FORCE / PARTITION START(...) END(...) /
|
||||
# WITH {SYNC|ASYNC} MODE clauses must round-trip, not vanish --
|
||||
# `format()` is what SQL Lab actually sends to the database.
|
||||
(
|
||||
"REFRESH MATERIALIZED VIEW lo_mv1 FORCE",
|
||||
"REFRESH MATERIALIZED VIEW lo_mv1 FORCE",
|
||||
),
|
||||
(
|
||||
"REFRESH MATERIALIZED VIEW lo_mv1 PARTITION START ('2020-02-01') "
|
||||
"END ('2020-03-01')",
|
||||
"REFRESH MATERIALIZED VIEW lo_mv1 PARTITION START ('2020-02-01') "
|
||||
"END ('2020-03-01')",
|
||||
),
|
||||
# FORCE is accepted either right after the view name or after the
|
||||
# PARTITION clause; it always renders after PARTITION.
|
||||
(
|
||||
"REFRESH MATERIALIZED VIEW lo_mv1 FORCE PARTITION START ('2020-02-01') "
|
||||
"END ('2020-03-01')",
|
||||
"REFRESH MATERIALIZED VIEW lo_mv1 PARTITION START ('2020-02-01') "
|
||||
"END ('2020-03-01') FORCE",
|
||||
),
|
||||
(
|
||||
"REFRESH MATERIALIZED VIEW lo_mv1 PARTITION START ('2020-02-01') "
|
||||
"END ('2020-03-01') FORCE",
|
||||
"REFRESH MATERIALIZED VIEW lo_mv1 PARTITION START ('2020-02-01') "
|
||||
"END ('2020-03-01') FORCE",
|
||||
),
|
||||
(
|
||||
"REFRESH MATERIALIZED VIEW lo_mv1 WITH SYNC MODE",
|
||||
"REFRESH MATERIALIZED VIEW lo_mv1 WITH SYNC MODE",
|
||||
),
|
||||
(
|
||||
"REFRESH MATERIALIZED VIEW lo_mv1 WITH ASYNC MODE",
|
||||
"REFRESH MATERIALIZED VIEW lo_mv1 WITH ASYNC MODE",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_starrocks_generator_round_trip(sql: str, expected: str) -> None:
|
||||
# SQL Lab regenerates SQL from this AST via `format()` for every
|
||||
# statement it executes (see `build_statement_blocks` in
|
||||
# `superset/sql/execution/executor.py`), so an incorrect round-trip here
|
||||
# would send malformed or semantically wrong SQL to the database.
|
||||
assert SQLStatement(sql, "starrocks").format() == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"sql",
|
||||
[
|
||||
# Aggregate/unique-key column agg-function suffix.
|
||||
"CREATE TABLE t(k1 INT, v2 INT SUM) AGGREGATE KEY(k1) DISTRIBUTED BY HASH(k1)",
|
||||
'CREATE TABLE t(k1 INT, v2 INT REPLACE_IF_NOT_NULL DEFAULT "10") '
|
||||
"AGGREGATE KEY(k1) DISTRIBUTED BY HASH(k1)",
|
||||
# Generated columns without the parenthesized `AS (expr)` form.
|
||||
"CREATE TABLE t1(id INT, newcol1 INT AS id + 1)",
|
||||
"CREATE TABLE test_tbl1(id INT NOT NULL, data_array ARRAY<int> NOT NULL, "
|
||||
"newcol1 DOUBLE AS array_avg(data_array)) PRIMARY KEY (id) "
|
||||
"DISTRIBUTED BY HASH(id)",
|
||||
"CREATE TABLE t1(id INT, newcol1 INT AS (id + 1))", # existing form
|
||||
# Bare, unnamed inline KEY constraint (a primary/duplicate key marker
|
||||
# with no name or column list is also accepted; see the CONSTRAINT_
|
||||
# PARSERS override below).
|
||||
"CREATE TABLE t (k1 INT, KEY (k1))",
|
||||
# GIN/NGRAM full-text index with an inline properties list.
|
||||
"CREATE TABLE t(k1 INT, INDEX idx (k1) USING GIN ('parser' = 'english')) "
|
||||
"DUPLICATE KEY(k1) DISTRIBUTED BY HASH(k1)",
|
||||
"CREATE TABLE t(k1 INT, INDEX idx (k1) USING BITMAP) "
|
||||
"DUPLICATE KEY(k1) DISTRIBUTED BY HASH(k1)", # existing form
|
||||
# Inherited MySQL inline-index forms/options, unrelated to the
|
||||
# StarRocks-specific GIN case above, but reachable through the same
|
||||
# overridden method.
|
||||
"CREATE TABLE t (c TEXT, FULLTEXT idx (c))",
|
||||
"CREATE TABLE t (k1 INT, INDEX idx (k1) KEY_BLOCK_SIZE = 1024)",
|
||||
"CREATE TABLE t (k1 INT, INDEX idx (k1) WITH PARSER ngram)",
|
||||
"CREATE TABLE t (k1 INT, INDEX idx (k1) COMMENT 'my index')",
|
||||
"CREATE TABLE t (k1 INT, INDEX idx (k1) VISIBLE)",
|
||||
"CREATE TABLE t (k1 INT, INDEX idx (k1) INVISIBLE)",
|
||||
"CREATE TABLE t (k1 INT, INDEX idx (k1) ENGINE_ATTRIBUTE = 'foo')",
|
||||
"CREATE TABLE t (k1 INT, INDEX idx (k1) SECONDARY_ENGINE_ATTRIBUTE = 'foo')",
|
||||
# Range partition VALUES forms.
|
||||
"CREATE TABLE t(k1 INT) PARTITION BY RANGE (k1) "
|
||||
'(PARTITION p1 VALUES LESS THAN ("10")) DISTRIBUTED BY HASH(k1)',
|
||||
"CREATE TABLE t(k1 INT) PARTITION BY RANGE (k1) "
|
||||
"(PARTITION p1 VALUES LESS THAN MAXVALUE) DISTRIBUTED BY HASH(k1)",
|
||||
# Legacy parenthesized MAXVALUE form, distinct from the bare form
|
||||
# immediately above.
|
||||
"CREATE TABLE t(k1 INT) PARTITION BY RANGE (k1) "
|
||||
"(PARTITION p1 VALUES LESS THAN (MAXVALUE)) DISTRIBUTED BY HASH(k1)",
|
||||
"CREATE TABLE t(k1 INT) PARTITION BY RANGE (k1) "
|
||||
'(PARTITION p1 VALUES [("2021-01-01"), ("2021-01-31"))) '
|
||||
"DISTRIBUTED BY HASH(k1)",
|
||||
# A range partition item with no VALUES clause at all.
|
||||
"CREATE TABLE t(k1 INT) PARTITION BY RANGE (k1) "
|
||||
"(PARTITION p1) DISTRIBUTED BY HASH(k1)",
|
||||
"CREATE TABLE t(dt DATETIME) PARTITION BY time_slice(dt, INTERVAL 7 day) "
|
||||
"DISTRIBUTED BY HASH(dt)",
|
||||
# ALTER TABLE clause variants.
|
||||
"ALTER TABLE example_db.my_table DROP PARTITION p1",
|
||||
"ALTER TABLE example_db.my_table DROP PARTITION IF EXISTS p1 FORCE",
|
||||
"ALTER TABLE example_db.my_table DROP TEMPORARY PARTITION p1", # existing
|
||||
"ALTER TABLE example_db.my_table DROP PARTITION (p1, p2)", # existing
|
||||
"ALTER TABLE db.tbl ADD ROLLUP r1(col1,col2) FROM r0",
|
||||
"ALTER TABLE db.tbl ADD ROLLUP r1(col1,col2)",
|
||||
"ALTER TABLE db.tbl DROP ROLLUP r1", # existing
|
||||
"ALTER TABLE my_table ADD COLUMN new_col INT KEY DEFAULT '0' FIRST",
|
||||
# existing form:
|
||||
"ALTER TABLE my_table ADD COLUMN new_col INT DEFAULT '0' AFTER col1",
|
||||
"ALTER TABLE my_table ADD COLUMN (c1 INT DEFAULT '0', c2 INT DEFAULT '0')",
|
||||
# existing form:
|
||||
"ALTER TABLE my_table ADD COLUMNS (c1 INT DEFAULT '0', c2 INT DEFAULT '0')",
|
||||
"ALTER TABLE my_table ADD COLUMN c1 INT DEFAULT '0'", # existing
|
||||
# Degenerate input where the "(" right after ADD COLUMN turns out not
|
||||
# to be a column list (disambiguated from a subquery start), so the
|
||||
# multi-column fast path backs off to the generic ADD handling.
|
||||
"ALTER TABLE t ADD COLUMN (SELECT 1)",
|
||||
"DROP INDEX index_name ON db.table1",
|
||||
"DROP COLUMN t1.c1",
|
||||
"DROP TABLE t1 ON cluster_name",
|
||||
"DROP FUNCTION my_func(INT, VARCHAR)",
|
||||
"DELETE FROM my_table PARTITION p1 WHERE k1 = 3",
|
||||
"DELETE FROM my_table PARTITION (p1, p2) WHERE k1 = 3",
|
||||
# MySQL "Multiple-Table Syntax" delete, where the target list
|
||||
# precedes FROM instead of following it directly.
|
||||
"DELETE t1 FROM t1 JOIN t2 ON t1.id = t2.id WHERE t2.x = 1",
|
||||
# INSERT clause variants.
|
||||
"INSERT OVERWRITE test PARTITION(p1, p2) WITH LABEL `label1` "
|
||||
"SELECT * FROM test3",
|
||||
"INSERT OVERWRITE test WITH LABEL `label1` (c1, c2) SELECT * FROM test3",
|
||||
"INSERT INTO test WITH LABEL `label1` SELECT * FROM test3",
|
||||
'INSERT INTO FILES("path" = "s3://bucket/x/", "format" = "parquet") '
|
||||
"SELECT * FROM t",
|
||||
"INSERT OVERWRITE test SELECT * FROM test3", # existing form
|
||||
# Regression guard: the ordinary `INSERT INTO t (col1, col2) VALUES
|
||||
# (...)` column-list form -- with no WITH LABEL and no table
|
||||
# function -- must still resolve via the normal schema=True path,
|
||||
# not get misread as a table-valued-function call.
|
||||
"INSERT INTO t (c1) VALUES (1)",
|
||||
"INSERT INTO t AS t_alias VALUES (1)",
|
||||
],
|
||||
)
|
||||
def test_starrocks_create_alter_table_clauses_parse(sql: str) -> None:
|
||||
SQLStatement(sql, "starrocks")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"sql, expected",
|
||||
[
|
||||
# ANALYZE writes CBO statistics server-side; structured `exp.Analyze`
|
||||
# was missing from the mutating-node tuple.
|
||||
("ANALYZE TABLE tbl_name", True),
|
||||
("ANALYZE TABLE tbl_name DROP HISTOGRAM ON col_name", True),
|
||||
("ANALYZE TABLE tbl_name UPDATE HISTOGRAM ON v1,v2 WITH 32 BUCKETS", True),
|
||||
("KILL ANALYZE 266030", True),
|
||||
("KILL QUERY 5", True),
|
||||
("KILL 20", True),
|
||||
# REFRESH MATERIALIZED VIEW/DICTIONARY/CONNECTIONS/EXTERNAL TABLE all
|
||||
# parse to a structured `exp.Refresh`, also missing from the tuple.
|
||||
("REFRESH MATERIALIZED VIEW lo_mv1", True),
|
||||
("REFRESH DICTIONARY dict_obj", True),
|
||||
("REFRESH CONNECTIONS", True),
|
||||
("REFRESH EXTERNAL TABLE t1", True),
|
||||
("CANCEL REFRESH MATERIALIZED VIEW lo_mv1", True),
|
||||
# SET PASSWORD/ROLE/DEFAULT ROLE/DEFAULT STORAGE VOLUME all fall
|
||||
# back to an opaque `exp.Command` with head "SET", which the
|
||||
# dialect gate only recognised for PostgreSQL.
|
||||
("SET PASSWORD FOR 'jack'@'192.%' = PASSWORD('123456')", True),
|
||||
("SET ROLE db_admin", True),
|
||||
("SET ROLE ALL EXCEPT db_admin", True),
|
||||
("SET DEFAULT ROLE db_admin TO test", True),
|
||||
("SET DEFAULT STORAGE VOLUME my_s3_volume", True),
|
||||
# `SET PASSWORD = ...` (own account) parses as a plain structured
|
||||
# `exp.Set`, indistinguishable from a benign session variable except
|
||||
# by inspecting the assignment target.
|
||||
("SET PASSWORD = PASSWORD('123456')", True),
|
||||
# Ordinary session variables must still read as non-mutating.
|
||||
("SET time_zone = 'UTC'", False),
|
||||
("SET SESSION time_zone = 'UTC'", False),
|
||||
("SET @myvar = 1", False),
|
||||
("SET NAMES utf8mb4", False),
|
||||
# Admin/ops/job-control commands that always fall back to an opaque
|
||||
# `exp.Command` with one of these heads.
|
||||
(
|
||||
"BACKUP SNAPSHOT example_db.snapshot_label1 TO example_repo "
|
||||
'PROPERTIES ("type" = "full")',
|
||||
True,
|
||||
),
|
||||
("CANCEL BACKUP FROM example_db", True),
|
||||
("CANCEL RESTORE FROM example_db", True),
|
||||
('CANCEL LOAD WHERE LABEL = "example_label"', True),
|
||||
("CANCEL ALTER TABLE COLUMN FROM example_db.my_table", True),
|
||||
(
|
||||
'EXPORT TABLE testTbl TO "hdfs://h:9000/a/b/c/testTbl_" WITH BROKER',
|
||||
True,
|
||||
),
|
||||
("PAUSE ROUTINE LOAD FOR example_db.example_tbl1_ordertest1", True),
|
||||
("RESUME ROUTINE LOAD FOR example_db.example_tbl1_ordertest1", True),
|
||||
("STOP ROUTINE LOAD FOR example_db.example_tbl1_ordertest1", True),
|
||||
("SUBMIT TASK etl0 AS CREATE TABLE tbl1 AS SELECT * FROM src_tbl", True),
|
||||
("RECOVER DATABASE example_db", True),
|
||||
("RECOVER TABLE example_db.example_tbl", True),
|
||||
(
|
||||
'ADMIN SET FRONTEND CONFIG ("disable_balance" = "true")',
|
||||
True,
|
||||
),
|
||||
# StarRocks blacklist management via the ADD/DELETE peek.
|
||||
('ADD SQLBLACKLIST "select count(*) from .+"', True),
|
||||
("DELETE SQLBLACKLIST 3, 4", True),
|
||||
("ADD BACKEND BLACKLIST 10001", True),
|
||||
("DELETE BACKEND BLACKLIST 10001", True),
|
||||
# Ordinary DELETE (and the ADD/DELETE peek generally) must not
|
||||
# misclassify unrelated statements.
|
||||
("DELETE FROM my_table WHERE k1 = 3", True),
|
||||
("DELETE FROM my_table PARTITION p1 WHERE k1 = 3", True),
|
||||
# TRANSLATE TRINO only returns translated SQL text; it is a read.
|
||||
("TRANSLATE TRINO SELECT 1", False),
|
||||
("SELECT 1", False),
|
||||
("SHOW TABLES", False),
|
||||
("SHOW TABLES IN catalog_1.schema_a", False),
|
||||
],
|
||||
)
|
||||
def test_is_mutating_starrocks_command_constructs(sql: str, expected: bool) -> None:
|
||||
"""
|
||||
Several StarRocks constructs are either structured nodes sqlglot models
|
||||
but ``is_mutating`` didn't check (``exp.Analyze``, ``exp.Kill``,
|
||||
``exp.Refresh``), or fall back to an opaque ``exp.Command`` whose head
|
||||
keyword wasn't in the mutating set, or -- for ``SET PASSWORD`` on the
|
||||
caller's own account -- parse identically to a benign session variable.
|
||||
Every one of these must be classified as mutating so a query-only role
|
||||
can't run them through the SQL Lab read-only gate; ordinary session
|
||||
variables and reads must stay classified as non-mutating.
|
||||
"""
|
||||
assert SQLStatement(sql, "starrocks").is_mutating() == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"method",
|
||||
[LimitMethod.FORCE_LIMIT, LimitMethod.WRAP_SQL],
|
||||
|
||||
Reference in New Issue
Block a user