Compare commits

...
Author SHA1 Message Date
rusackasandClaude ad3b337aba docs(security): document PERMISSION_INSTRUCTIONS_LINK templating
#41843 added templated {datasource_id}/{datasource_name}/{table_names}/{username}
placeholders to PERMISSION_INSTRUCTIONS_LINK so the "Request access" link on
data-permission errors can deep-link into an org's access-request tool, but the
security config docs only described roles/permissions in general terms.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-04 13:40:11 -07:00
Endi Monan 0d025daaf7 fix(plugin-chart-table): defer search until IME composition ends (#43756) 2026-09-04 13:33:30 -07:00
c3109899e2 fix(dashboard): is_externally_managed still allows edits (#38584)
Co-authored-by: Matthew Kramer <matthewkramer@Matthews-MacBook-Pro-2.local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: rusackas <evan@rusackas.com>
2026-09-04 13:15:50 -07:00
ʈᵃᵢ 6956da1de5 fix(sql): close StarRocks SQL parsing and mutation-detection gaps (#43809) 2026-09-04 12:38:31 -07:00
Shivam Goel c1822666da ci: fail when the published OpenAPI spec is stale (#43841) 2026-09-04 12:35:04 -07:00
581ef8b7c1 chore(deps): bump antd from 6.6.1 to 6.6.2 in /docs (#43852)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Đỗ Trọng Hải <41283691+hainenber@users.noreply.github.com>
Co-authored-by: rusackas <evan@rusackas.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 12:11:11 -07:00
Ville Brofeldt 0636ac4b11 fix(examples): correct chart metadata (#43810) 2026-09-04 10:49:26 -07:00
Joe LiandClaude Sonnet 5 99a910b81c fix(echarts): render Plain Timeseries legends as HTML, not canvas (#43314)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 10:48:36 -07:00
Ville Brofeldt 635de27b25 fix(explore): prevent editable title over-truncation (#43811) 2026-09-04 10:29:10 -07:00
dependabot[bot] e1ce6b601c chore(deps-dev): bump jest-environment-jsdom from 30.4.1 to 30.5.0 in /superset-frontend (#43878)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-04 10:18:56 -07:00
dependabot[bot] db60f25ee9 chore(deps-dev): bump babel-jest from 30.4.1 to 30.5.0 in /superset-frontend (#43879)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-04 10:18:35 -07:00
dependabot[bot]andgithub-actions[bot] 297b0455f6 chore(deps): bump nh3 from 0.3.6 to 0.3.7 (#43868)
Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-09-04 10:18:01 -07:00
dependabot[bot] b1c33e5435 chore(deps-dev): bump hdbcli from 2.29.25 to 2.29.27 (#43869)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-04 10:17:51 -07:00
dependabot[bot]andgithub-actions[bot] b925246bef chore(deps): bump click from 8.4.2 to 8.5.0 (#43872)
Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-09-04 10:15:28 -07:00
dependabot[bot]andgithub-actions[bot] 592d378c0d chore(deps-dev): bump pandas-gbq from 0.35.1 to 0.35.2 (#43873)
Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-09-04 10:14:47 -07:00
dependabot[bot]andgithub-actions[bot] 935ee5f1a6 chore(deps): bump simplejson from 4.1.1 to 4.1.2 (#43874)
Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-09-04 10:14:37 -07:00
Mike BridgeandClaude Fable 5 d9b201db74 fix(explore): open version-history forks in a usable tab, not a blank one (#43830)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-09-04 09:19:06 -07:00
55 changed files with 4798 additions and 511 deletions
@@ -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
+25
View File
@@ -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
View File
@@ -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",
+110 -46
View File
@@ -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
View File
@@ -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
View File
@@ -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",
+3 -3
View File
@@ -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
+4 -4
View File
@@ -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
+932 -30
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -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",
@@ -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();
});
@@ -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();
}
});
@@ -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}
/>
@@ -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;
@@ -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;
@@ -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);
});
});
@@ -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();
@@ -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 = {
@@ -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,
+1
View File
@@ -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();
});
});
+29 -2
View File
@@ -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
View File
@@ -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()
+1 -1
View File
@@ -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
+4 -3
View File
@@ -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(
+1
View File
@@ -479,6 +479,7 @@ class DashboardRestApi(
"changed_by",
"dashboard_title",
"id",
"is_managed_externally",
"uuid",
"editors",
"viewers",
+29 -26
View File
@@ -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'
@@ -37,7 +37,7 @@ params:
columns: []
contribution: true
datasource: 21__table
granularity_sqla: year
x_axis: year
groupby:
- name
label_colors: {}
@@ -28,7 +28,7 @@ params:
columns: []
contribution: false
datasource: 21__table
granularity_sqla: year
x_axis: year
groupby:
- genre
label_colors:
+13 -12
View File
@@ -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'
+2
View File
@@ -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",
]
+813
View File
@@ -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
View File
@@ -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):
+38
View File
@@ -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()
+472
View File
@@ -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],