mirror of
https://github.com/apache/superset.git
synced 2026-09-09 00:34:49 +00:00
Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ad3b337aba | ||
|
|
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
|
||||
@@ -132,6 +132,31 @@ tables in the **Permissions** dropdown. To select the data sources you want to a
|
||||
You can then confirm with users assigned to the **Gamma** role that they see the
|
||||
objects (dashboards and slices) associated with the tables you just extended them.
|
||||
|
||||
### Actionable Access-Denied Messages
|
||||
|
||||
When a viewer opens a chart or dashboard built on a dataset they don't have access to, Superset
|
||||
shows a plain-language error naming the dataset (or tables, for SQL Lab) and, when known, the
|
||||
chart owners to contact. You can also point users at your own access-request process by setting
|
||||
`PERMISSION_INSTRUCTIONS_LINK` in `superset_config.py`:
|
||||
|
||||
```python
|
||||
PERMISSION_INSTRUCTIONS_LINK = (
|
||||
"https://access.example.com/request?dataset={datasource_name}&user={username}"
|
||||
)
|
||||
```
|
||||
|
||||
The URL may include any of the following placeholders, which are substituted with URL-encoded
|
||||
values so the link can deep-link into an internal ticketing or access-request tool with the
|
||||
denied resource pre-filled:
|
||||
|
||||
- `{datasource_id}` — id of the denied dataset (datasource errors)
|
||||
- `{datasource_name}` — name of the denied dataset (datasource errors)
|
||||
- `{table_names}` — comma-separated denied table names (table/SQL errors)
|
||||
- `{username}` — the requesting user's username
|
||||
|
||||
A URL with no placeholders is used as-is, and leaving `PERMISSION_INSTRUCTIONS_LINK` unset (the
|
||||
default) omits the "Request access" link, falling back to owner-contact guidance.
|
||||
|
||||
### Subjects
|
||||
|
||||
A **subject** is a unified identity that can be granted access to Superset resources such as
|
||||
|
||||
+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