mirror of
https://github.com/apache/superset.git
synced 2026-08-21 23:51:17 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ba34c97a0 | ||
|
|
a8284ec014 | ||
|
|
a05a099987 | ||
|
|
05842a6350 | ||
|
|
e45dd2d3f1 | ||
|
|
65910abf21 | ||
|
|
6e22050b50 | ||
|
|
132340b652 | ||
|
|
14408eb3db | ||
|
|
87743ef3f8 | ||
|
|
fb53761ec8 | ||
|
|
08935144c8 | ||
|
|
9b1f66823b | ||
|
|
e5ed37a333 | ||
|
|
6f96d1121f | ||
|
|
f7d505e1fd | ||
|
|
22396d504a | ||
|
|
27ea5de44a | ||
|
|
18fc2c6228 | ||
|
|
18a36d04c7 | ||
|
|
01b1d58ac9 | ||
|
|
7441ce90ae | ||
|
|
42ba2a4433 | ||
|
|
271564cb0d | ||
|
|
bcfb4346f6 | ||
|
|
b5fe00b407 | ||
|
|
148ffaff50 | ||
|
|
83c6ea4e03 | ||
|
|
1ca5e61f54 | ||
|
|
1ef12580a7 | ||
|
|
c0884c0f0c | ||
|
|
2ff79bd495 | ||
|
|
e7dccd44a7 | ||
|
|
fdd3945dde | ||
|
|
8b67fb4d30 | ||
|
|
b288db91f9 | ||
|
|
80bba12d0f | ||
|
|
5d4358a149 | ||
|
|
f9cedf84e2 | ||
|
|
c2d653b4b8 | ||
|
|
5a96c3f538 | ||
|
|
faf7c34c0a | ||
|
|
b8fca2145d | ||
|
|
c10054f521 | ||
|
|
8c500ccee1 | ||
|
|
6d77efad29 | ||
|
|
8222db3340 | ||
|
|
01ce8358a6 | ||
|
|
53a8a0e140 | ||
|
|
eafbff9f8d | ||
|
|
1339bcd9da | ||
|
|
334e280489 | ||
|
|
c07f3ebf2d | ||
|
|
1569915096 | ||
|
|
fde0ba26d1 |
@@ -1,6 +1,6 @@
|
||||
name: Bug report
|
||||
description: Report a bug to improve Superset's stability
|
||||
labels: ["bug"]
|
||||
labels: ["#bug"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: Cosmetic Issue
|
||||
about: Describe a cosmetic issue with CSS, positioning, layout, labeling, or similar
|
||||
labels: "cosmetic-issue"
|
||||
labels: "#bug:cosmetic"
|
||||
---
|
||||
|
||||
## Screenshot
|
||||
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
with:
|
||||
python-version: "3.11"
|
||||
enable-cache: true
|
||||
|
||||
@@ -67,7 +67,7 @@ jobs:
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
|
||||
uses: github/codeql-action/init@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
@@ -78,6 +78,6 @@ jobs:
|
||||
# queries: security-extended,security-and-quality
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
|
||||
uses: github/codeql-action/analyze@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
|
||||
with:
|
||||
category: "/language:${{matrix.language}}"
|
||||
|
||||
@@ -78,14 +78,14 @@ jobs:
|
||||
USE_DASHBOARD: ${{ github.event.inputs.use_dashboard == 'true' || 'false' }}
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:17-alpine
|
||||
image: ghcr.io/apache/superset/ci/postgres:17-alpine
|
||||
env:
|
||||
POSTGRES_USER: superset
|
||||
POSTGRES_PASSWORD: superset
|
||||
ports:
|
||||
- 15432:5432
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
image: ghcr.io/apache/superset/ci/redis:7-alpine
|
||||
ports:
|
||||
- 16379:6379
|
||||
steps:
|
||||
@@ -186,14 +186,14 @@ jobs:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:17-alpine
|
||||
image: ghcr.io/apache/superset/ci/postgres:17-alpine
|
||||
env:
|
||||
POSTGRES_USER: superset
|
||||
POSTGRES_PASSWORD: superset
|
||||
ports:
|
||||
- 15432:5432
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
image: ghcr.io/apache/superset/ci/redis:7-alpine
|
||||
ports:
|
||||
- 16379:6379
|
||||
steps:
|
||||
|
||||
@@ -53,9 +53,7 @@ jobs:
|
||||
mysql+mysqldb://superset:superset@127.0.0.1:13306/superset?charset=utf8mb4&binary_prefix=true
|
||||
services:
|
||||
mysql:
|
||||
image: mysql:8.0
|
||||
# Authenticated pulls use our higher Docker Hub rate limit. Empty on
|
||||
# fork PRs (secrets unavailable) -> runner falls back to anonymous.
|
||||
image: ghcr.io/apache/superset/ci/mysql:8.0
|
||||
env:
|
||||
MYSQL_ROOT_PASSWORD: root
|
||||
ports:
|
||||
@@ -66,7 +64,7 @@ jobs:
|
||||
--health-timeout=5s
|
||||
--health-retries=5
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
image: ghcr.io/apache/superset/ci/redis:7-alpine
|
||||
options: --entrypoint redis-server
|
||||
ports:
|
||||
- 16379:6379
|
||||
@@ -143,7 +141,7 @@ jobs:
|
||||
SUPERSET__SQLALCHEMY_DATABASE_URI: postgresql+psycopg2://superset:superset@127.0.0.1:15432/superset
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:17-alpine
|
||||
image: ghcr.io/apache/superset/ci/postgres:17-alpine
|
||||
env:
|
||||
POSTGRES_USER: superset
|
||||
POSTGRES_PASSWORD: superset
|
||||
@@ -152,7 +150,7 @@ jobs:
|
||||
# GitHub action runner's default installations
|
||||
- 15432:5432
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
image: ghcr.io/apache/superset/ci/redis:7-alpine
|
||||
ports:
|
||||
- 16379:6379
|
||||
steps:
|
||||
@@ -202,7 +200,7 @@ jobs:
|
||||
sqlite:///${{ github.workspace }}/.temp/examples.db?check_same_thread=true
|
||||
services:
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
image: ghcr.io/apache/superset/ci/redis:7-alpine
|
||||
ports:
|
||||
- 16379:6379
|
||||
steps:
|
||||
|
||||
@@ -52,7 +52,7 @@ jobs:
|
||||
SUPERSET__SQLALCHEMY_EXAMPLES_URI: presto://localhost:15433/memory/default
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:17-alpine
|
||||
image: ghcr.io/apache/superset/ci/postgres:17-alpine
|
||||
env:
|
||||
POSTGRES_USER: superset
|
||||
POSTGRES_PASSWORD: superset
|
||||
@@ -61,7 +61,7 @@ jobs:
|
||||
# GitHub action runner's default installations
|
||||
- 15432:5432
|
||||
presto:
|
||||
image: starburstdata/presto:350-e.6
|
||||
image: ghcr.io/apache/superset/ci/presto:350-e.6
|
||||
env:
|
||||
POSTGRES_USER: superset
|
||||
POSTGRES_PASSWORD: superset
|
||||
@@ -70,7 +70,7 @@ jobs:
|
||||
# GitHub action runner's default installations
|
||||
- 15433:8080
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
image: ghcr.io/apache/superset/ci/redis:7-alpine
|
||||
ports:
|
||||
- 16379:6379
|
||||
steps:
|
||||
@@ -116,7 +116,7 @@ jobs:
|
||||
UPLOAD_FOLDER: /tmp/.superset/uploads/
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:17-alpine
|
||||
image: ghcr.io/apache/superset/ci/postgres:17-alpine
|
||||
env:
|
||||
POSTGRES_USER: superset
|
||||
POSTGRES_PASSWORD: superset
|
||||
@@ -125,7 +125,7 @@ jobs:
|
||||
# GitHub action runner's default installations
|
||||
- 15432:5432
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
image: ghcr.io/apache/superset/ci/redis:7-alpine
|
||||
ports:
|
||||
- 16379:6379
|
||||
steps:
|
||||
|
||||
+40
@@ -26,6 +26,29 @@ assists people when migrating to a new version.
|
||||
|
||||
- `SAMPLES_ROW_LIMIT` is now the default for `/datasource/samples` requests without a valid explicit `per_page`, rather than a hard per-request ceiling; explicit limits are honored up to the existing global row-limit ceiling, matching `/chart/data` SAMPLES requests.
|
||||
|
||||
### MCP tool results preserve stored string values
|
||||
|
||||
Structured MCP tool results no longer add `<UNTRUSTED-CONTENT>` wrappers or
|
||||
rewrite delimiter-looking text inside string fields. Tool-result content remains
|
||||
user-controlled data, but clients must convey that trust boundary outside domain
|
||||
values instead of recognizing or removing marker strings.
|
||||
|
||||
Clients that handled the former delimiter convention should stop stripping marker
|
||||
text: the same text can be legitimate stored content. Response models and content
|
||||
types are unchanged, and no metadata-database migration is required. Automated
|
||||
read-modify-write workflows should be paused or pinned away from older instances
|
||||
until every serving instance is upgraded; a mixed-version response has no reliable
|
||||
signal that tells a client whether its text is decorated. Redis-backed MCP response
|
||||
caches use a new internal namespace after the upgrade, so upgraded instances do not
|
||||
reuse older cached results.
|
||||
|
||||
Values that a client already wrote back with presentation wrappers cannot be
|
||||
distinguished safely from intentional content. Operators should review possible
|
||||
`<UNTRUSTED-CONTENT>` / `</UNTRUSTED-CONTENT>` wrappers and
|
||||
`[ESCAPED-UNTRUSTED-CONTENT-OPEN]` /
|
||||
`[ESCAPED-UNTRUSTED-CONTENT-CLOSE]` substitutions rather than applying an automatic
|
||||
marker-removal migration.
|
||||
|
||||
### OAuth2 database callback metrics include their outcome
|
||||
|
||||
The unqualified `DatabaseRestApi.oauth2` StatsD counter has been replaced with
|
||||
@@ -102,6 +125,23 @@ dialect; each package's constraint in `pyproject.toml` documents why.
|
||||
No application-level configuration changes are required for deployments
|
||||
that don't touch SQLAlchemy directly.
|
||||
|
||||
### New metric aggregates: MEDIAN, Sample Standard Deviation, Sample Variance
|
||||
|
||||
`MEDIAN`, `STDDEV_SAMP`, and `VAR_SAMP` are now available anywhere a metric
|
||||
aggregate is chosen (every chart type, SQL Lab, MCP), not only in Pivot
|
||||
Table's controls. Support is opt-in per database engine *spec class*,
|
||||
verified against a live instance before being enabled: Postgres, MySQL
|
||||
(`STDDEV_SAMP`/`VAR_SAMP` only, no `MEDIAN`), DuckDB, and Redshift (inherits
|
||||
Postgres's support, not yet separately verified) ship enabled in this
|
||||
release. Engine specs that subclass one of those (e.g. MariaDB, Aurora
|
||||
MySQL/Postgres, TimescaleDB) inherit the same support, on the same
|
||||
not-yet-independently-verified basis. Picking one of these aggregates on a
|
||||
database that has not opted in returns a clear "not supported on this
|
||||
database" error rather than a failed query. See
|
||||
`docs/sip/median-stddev-variance-aggregates.md` for the full design
|
||||
rationale, including why this is safe to add without reintroducing the
|
||||
totals/subtotals correctness bug fixed by #41184 (SIP-216).
|
||||
|
||||
### Soft delete is on by default, and purging is live
|
||||
|
||||
`SOFT_DELETE` now ships **on** (`DEFAULT_FEATURE_FLAGS`), so deleting a
|
||||
|
||||
@@ -576,7 +576,7 @@ MCP_CACHE_CONFIG = {
|
||||
| Key | Default | Description |
|
||||
| -------------------- | --------- | ----------------------------------------------------------- |
|
||||
| `enabled` | `False` | Enable response caching |
|
||||
| `CACHE_KEY_PREFIX` | `None` | Optional prefix for cache keys (useful for shared Redis) |
|
||||
| `CACHE_KEY_PREFIX` | `None` | Base prefix for shared Redis; Superset appends an internal response-contract namespace |
|
||||
| `list_tools_ttl` | `300` | Cache TTL in seconds for `tools/list` |
|
||||
| `list_resources_ttl` | `300` | Cache TTL for `resources/list` |
|
||||
| `list_prompts_ttl` | `300` | Cache TTL for `prompts/list` |
|
||||
@@ -718,6 +718,34 @@ Every MCP request passes through a middleware stack before reaching the tool fun
|
||||
|
||||
Additional middleware classes (`RateLimitMiddleware`, `FieldPermissionsMiddleware`, `PrivateToolMiddleware`) are implemented in `superset/mcp_service/middleware.py` but are not added to the default pipeline. They are available for operators who want to layer them in via a custom startup path.
|
||||
|
||||
### Tool Result Value Contract
|
||||
|
||||
Structured tool results preserve Superset domain values exactly. In particular,
|
||||
string fields are not wrapped in trust delimiters, and text that resembles a
|
||||
delimiter is returned as literal application data. This lets clients safely use a
|
||||
read result as the basis for an update without persisting presentation markup.
|
||||
|
||||
All tool-result content should still be treated as user-controlled data with no
|
||||
instruction authority. MCP clients should communicate that trust boundary through
|
||||
their model instructions or presentation layer, outside the returned field values;
|
||||
fixed or generated marker strings inside a value are ambiguous and must not be used
|
||||
as a trust signal.
|
||||
|
||||
For compatibility, clients that supported the former
|
||||
`<UNTRUSTED-CONTENT>` convention should stop recognizing or stripping those strings.
|
||||
The response schemas and content types have not changed. Because marker-looking text
|
||||
can be legitimate application data, a client cannot reliably distinguish a legacy
|
||||
decorated response from a clean one. Pause automated read-modify-write workflows, or
|
||||
route them only to upgraded instances, until every serving instance is upgraded.
|
||||
|
||||
Redis-backed MCP response caches include an internal response-contract namespace, so
|
||||
an upgraded instance does not reuse responses cached by an older release. Older
|
||||
instances can still return legacy values while they remain in service. After the
|
||||
upgrade, review previously written values for wrapper text and both
|
||||
`[ESCAPED-UNTRUSTED-CONTENT-OPEN]` and
|
||||
`[ESCAPED-UNTRUSTED-CONTENT-CLOSE]`; do not remove these strings automatically,
|
||||
because they may be intentional content.
|
||||
|
||||
### Error Sanitization
|
||||
|
||||
The `GlobalErrorHandlerMiddleware` automatically redacts sensitive information from all error messages before they reach the LLM client. The following are replaced with generic messages:
|
||||
@@ -752,7 +780,11 @@ For a 3-pod Kubernetes deployment with the defaults above, expect up to 3 × (5
|
||||
Enable response caching for read-heavy workloads (dashboards/datasets that don't change frequently). With the in-memory backend (default when `MCP_STORE_CONFIG` is disabled), caching is per-process. Use Redis-backed caching for consistent cache hits across multiple pods:
|
||||
|
||||
```python
|
||||
MCP_CACHE_CONFIG = {"enabled": True, "call_tool_ttl": 3600}
|
||||
MCP_CACHE_CONFIG = {
|
||||
"enabled": True,
|
||||
"CACHE_KEY_PREFIX": "mcp_cache_",
|
||||
"call_tool_ttl": 3600,
|
||||
}
|
||||
MCP_STORE_CONFIG = {"enabled": True, "CACHE_REDIS_URL": "redis://redis:6379/0"}
|
||||
```
|
||||
|
||||
|
||||
+6
-6
@@ -58,14 +58,14 @@
|
||||
"@fontsource/inter": "^5.3.0",
|
||||
"@mdx-js/react": "^3.1.1",
|
||||
"@saucelabs/theme-github-codeblock": "^0.3.0",
|
||||
"@storybook/addon-docs": "^10.5.7",
|
||||
"@storybook/addon-docs": "^10.5.8",
|
||||
"@superset-ui/core": "^0.20.4",
|
||||
"@swc/core": "^1.15.47",
|
||||
"antd": "^6.6.0",
|
||||
"baseline-browser-mapping": "^2.11.13",
|
||||
"caniuse-lite": "^1.0.30001809",
|
||||
"docusaurus-plugin-openapi-docs": "^5.1.3",
|
||||
"docusaurus-theme-openapi-docs": "^5.1.3",
|
||||
"docusaurus-plugin-openapi-docs": "^5.2.0",
|
||||
"docusaurus-theme-openapi-docs": "^5.2.0",
|
||||
"js-yaml": "^5.2.3",
|
||||
"json-bigint": "^1.0.0",
|
||||
"prism-react-renderer": "^2.4.1",
|
||||
@@ -77,8 +77,8 @@
|
||||
"react-table": "^7.8.0",
|
||||
"remark-import-partial": "^0.0.2",
|
||||
"reselect": "^5.2.0",
|
||||
"storybook": "^10.5.7",
|
||||
"swagger-ui-react": "^5.32.12",
|
||||
"storybook": "^10.5.8",
|
||||
"swagger-ui-react": "^5.32.13",
|
||||
"swc-loader": "^0.2.7",
|
||||
"tinycolor2": "^1.4.2",
|
||||
"unist-util-visit": "^5.1.0"
|
||||
@@ -93,7 +93,7 @@
|
||||
"@typescript-eslint/parser": "^8.67.0",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"globals": "^17.9.0",
|
||||
"globals": "^17.11.0",
|
||||
"oxfmt": "^0.63.0",
|
||||
"typescript": "~6.0.3",
|
||||
"typescript-eslint": "^8.67.0",
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
<!--
|
||||
Licensed to the Apache Software Foundation (ASF) under one
|
||||
or more contributor license agreements. See the NOTICE file
|
||||
distributed with this work for additional information
|
||||
regarding copyright ownership. The ASF licenses this file
|
||||
to you under the Apache License, Version 2.0 (the
|
||||
"License"); you may not use this file except in compliance
|
||||
with the License. You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing,
|
||||
software distributed under the License is distributed on an
|
||||
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
KIND, either express or implied. See the License for the
|
||||
specific language governing permissions and limitations
|
||||
under the License.
|
||||
-->
|
||||
|
||||
# SIP: System-wide MEDIAN, Sample Standard Deviation, and Sample Variance metric aggregates
|
||||
|
||||
## [DRAFT — proposal for discussion]
|
||||
|
||||
This document now has an accompanying implementation in this PR, for the
|
||||
proposed mechanism plus a first, empirically-verified engine set (Postgres,
|
||||
MySQL, DuckDB, Redshift by inheritance). It is intended to seed discussion on
|
||||
whether this is the right shape and scope before it goes up for a formal SIP
|
||||
vote, not to pre-empt that discussion, the code exists so reviewers have a
|
||||
concrete design to react to rather than a description of one.
|
||||
|
||||
## Motivation
|
||||
|
||||
Before #41184 (SIP-216, the non-additive-totals fix), the Pivot Table chart
|
||||
exposed an "Aggregation function" control with 18 choices, including
|
||||
`Median`, `Sample Standard Deviation`, `Sample Variance`, `First`, `Last`,
|
||||
`Count Unique Values`, and `List Unique Values`. #41184 deleted that control
|
||||
wholesale, and deliberately so: it re-aggregated already-aggregated cell
|
||||
values to compute totals/subtotals, which is exactly the class of bug
|
||||
SIP-216 fixed (summing per-group averages, averaging per-group medians, etc.
|
||||
produces silently wrong totals). #42761 subsequently restored the one piece
|
||||
of that control's functionality that was cleanly separable from the
|
||||
correctness bug, the "show as % of row/column/total" display option,
|
||||
redesigned as a decoupled, post-hoc-only `showValuesAs` control.
|
||||
|
||||
A user has since noticed that several of the other pre-#41184 options never
|
||||
came back. Checking today's metric aggregate list (`AVG, COUNT,
|
||||
COUNT_DISTINCT, MAX, MIN, SUM`, see
|
||||
`superset-frontend/packages/superset-ui-core/src/query/types/Metric.ts`),
|
||||
most of these have a reasonable equivalent already: `Count Unique Values`
|
||||
maps to `COUNT_DISTINCT`; `Count`/`Average`/`Max`/`Min` are already standard
|
||||
aggregates; the two "fraction of" variants are already covered by
|
||||
`showValuesAs`. But `Median`, `Sample Standard Deviation`, and `Sample
|
||||
Variance` have no equivalent today anywhere in Superset, not just in Pivot
|
||||
Table, in any chart type, since the aggregate list is shared across the
|
||||
whole app.
|
||||
|
||||
This is a real, currently-live gap, not a hypothetical one:
|
||||
`superset/mcp_service/chart/chart_utils.py`, `schemas.py`, and
|
||||
`prompts/create_chart_guided.py` already treat `STDDEV`, `VAR`, `MEDIAN`,
|
||||
and `PERCENTILE` as valid aggregate values in their own validation and
|
||||
documentation, but those values are never recognized by
|
||||
`superset/connectors/sqla/models.py`'s `sqla_aggregations` dict (the actual
|
||||
mapping from aggregate name to SQL), so an AI agent using the MCP tool to
|
||||
build a chart with `"aggregate": "STDDEV"` today creates a chart that
|
||||
**errors at query time** with "Adhoc metric aggregate is invalid." This SIP
|
||||
proposes closing that gap for real, at the source, rather than patching
|
||||
around it in MCP.
|
||||
|
||||
## Proposed change
|
||||
|
||||
Add `MEDIAN`, `STDDEV_SAMP`, and `VAR_SAMP` as first-class, system-wide
|
||||
metric aggregates, available anywhere a metric aggregate is chosen (every
|
||||
chart type, SQL Lab metric picker, MCP), not as a Pivot-Table-specific
|
||||
control.
|
||||
|
||||
**Why this is safe with respect to SIP-216, and needs no Pivot-Table-specific
|
||||
code at all:** Pivot Table's non-additive-totals machinery
|
||||
(`superset-frontend/plugins/plugin-chart-pivot-table/src/plugin/utilities.ts`)
|
||||
already classifies any metric aggregate not in `ADDITIVE_AGGREGATES = {SUM,
|
||||
COUNT, MIN, MAX}` as non-additive, which routes totals/subtotals through the
|
||||
correct DB-`GROUPING SETS`-rollup path rather than client-side
|
||||
re-aggregation (`AVG` and `COUNT_DISTINCT` already go through this path
|
||||
today). `MEDIAN`/`STDDEV_SAMP`/`VAR_SAMP` fall into that bucket
|
||||
automatically, with zero changes needed to the additivity logic. So once
|
||||
these are valid, buildable SQL aggregates, Pivot Table (and every other
|
||||
chart) gets correct behavior for free. This is the version of "restore the
|
||||
control" that does not reopen the bug that was just fixed.
|
||||
|
||||
**Where the actual change needs to land, and what this PR does:**
|
||||
|
||||
1. **Done.** `superset-frontend/packages/superset-ui-core/src/query/types/Metric.ts`,
|
||||
extended the `Aggregate` type.
|
||||
2. **Done.** `superset-frontend/src/explore/constants.ts`, added to `AGGREGATES`
|
||||
(drives `AGGREGATES_OPTIONS`, the dropdown in `AdhocMetricEditPopover`).
|
||||
3. **Done**, but not consolidated. `superset/connectors/sqla/models.py`
|
||||
(`sqla_aggregations`) and `superset/models/helpers.py`
|
||||
(`ExploreMixin.sqla_aggregations`) are both wired to consult the new
|
||||
`BaseEngineSpec.get_extended_aggregation_func`, in addition to their
|
||||
existing 6-aggregate dict, so neither's original, already-tested behavior
|
||||
changed. They remain two separate dicts, consolidating them into one
|
||||
source of truth is left as a follow-up (see Open questions).
|
||||
4. **Done**, and it surfaced a second, smaller bug on top of the one this SIP
|
||||
opened with: MCP's own aggregate names (`STDDEV`, `VAR`) never matched any
|
||||
real Superset aggregate, before or after this PR, they were always going
|
||||
to error regardless of what this SIP does. `superset/mcp_service/chart/*`
|
||||
now accepts the old shorthand as an alias, normalized to the real,
|
||||
unambiguous names (`STDDEV_SAMP`, `VAR_SAMP`) this PR ships, and the guided
|
||||
prompt text points at the correct names going forward. `MEDIAN`/
|
||||
`PERCENTILE` were already spelled correctly in MCP; `PERCENTILE` remains
|
||||
unimplemented (it needs a parameter this schema has no field for) and is
|
||||
unchanged by this PR, out of scope here.
|
||||
|
||||
**The part that needs real engineering care, this must not be a blind
|
||||
`sa.func.MEDIAN` / `sa.func.STDDEV_SAMP` / `sa.func.VAR_SAMP`:**
|
||||
|
||||
`sqla_aggregations` today is a flat, engine-unaware dict (`sa.func.AVG`,
|
||||
etc., SQLAlchemy emits whatever function name it is given, with zero
|
||||
validation that the target dialect actually has it). Superset already has
|
||||
precedent for exactly this class of per-engine capability difference:
|
||||
`BaseEngineSpec.supports_grouping_sets` and `_time_grain_expressions`, both
|
||||
introduced by #41184 itself. This SIP proposes the same shape, a new
|
||||
per-engine-overridable mechanism (for example
|
||||
`BaseEngineSpec.get_aggregate_sql(aggregate, column)` with a sensible
|
||||
default, overridden per engine spec where the default does not hold),
|
||||
rather than a single hardcoded dict.
|
||||
|
||||
Verified findings so far (via `sqlglot.transpile`, cross-checked against
|
||||
known engine docs; **not** exhaustively tested against live databases, that
|
||||
is necessary follow-up work this SIP alone cannot complete):
|
||||
|
||||
| Engine | `MEDIAN(x)` | `STDDEV_SAMP(x)` | `VAR_SAMP(x)` |
|
||||
|---|---|---|---|
|
||||
| Postgres | `PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY x)` | native | native |
|
||||
| MySQL | no native equivalent, needs explicit "unsupported" handling, not a blind emit | native | MySQL's `VARIANCE()` is an alias for `VAR_POP` (population), not `VAR_SAMP` (sample); a naive dialect-name substitution would silently compute the wrong statistic and needs an explicit, verified expression instead |
|
||||
| SQLite | only if the specific build was compiled with the (non-default) `SQLITE_ENABLE_PERCENTILE` extension (added in SQLite 3.43, 2023), cannot be assumed available | not available in core SQLite | not available in core SQLite |
|
||||
| BigQuery / Snowflake / DuckDB / Redshift / Oracle / T-SQL / Databricks / Spark | native `MEDIAN(x)` | native | native on BigQuery/Snowflake/Databricks/Spark, where `VARIANCE` is correctly sample variance; T-SQL has no function named `VARIANCE` at all and needs `VAR(x)` instead |
|
||||
| Trino / Presto / Hive | `PERCENTILE_CONT` / `approx_percentile` (dialect- and exactness-dependent) | native | `variance` is correctly sample variance per Trino/Presto docs |
|
||||
|
||||
This table is deliberately not exhaustive, Superset has roughly 75
|
||||
`db_engine_specs` files. The proposed default (`BaseEngineSpec`) should be
|
||||
the safe choice (mark unsupported, surface a clear user-facing error) rather
|
||||
than an optimistic one, with individual engine specs opting in once
|
||||
verified. Ship for the handful of engines above first, extend
|
||||
opportunistically.
|
||||
|
||||
**`Count Unique Values`, `First`, `Last`, `List Unique Values`, explicitly
|
||||
out of scope for this SIP:**
|
||||
|
||||
- `Count Unique Values` needs no work, it is already `COUNT_DISTINCT`.
|
||||
- `First`/`Last` have no well-defined, unambiguous meaning as a plain
|
||||
`GROUP BY` aggregate without an explicit ordering; most engines only
|
||||
support this via window functions (`FIRST_VALUE`/`LAST_VALUE` `OVER
|
||||
(ORDER BY ...)`) or do not support it as a simple aggregate at all
|
||||
(Postgres has neither built in). Restoring this properly would mean
|
||||
designing an "order by" sub-control on the metric, a real, separate
|
||||
feature, not a one-line aggregate addition. Proposed as a follow-up SIP if
|
||||
there is demand.
|
||||
- `List Unique Values` maps to the `STRING_AGG`/`GROUP_CONCAT`/`LISTAGG`/
|
||||
`ARRAY_AGG(DISTINCT ...)` family, real dialect differences, plus an open
|
||||
UX question (unbounded cell content for high-cardinality columns).
|
||||
Proposed as a follow-up SIP.
|
||||
|
||||
## New or changed public interfaces
|
||||
|
||||
- New `Aggregate` values (`MEDIAN`, `STDDEV_SAMP`, `VAR_SAMP`) selectable
|
||||
anywhere the standard metric control appears, every chart type, not just
|
||||
Pivot Table.
|
||||
- New `BaseEngineSpec` extensibility point for per-engine aggregate SQL
|
||||
generation (exact shape TBD in implementation, likely mirrors
|
||||
`_time_grain_expressions`).
|
||||
- No REST API surface changes beyond the existing metric aggregate field
|
||||
accepting new values.
|
||||
|
||||
## Migration plan and compatibility
|
||||
|
||||
No new tables/columns needed for the aggregate addition itself.
|
||||
|
||||
Restoring prior chart settings, the way #42761 restored `show_values_as` for
|
||||
charts that had it before #41184, is murkier here than it was for that PR
|
||||
and needs its own design pass: the old `aggregate_function` was a single
|
||||
Pivot-Table-level setting applied uniformly to every metric on the chart,
|
||||
not a per-metric property. A chart that had `aggregate_function: Median`
|
||||
before #41184, with a metric of `SUM(sales)`, was already silently wrong
|
||||
under the old architecture (that is the bug that was fixed); mechanically
|
||||
rewriting its metric to `MEDIAN(sales)` on upgrade would change what the
|
||||
chart's leaf cells display, not just its totals, which may not match user
|
||||
intent. This SIP proposes a best-effort, flagged-for-review migration
|
||||
(surface affected charts to admins rather than silently rewriting them)
|
||||
rather than a fully automatic one-to-one restoration.
|
||||
|
||||
## Rejected alternatives
|
||||
|
||||
- **Restoring the old `aggregateFunction` Pivot-Table control as-is.**
|
||||
Rejected: this is the literal mechanism SIP-216 removed because it
|
||||
reintroduces incorrect totals for non-additive metrics. Any fix has to go
|
||||
through the metric's own aggregate, not a separate pivot-level override.
|
||||
- **Routing all metric SQL generation through `sqlglot` expression-building
|
||||
instead of SQLAlchemy's `sa.func`.** More architecturally thorough (would
|
||||
give correct dialect syntax for free across more of the roughly 75 engine
|
||||
specs), but a much larger, more invasive change to a hot path used by
|
||||
every chart query. Noted as a possible future direction, not this SIP's
|
||||
scope; this SIP proposes the smaller, `supports_grouping_sets`-shaped
|
||||
extensibility point instead.
|
||||
|
||||
## Open questions
|
||||
|
||||
- **Resolved for this PR, worth confirming as the community's preferred
|
||||
shape:** implemented as `BaseEngineSpec._extended_aggregations` (a
|
||||
`{aggregate_name: sqla_column -> sqla_column}` dict) plus a
|
||||
`get_extended_aggregation_func` accessor, set on the concrete or shared
|
||||
base engine spec class per engine (e.g. on `PostgresBaseEngineSpec` so
|
||||
Redshift inherits it, but *not* on `PrestoBaseEngineSpec` so Hive/Spark/
|
||||
Databricks don't silently inherit unverified behavior, mirroring how
|
||||
`supports_grouping_sets` is opted into per-concrete-engine there today).
|
||||
Did not route through the `superset/sql/dialects/` sqlglot-based layer;
|
||||
that layer is for SQL Lab parsing, wiring it into chart-metric query
|
||||
building felt like a separate, larger change from this SIP's scope.
|
||||
- **Still open, not addressed in this PR:** how aggressively should
|
||||
`MEDIAN` degrade on engines without a native or exact equivalent?
|
||||
Trino/Presto/Hive were left unimplemented (unsupported) specifically to
|
||||
avoid silently answering this with an approximate function
|
||||
(`approx_percentile`) that changes the semantics of what a user asked
|
||||
for. If someone wants `MEDIAN` on those engines, this needs a real
|
||||
decision: require explicit opt-in, show a UI warning, or keep it
|
||||
disallowed.
|
||||
- **Resolved for this PR:** left the two `sqla_aggregations` dicts
|
||||
(`connectors/sqla/models.py` and `models/helpers.py`) unconsolidated,
|
||||
both now separately wired to the same new `get_extended_aggregation_func`
|
||||
hook. Consolidating them into one source of truth is real but unrelated
|
||||
cleanup, not bundled here to keep the diff reviewable.
|
||||
- **New, from implementation:** only Postgres, MySQL (partial), DuckDB, and
|
||||
Redshift (by inheritance, unverified) ship enabled. BigQuery, Snowflake,
|
||||
Trino, Presto, Hive, Spark, Databricks, Oracle, and T-SQL all have
|
||||
documented (not live-verified) support per the table above but are not
|
||||
yet wired up, each needs the same live-instance verification treatment
|
||||
before being enabled, this PR intentionally didn't guess.
|
||||
+41
-41
@@ -4122,23 +4122,23 @@
|
||||
resolved "https://registry.yarnpkg.com/@standard-schema/utils/-/utils-0.3.0.tgz#3d5e608f16c2390c10528e98e59aef6bf73cae7b"
|
||||
integrity sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==
|
||||
|
||||
"@storybook/addon-docs@^10.5.7":
|
||||
version "10.5.7"
|
||||
resolved "https://registry.yarnpkg.com/@storybook/addon-docs/-/addon-docs-10.5.7.tgz#6d599c94fc871c248ce06a5c081f57655c83f40a"
|
||||
integrity sha512-KNARJfjICaizinsR3INMEiipZm1ObYo+xw+E26gteu50Bcy2dIZUtk5uHY5XdtardU3AXX6yRXoBZ2HCY3lbHA==
|
||||
"@storybook/addon-docs@^10.5.8":
|
||||
version "10.5.8"
|
||||
resolved "https://registry.yarnpkg.com/@storybook/addon-docs/-/addon-docs-10.5.8.tgz#767c10c7a4cc1b625b93f869b2a2b09fc8514f2e"
|
||||
integrity sha512-NlHiMKW/UvW/uL8HXFDCEVwoH3qZeGYZ/qlWax4d7H471b/T54MBq2KcB4ZrdA785FfIH3numAJdBb5jwn00Mg==
|
||||
dependencies:
|
||||
"@mdx-js/react" "^3.0.0"
|
||||
"@storybook/csf-plugin" "10.5.7"
|
||||
"@storybook/csf-plugin" "10.5.8"
|
||||
"@storybook/icons" "^2.0.2"
|
||||
"@storybook/react-dom-shim" "10.5.7"
|
||||
"@storybook/react-dom-shim" "10.5.8"
|
||||
react "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
react-dom "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
ts-dedent "^2.0.0"
|
||||
|
||||
"@storybook/csf-plugin@10.5.7":
|
||||
version "10.5.7"
|
||||
resolved "https://registry.yarnpkg.com/@storybook/csf-plugin/-/csf-plugin-10.5.7.tgz#bc73f164d1b5f8e2931b2774f4b389a06453cf6e"
|
||||
integrity sha512-IaX8FlM0H36HNFhJ2+4L9bCldqfvHGqcLg841SJNyK/DhfMlM7JsvY/GDH2ZFuWrUf8FSOx96GRRnHq6XfRKag==
|
||||
"@storybook/csf-plugin@10.5.8":
|
||||
version "10.5.8"
|
||||
resolved "https://registry.yarnpkg.com/@storybook/csf-plugin/-/csf-plugin-10.5.8.tgz#c626c5bfe55d0e279b2457e5cf150a788d1fc637"
|
||||
integrity sha512-/FHiMyOWWEXfwK/lM0WxmkP9GLzbSJJuzGtfeuNWSOVDnvAMbjavitxfHb5wSbWKIQo0XYC1EJ2Y7x91XNYP4w==
|
||||
dependencies:
|
||||
unplugin "^2.3.5"
|
||||
|
||||
@@ -4152,10 +4152,10 @@
|
||||
resolved "https://registry.yarnpkg.com/@storybook/icons/-/icons-2.1.0.tgz#edfc2450a39c5e780f28c6cbc49acd7bff59b41a"
|
||||
integrity sha512-Fxh9vYpX9bQqFeHRiY8h2ApeRGDzRSMLwJwNZ/AIRqnyOKHxRKL+yFe+ctEkVJmuptRE9u1Hrn8ZZNHyfDKKNg==
|
||||
|
||||
"@storybook/react-dom-shim@10.5.7":
|
||||
version "10.5.7"
|
||||
resolved "https://registry.yarnpkg.com/@storybook/react-dom-shim/-/react-dom-shim-10.5.7.tgz#9a5aa0e0f89c09e71c6cbfc6bb1abeb537e5aabf"
|
||||
integrity sha512-lxOkyh+wu/MiBXvYQHjZfD+DRKOa4bHBzbuGuiHXnHXmdOcTRdcrQTsoeN2FPtfugmmOG66cZUEgDwNX+k5eRA==
|
||||
"@storybook/react-dom-shim@10.5.8":
|
||||
version "10.5.8"
|
||||
resolved "https://registry.yarnpkg.com/@storybook/react-dom-shim/-/react-dom-shim-10.5.8.tgz#40cc3e32af424baa2e4109a325dae2ede29999e2"
|
||||
integrity sha512-N8D13/Xny+V3kfe1KBgsAHS0nKWXLLdgOOXS9poKdYzVwVCN+CGEGBxWX0zMMtdCptqa6/57em9coPlZMoO+bg==
|
||||
|
||||
"@superset-ui/core@^0.20.4":
|
||||
version "0.20.4"
|
||||
@@ -8014,10 +8014,10 @@ doctrine@^2.1.0:
|
||||
dependencies:
|
||||
esutils "^2.0.2"
|
||||
|
||||
docusaurus-plugin-openapi-docs@^5.1.3:
|
||||
version "5.1.3"
|
||||
resolved "https://registry.yarnpkg.com/docusaurus-plugin-openapi-docs/-/docusaurus-plugin-openapi-docs-5.1.3.tgz#b8cd5f8451aaf881deb1a744a8295685f1681865"
|
||||
integrity sha512-HnpblSBdXoR39VNTIW9zWERUsMJxXOpvdQoBKyaTkUBPwCM48Z76+ndo2yO2vADq+EhWjJlfxL1DUzCrgNjThQ==
|
||||
docusaurus-plugin-openapi-docs@^5.2.0:
|
||||
version "5.2.0"
|
||||
resolved "https://registry.yarnpkg.com/docusaurus-plugin-openapi-docs/-/docusaurus-plugin-openapi-docs-5.2.0.tgz#8318ec90cd21fed023be57696211af7d72fd81db"
|
||||
integrity sha512-MjrfRAMB64uvdxRVz6L9AXWe4QFjCdoBAzYs306yyI3nnXHsFj2lv2FnLA90JV9CAUZaGiYMvvkzBo2Nrkq/9w==
|
||||
dependencies:
|
||||
"@apidevtools/json-schema-ref-parser" "^15.3.3"
|
||||
"@redocly/openapi-core" "^2.25.2"
|
||||
@@ -8035,10 +8035,10 @@ docusaurus-plugin-openapi-docs@^5.1.3:
|
||||
swagger2openapi "^7.0.8"
|
||||
xml-formatter "^3.6.6"
|
||||
|
||||
docusaurus-theme-openapi-docs@^5.1.3:
|
||||
version "5.1.3"
|
||||
resolved "https://registry.yarnpkg.com/docusaurus-theme-openapi-docs/-/docusaurus-theme-openapi-docs-5.1.3.tgz#e23644a63785352abbc76e42760c0dfdff3669e1"
|
||||
integrity sha512-npbD1QahtjAEmrOet/86i5fTmcJX4/rPhVT+c0qKjm7StUNbyqjwchSVBQuU1rB69T51JOA9TpT/y6QcB9Xjvw==
|
||||
docusaurus-theme-openapi-docs@^5.2.0:
|
||||
version "5.2.0"
|
||||
resolved "https://registry.yarnpkg.com/docusaurus-theme-openapi-docs/-/docusaurus-theme-openapi-docs-5.2.0.tgz#6d93a74e2e3cf0ae77d24e1c4144bd2e74a52115"
|
||||
integrity sha512-L0b80LzaMUfr76a9EQXRPCf8nxkEz8Xo6Aknnke1UeE2oXsgoiVki6U+RTE7GmJRjO8zSNKXyckGmGmqqWuHeA==
|
||||
dependencies:
|
||||
"@hookform/error-message" "^2.0.1"
|
||||
"@reduxjs/toolkit" "^2.8.2"
|
||||
@@ -8123,7 +8123,7 @@ domhandler@^5.0.2, domhandler@^5.0.3:
|
||||
dependencies:
|
||||
domelementtype "^2.3.0"
|
||||
|
||||
dompurify@^3.3.3, dompurify@^3.4.12:
|
||||
dompurify@^3.3.3, dompurify@^3.4.13:
|
||||
version "3.4.13"
|
||||
resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-3.4.13.tgz#fc28949d59f92d62e28a3a764bcbeee35897a1be"
|
||||
integrity sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==
|
||||
@@ -9174,10 +9174,10 @@ globals@^14.0.0:
|
||||
resolved "https://registry.yarnpkg.com/globals/-/globals-14.0.0.tgz#898d7413c29babcf6bafe56fcadded858ada724e"
|
||||
integrity sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==
|
||||
|
||||
globals@^17.9.0:
|
||||
version "17.9.0"
|
||||
resolved "https://registry.yarnpkg.com/globals/-/globals-17.9.0.tgz#e43f252d6bbe71508da43902a1709c8895a59f70"
|
||||
integrity sha512-m/MvAW61QVU5VDNF1Vj8axt016h8w7L5TU1e9zlab7XIttAT2YAlCwl75K1fOqvMM9apmD7lbCIRhpfkhmxhCg==
|
||||
globals@^17.11.0:
|
||||
version "17.11.0"
|
||||
resolved "https://registry.yarnpkg.com/globals/-/globals-17.11.0.tgz#d643485bb30220d7751e511cf4f68c73d3870d87"
|
||||
integrity sha512-Z2I8hM+PbJDXQDq3Icgpzv+mPdwr68iZUU9d5WW4FuXfDUQfkZaZuvjMv42/5crNyw154+9+VWXbYrUgDXbxNw==
|
||||
|
||||
globalthis@^1.0.4:
|
||||
version "1.0.4"
|
||||
@@ -10284,10 +10284,10 @@ js-levenshtein@^1.1.6:
|
||||
resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499"
|
||||
integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==
|
||||
|
||||
js-yaml@4.1.0, js-yaml@=4.3.0, js-yaml@^4.1.0, js-yaml@^4.1.1, js-yaml@^4.2.0, js-yaml@^4.3.0:
|
||||
version "4.3.0"
|
||||
resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.3.0.tgz#d1900572a7f7cf0b5f540c83673e60bad3436592"
|
||||
integrity sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==
|
||||
js-yaml@4.1.0, js-yaml@=4.3.1, js-yaml@^4.1.0, js-yaml@^4.1.1, js-yaml@^4.2.0, js-yaml@^4.3.0:
|
||||
version "4.3.1"
|
||||
resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.3.1.tgz#01216c001d67f48e2cd560d708c7af21090a3848"
|
||||
integrity sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==
|
||||
dependencies:
|
||||
argparse "^2.0.1"
|
||||
|
||||
@@ -14783,10 +14783,10 @@ stop-iteration-iterator@^1.1.0:
|
||||
es-errors "^1.3.0"
|
||||
internal-slot "^1.1.0"
|
||||
|
||||
storybook@^10.5.7:
|
||||
version "10.5.7"
|
||||
resolved "https://registry.yarnpkg.com/storybook/-/storybook-10.5.7.tgz#adfc465e51f337291c095278c23f1b8024ef2da7"
|
||||
integrity sha512-oiKvWIwIoOhFP1i6dASYyMXwPHKEtVZMshqSB7EvIVYjWRh0l9H7gHEt1z4Gh2rLGFMekWdsm4s94rvwpR7gkg==
|
||||
storybook@^10.5.8:
|
||||
version "10.5.8"
|
||||
resolved "https://registry.yarnpkg.com/storybook/-/storybook-10.5.8.tgz#d5f051983e6232c0a73ea02149a72c7bafb43275"
|
||||
integrity sha512-rR4oFMSiWBSqI0lvsJPtcQUPj8+hzj3TkLu+Mw61Wo6YxPSb5FsLSHai0jZnuaIdKIlmu25KCfwlSQl4e1uvnA==
|
||||
dependencies:
|
||||
"@storybook/global" "^5.0.0"
|
||||
"@storybook/icons" "^2.0.2"
|
||||
@@ -15103,10 +15103,10 @@ swagger-client@^3.37.8:
|
||||
"@swagger-api/apidom-parser-adapter-openapi-yaml-3-2" "^1.12.0"
|
||||
"@swagger-api/apidom-parser-adapter-yaml-1-2" "^1.12.0"
|
||||
|
||||
swagger-ui-react@^5.32.12:
|
||||
version "5.32.12"
|
||||
resolved "https://registry.yarnpkg.com/swagger-ui-react/-/swagger-ui-react-5.32.12.tgz#47525a26774eb02db0e6203af72f5b32fa6205cc"
|
||||
integrity sha512-WCdkNOQyMTZDu+z356FpwVWHf1dwZgQPUjdQPh1L4r7jULaJTKKlIItXq6WsZdYeXvsHndMdxxccEQXOAroUHQ==
|
||||
swagger-ui-react@^5.32.13:
|
||||
version "5.32.13"
|
||||
resolved "https://registry.yarnpkg.com/swagger-ui-react/-/swagger-ui-react-5.32.13.tgz#04c96140b0a2d4ea01ebec4d4cfc655d5ed9a500"
|
||||
integrity sha512-XIDl+Ny6kE1N8wpSPiOFrjPfAevs4GR4XmV6BT6NLMikkMFIbIVocWbA8pnKYyYXQe8Rccfli5o2zDfySw0FnQ==
|
||||
dependencies:
|
||||
"@babel/runtime-corejs3" "^7.27.1"
|
||||
"@scarf/scarf" "=1.4.0"
|
||||
@@ -15115,11 +15115,11 @@ swagger-ui-react@^5.32.12:
|
||||
classnames "^2.5.1"
|
||||
css.escape "1.5.1"
|
||||
deep-extend "0.6.0"
|
||||
dompurify "^3.4.12"
|
||||
dompurify "^3.4.13"
|
||||
ieee754 "^1.2.1"
|
||||
immutable "^4.3.9"
|
||||
js-file-download "^0.4.12"
|
||||
js-yaml "=4.3.0"
|
||||
js-yaml "=4.3.1"
|
||||
lodash "^4.18.1"
|
||||
prop-types "^15.8.1"
|
||||
randexp "^0.5.3"
|
||||
|
||||
@@ -93,7 +93,7 @@ def find_models(module: ModuleType) -> list[type[Model]]: # noqa: C901
|
||||
# where the current model is out-of-sync with the existing table after a
|
||||
# downgrade
|
||||
sqlalchemy_uri = current_app.config["SQLALCHEMY_DATABASE_URI"]
|
||||
engine = create_engine(sqlalchemy_uri, future=True)
|
||||
engine = create_engine(sqlalchemy_uri)
|
||||
Base = automap_base() # noqa: N806
|
||||
Base.prepare(engine, reflect=True)
|
||||
seen = set()
|
||||
|
||||
Generated
+88
-88
@@ -99,7 +99,7 @@
|
||||
"geostyler-openlayers-parser": "^5.7.1",
|
||||
"geostyler-style": "11.0.2",
|
||||
"geostyler-wfs-parser": "^3.0.1",
|
||||
"google-auth-library": "^11.0.1",
|
||||
"google-auth-library": "^11.0.2",
|
||||
"immer": "^11.1.16",
|
||||
"interweave": "^13.1.1",
|
||||
"jquery": "^4.0.0",
|
||||
@@ -180,13 +180,13 @@
|
||||
"@istanbuljs/nyc-config-typescript": "^1.0.1",
|
||||
"@playwright/test": "^1.62.1",
|
||||
"@pmmmwh/react-refresh-webpack-plugin": "^0.6.2",
|
||||
"@storybook/addon-docs": "10.5.7",
|
||||
"@storybook/addon-links": "10.5.7",
|
||||
"@storybook/react-webpack5": "10.5.7",
|
||||
"@storybook/addon-docs": "10.5.8",
|
||||
"@storybook/addon-links": "10.5.8",
|
||||
"@storybook/react-webpack5": "10.5.8",
|
||||
"@storybook/test-runner": "0.24.4",
|
||||
"@svgr/webpack": "^8.1.0",
|
||||
"@swc/core": "^1.15.47",
|
||||
"@swc/plugin-emotion": "^14.15.0",
|
||||
"@swc/plugin-emotion": "^14.19.0",
|
||||
"@swc/plugin-transform-imports": "^12.5.0",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^7.0.1",
|
||||
@@ -218,7 +218,7 @@
|
||||
"babel-loader": "^10.1.1",
|
||||
"babel-plugin-dynamic-import-node": "^2.3.3",
|
||||
"babel-plugin-jsx-remove-data-test-id": "^3.0.0",
|
||||
"baseline-browser-mapping": "^2.11.13",
|
||||
"baseline-browser-mapping": "^2.11.14",
|
||||
"cheerio": "1.2.0",
|
||||
"concurrently": "^10.0.4",
|
||||
"copy-webpack-plugin": "^14.0.0",
|
||||
@@ -235,7 +235,7 @@
|
||||
"eslint-plugin-no-only-tests": "^3.4.0",
|
||||
"eslint-plugin-react-prefer-function-component": "^5.0.0",
|
||||
"eslint-plugin-react-you-might-not-need-an-effect": "^1.0.1",
|
||||
"eslint-plugin-storybook": "10.5.7",
|
||||
"eslint-plugin-storybook": "10.5.8",
|
||||
"eslint-plugin-testing-library": "^7.16.2",
|
||||
"eslint-plugin-theme-colors": "file:eslint-rules/eslint-plugin-theme-colors",
|
||||
"fetch-mock": "^12.6.0",
|
||||
@@ -266,7 +266,7 @@
|
||||
"source-map": "^0.8.0",
|
||||
"source-map-support": "^0.5.21",
|
||||
"speed-measure-webpack-plugin": "^1.6.0",
|
||||
"storybook": "10.5.7",
|
||||
"storybook": "10.5.8",
|
||||
"style-loader": "^4.0.0",
|
||||
"stylelint": "^17.14.1",
|
||||
"swc-loader": "^0.2.7",
|
||||
@@ -10765,16 +10765,16 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@storybook/addon-docs": {
|
||||
"version": "10.5.7",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-10.5.7.tgz",
|
||||
"integrity": "sha512-KNARJfjICaizinsR3INMEiipZm1ObYo+xw+E26gteu50Bcy2dIZUtk5uHY5XdtardU3AXX6yRXoBZ2HCY3lbHA==",
|
||||
"version": "10.5.8",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-10.5.8.tgz",
|
||||
"integrity": "sha512-NlHiMKW/UvW/uL8HXFDCEVwoH3qZeGYZ/qlWax4d7H471b/T54MBq2KcB4ZrdA785FfIH3numAJdBb5jwn00Mg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@mdx-js/react": "^3.0.0",
|
||||
"@storybook/csf-plugin": "10.5.7",
|
||||
"@storybook/csf-plugin": "10.5.8",
|
||||
"@storybook/icons": "^2.0.2",
|
||||
"@storybook/react-dom-shim": "10.5.7",
|
||||
"@storybook/react-dom-shim": "10.5.8",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"ts-dedent": "^2.0.0"
|
||||
@@ -10785,7 +10785,7 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"storybook": "10.5.7"
|
||||
"storybook": "10.5.8"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
@@ -10794,9 +10794,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@storybook/addon-docs/node_modules/@storybook/csf-plugin": {
|
||||
"version": "10.5.7",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/csf-plugin/-/csf-plugin-10.5.7.tgz",
|
||||
"integrity": "sha512-IaX8FlM0H36HNFhJ2+4L9bCldqfvHGqcLg841SJNyK/DhfMlM7JsvY/GDH2ZFuWrUf8FSOx96GRRnHq6XfRKag==",
|
||||
"version": "10.5.8",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/csf-plugin/-/csf-plugin-10.5.8.tgz",
|
||||
"integrity": "sha512-/FHiMyOWWEXfwK/lM0WxmkP9GLzbSJJuzGtfeuNWSOVDnvAMbjavitxfHb5wSbWKIQo0XYC1EJ2Y7x91XNYP4w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -10809,7 +10809,7 @@
|
||||
"peerDependencies": {
|
||||
"esbuild": "*",
|
||||
"rollup": "*",
|
||||
"storybook": "10.5.7",
|
||||
"storybook": "10.5.8",
|
||||
"vite": "*",
|
||||
"webpack": "*"
|
||||
},
|
||||
@@ -10829,9 +10829,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@storybook/addon-docs/node_modules/@storybook/react-dom-shim": {
|
||||
"version": "10.5.7",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-10.5.7.tgz",
|
||||
"integrity": "sha512-lxOkyh+wu/MiBXvYQHjZfD+DRKOa4bHBzbuGuiHXnHXmdOcTRdcrQTsoeN2FPtfugmmOG66cZUEgDwNX+k5eRA==",
|
||||
"version": "10.5.8",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-10.5.8.tgz",
|
||||
"integrity": "sha512-N8D13/Xny+V3kfe1KBgsAHS0nKWXLLdgOOXS9poKdYzVwVCN+CGEGBxWX0zMMtdCptqa6/57em9coPlZMoO+bg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
@@ -10843,7 +10843,7 @@
|
||||
"@types/react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"storybook": "10.5.7"
|
||||
"storybook": "10.5.8"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
@@ -10855,9 +10855,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@storybook/addon-links": {
|
||||
"version": "10.5.7",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/addon-links/-/addon-links-10.5.7.tgz",
|
||||
"integrity": "sha512-17PxEOocLhAEaPeQ4q+8yul/LF9YEIePS1arknCAS7U1pQXTe0uj+R0pB6uPLVflM5gECQMiP4WzIj4tEiL6+A==",
|
||||
"version": "10.5.8",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/addon-links/-/addon-links-10.5.8.tgz",
|
||||
"integrity": "sha512-mpWw4alBJVGqgVh897LZ2keN/xnMHcH93wKJG+oGg4+cdEUA+06hCs5T4k+AS5Aa+EZ6LvdOoi2VPHssyQlCCA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -10870,7 +10870,7 @@
|
||||
"peerDependencies": {
|
||||
"@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"storybook": "10.5.7"
|
||||
"storybook": "10.5.8"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
@@ -10964,15 +10964,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@storybook/react-webpack5": {
|
||||
"version": "10.5.7",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/react-webpack5/-/react-webpack5-10.5.7.tgz",
|
||||
"integrity": "sha512-vvl07oXp2qfmHJHZ77Aw1F3LFOo7XubOta+lC8UmlEw3rDDJhQxJN3erJJVHavNhdA2jBTK6VUXQKdqQh7X7nQ==",
|
||||
"version": "10.5.8",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/react-webpack5/-/react-webpack5-10.5.8.tgz",
|
||||
"integrity": "sha512-HkPi42WaoNSHC0DAERsJEF7Vhnluzsp/aiuhnH65GGYG5TmdLL9G8KDiYvXHGDCyb4RfoAPYrtzxaLMFfPPFvQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@storybook/builder-webpack5": "10.5.7",
|
||||
"@storybook/preset-react-webpack": "10.5.7",
|
||||
"@storybook/react": "10.5.7"
|
||||
"@storybook/builder-webpack5": "10.5.8",
|
||||
"@storybook/preset-react-webpack": "10.5.8",
|
||||
"@storybook/react": "10.5.8"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
@@ -10981,7 +10981,7 @@
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"storybook": "10.5.7",
|
||||
"storybook": "10.5.8",
|
||||
"typescript": ">= 4.9.x"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
@@ -10991,13 +10991,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@storybook/react-webpack5/node_modules/@storybook/builder-webpack5": {
|
||||
"version": "10.5.7",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/builder-webpack5/-/builder-webpack5-10.5.7.tgz",
|
||||
"integrity": "sha512-4n4c60LihFivZnjAcXGO5+XbgZthoUtKb/nPKVgypj3MpEetzjq6XR83A4UNnRsXYmjqfn6bsDWNgEJ/RvQg5A==",
|
||||
"version": "10.5.8",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/builder-webpack5/-/builder-webpack5-10.5.8.tgz",
|
||||
"integrity": "sha512-ke5x27gtWQ4gpXCLWxdGkr8ZlJwBykV/KjbBTAlC04dmS9OkI9MBzGj+TteUlgrcaN7LwoNTR5zRmxKStOZYzQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@storybook/core-webpack": "10.5.7",
|
||||
"@storybook/core-webpack": "10.5.8",
|
||||
"case-sensitive-paths-webpack-plugin": "^2.4.0",
|
||||
"cjs-module-lexer": "^1.2.3",
|
||||
"css-loader": "^7.1.2",
|
||||
@@ -11019,7 +11019,7 @@
|
||||
"url": "https://opencollective.com/storybook"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"storybook": "10.5.7"
|
||||
"storybook": "10.5.8"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
@@ -11028,9 +11028,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@storybook/react-webpack5/node_modules/@storybook/builder-webpack5/node_modules/@storybook/core-webpack": {
|
||||
"version": "10.5.7",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/core-webpack/-/core-webpack-10.5.7.tgz",
|
||||
"integrity": "sha512-0dtDw/FNPREoeCHX2RgZz0OecxaAGol1R7bCobFevArxyFIPJisTfjDMUFHKr+3B7BilTd3vnatl7Nlvgs0EiA==",
|
||||
"version": "10.5.8",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/core-webpack/-/core-webpack-10.5.8.tgz",
|
||||
"integrity": "sha512-HccINB0UbTtnyJtKpaX+C35BRTSnAwnreIMwwI+LpeUd4x9mQg0G9orB7lfBBZwd5LQf8YhM2Vkjiawzo41GLg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -11041,17 +11041,17 @@
|
||||
"url": "https://opencollective.com/storybook"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"storybook": "10.5.7"
|
||||
"storybook": "10.5.8"
|
||||
}
|
||||
},
|
||||
"node_modules/@storybook/react-webpack5/node_modules/@storybook/preset-react-webpack": {
|
||||
"version": "10.5.7",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/preset-react-webpack/-/preset-react-webpack-10.5.7.tgz",
|
||||
"integrity": "sha512-xwNRcoVlIDx1/YYCFBAxfh/91vFiOgrVI+0Ir4u9eO87SH2leehRnJh619QEOrlQEU5px487y2BmL2ZVtmTpYA==",
|
||||
"version": "10.5.8",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/preset-react-webpack/-/preset-react-webpack-10.5.8.tgz",
|
||||
"integrity": "sha512-0JjgVoX5t9Wb+gwddYHx/Ej7KFqwd65lpHXEhBoT4pFWRqVI0pvfHu42M+DRGjsgOye3uE+3pH4yHR3+0/fCHA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@storybook/core-webpack": "10.5.7",
|
||||
"@storybook/core-webpack": "10.5.8",
|
||||
"@storybook/react-docgen-typescript-plugin": "1.0.6--canary.9.0c3f3b7.0",
|
||||
"@types/semver": "^7.7.1",
|
||||
"magic-string": "^0.30.5",
|
||||
@@ -11068,7 +11068,7 @@
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"storybook": "10.5.7"
|
||||
"storybook": "10.5.8"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
@@ -11077,9 +11077,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@storybook/react-webpack5/node_modules/@storybook/preset-react-webpack/node_modules/@storybook/core-webpack": {
|
||||
"version": "10.5.7",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/core-webpack/-/core-webpack-10.5.7.tgz",
|
||||
"integrity": "sha512-0dtDw/FNPREoeCHX2RgZz0OecxaAGol1R7bCobFevArxyFIPJisTfjDMUFHKr+3B7BilTd3vnatl7Nlvgs0EiA==",
|
||||
"version": "10.5.8",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/core-webpack/-/core-webpack-10.5.8.tgz",
|
||||
"integrity": "sha512-HccINB0UbTtnyJtKpaX+C35BRTSnAwnreIMwwI+LpeUd4x9mQg0G9orB7lfBBZwd5LQf8YhM2Vkjiawzo41GLg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -11090,18 +11090,18 @@
|
||||
"url": "https://opencollective.com/storybook"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"storybook": "10.5.7"
|
||||
"storybook": "10.5.8"
|
||||
}
|
||||
},
|
||||
"node_modules/@storybook/react-webpack5/node_modules/@storybook/react": {
|
||||
"version": "10.5.7",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/react/-/react-10.5.7.tgz",
|
||||
"integrity": "sha512-uFvty2MMdFXzW5PcQe1JqDAZkz6cQq7q/9G/cbGVnBEvP6zsOVeL+bmrQ0/WBlFQN0Ko9+ZoCTvaQ9s65zBa5g==",
|
||||
"version": "10.5.8",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/react/-/react-10.5.8.tgz",
|
||||
"integrity": "sha512-6qqkmqX6imtL+0Z9Uan2tIfYivOI0FiVmWr0zpqqQR15AkJ18JfNcNTQoyjeAlCO0Kei56SWqnu2qLq52TYplg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@storybook/global": "^5.0.0",
|
||||
"@storybook/react-dom-shim": "10.5.7",
|
||||
"@storybook/react-dom-shim": "10.5.8",
|
||||
"react-docgen": "^8.0.2",
|
||||
"react-docgen-typescript": "^2.2.2"
|
||||
},
|
||||
@@ -11114,7 +11114,7 @@
|
||||
"@types/react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"storybook": "10.5.7",
|
||||
"storybook": "10.5.8",
|
||||
"typescript": ">= 4.9.x"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
@@ -11130,9 +11130,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@storybook/react-webpack5/node_modules/@storybook/react/node_modules/@storybook/react-dom-shim": {
|
||||
"version": "10.5.7",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-10.5.7.tgz",
|
||||
"integrity": "sha512-lxOkyh+wu/MiBXvYQHjZfD+DRKOa4bHBzbuGuiHXnHXmdOcTRdcrQTsoeN2FPtfugmmOG66cZUEgDwNX+k5eRA==",
|
||||
"version": "10.5.8",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-10.5.8.tgz",
|
||||
"integrity": "sha512-N8D13/Xny+V3kfe1KBgsAHS0nKWXLLdgOOXS9poKdYzVwVCN+CGEGBxWX0zMMtdCptqa6/57em9coPlZMoO+bg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
@@ -11144,7 +11144,7 @@
|
||||
"@types/react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"storybook": "10.5.7"
|
||||
"storybook": "10.5.8"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
@@ -11808,9 +11808,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/plugin-emotion": {
|
||||
"version": "14.15.0",
|
||||
"resolved": "https://registry.npmjs.org/@swc/plugin-emotion/-/plugin-emotion-14.15.0.tgz",
|
||||
"integrity": "sha512-nCsTO7mOOPz2UnT3N6YWb014uI0CVxeKg53A/KM/CvuSIE6H3KPkhaziJQ3q2jI3u3LfFuDKEnU5ZmB1330Dqg==",
|
||||
"version": "14.19.0",
|
||||
"resolved": "https://registry.npmjs.org/@swc/plugin-emotion/-/plugin-emotion-14.19.0.tgz",
|
||||
"integrity": "sha512-0/q84ro0a7kdjpYpn9Wmi5/RLHYuSwYjO638lE5ZBQfIvYpSLJxbEgLsObCmdH4KPe2stoN8plVKUpCsKPggaw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
@@ -15697,9 +15697,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.11.13",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.13.tgz",
|
||||
"integrity": "sha512-k9HNuUVMlqVjQ9UHzfPjIqiDbWw7WqT1AoT7GL8VwvF3r0ZfArtgiSPAlmupyNquNgOJHTuH4CKYf8ttMTWBTQ==",
|
||||
"version": "2.11.14",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.14.tgz",
|
||||
"integrity": "sha512-JyJ954WzuIR8/FFzX0o5krdSTrBAkcCSRfWSleRsIHSWV+cZe2FI1PKggVkFke1hBldRs+LRxUczzE9iPmgZww==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
@@ -20219,9 +20219,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-storybook": {
|
||||
"version": "10.5.7",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-storybook/-/eslint-plugin-storybook-10.5.7.tgz",
|
||||
"integrity": "sha512-mLpamG1Rsica2jYbUzIZOEuy7Fm1IMtVLMvvxGTpjTVKUMxTXJsANx3MBpH2VSbGQB8Yzlt5399WL/O07K97Ig==",
|
||||
"version": "10.5.8",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-storybook/-/eslint-plugin-storybook-10.5.8.tgz",
|
||||
"integrity": "sha512-bf9W5nZyWdIaCUZf4aEZnEeD1mn+csNYX8dYUQjAo6L7/DkSLtr65R4zFZ1xeS4m6dOXO6UtUySesCSw4e8w1g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -20230,7 +20230,7 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"eslint": ">=8",
|
||||
"storybook": "10.5.7"
|
||||
"storybook": "10.5.8"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-testing-library": {
|
||||
@@ -22783,9 +22783,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/google-auth-library": {
|
||||
"version": "11.0.1",
|
||||
"resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-11.0.1.tgz",
|
||||
"integrity": "sha512-ZqfaYduu9ASUaFuUk5dF9g9QvufdhhSj7jFiEnCrTQcH57sFPKYetM0iU4dcKkQk6CqC1xpSrVr5uQ9NhqjNOg==",
|
||||
"version": "11.0.2",
|
||||
"resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-11.0.2.tgz",
|
||||
"integrity": "sha512-vzpgPutxrghPsnjrjpzLX2bdv8IOL719Rh0oEjGnQu8YCIbnbMuTTQ5zU9LcKvLdOPgCxBwppbvnhgW90Qna5Q==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.3.0",
|
||||
@@ -37711,9 +37711,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/storybook": {
|
||||
"version": "10.5.7",
|
||||
"resolved": "https://registry.npmjs.org/storybook/-/storybook-10.5.7.tgz",
|
||||
"integrity": "sha512-oiKvWIwIoOhFP1i6dASYyMXwPHKEtVZMshqSB7EvIVYjWRh0l9H7gHEt1z4Gh2rLGFMekWdsm4s94rvwpR7gkg==",
|
||||
"version": "10.5.8",
|
||||
"resolved": "https://registry.npmjs.org/storybook/-/storybook-10.5.8.tgz",
|
||||
"integrity": "sha512-rR4oFMSiWBSqI0lvsJPtcQUPj8+hzj3TkLu+Mw61Wo6YxPSb5FsLSHai0jZnuaIdKIlmu25KCfwlSQl4e1uvnA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -43073,6 +43073,15 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"packages/superset-ui-core/node_modules/dompurify": {
|
||||
"version": "3.4.13",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.13.tgz",
|
||||
"integrity": "sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
}
|
||||
},
|
||||
"packages/superset-ui-core/node_modules/react-ace": {
|
||||
"version": "14.0.1",
|
||||
"resolved": "https://registry.npmjs.org/react-ace/-/react-ace-14.0.1.tgz",
|
||||
@@ -43420,22 +43429,13 @@
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"d3": "^3.5.17",
|
||||
"prop-types": "^15.8.1",
|
||||
"react": "^19.2.7"
|
||||
"prop-types": "^15.8.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@apache-superset/core": "*",
|
||||
"@superset-ui/chart-controls": "*",
|
||||
"@superset-ui/core": "*"
|
||||
}
|
||||
},
|
||||
"plugins/plugin-chart-chord/node_modules/react": {
|
||||
"version": "19.2.8",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
|
||||
"integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
"@superset-ui/core": "*",
|
||||
"react": "^18.3.0"
|
||||
}
|
||||
},
|
||||
"plugins/plugin-chart-country-map": {
|
||||
|
||||
@@ -176,7 +176,7 @@
|
||||
"geostyler-openlayers-parser": "^5.7.1",
|
||||
"geostyler-style": "11.0.2",
|
||||
"geostyler-wfs-parser": "^3.0.1",
|
||||
"google-auth-library": "^11.0.1",
|
||||
"google-auth-library": "^11.0.2",
|
||||
"immer": "^11.1.16",
|
||||
"interweave": "^13.1.1",
|
||||
"jquery": "^4.0.0",
|
||||
@@ -257,13 +257,13 @@
|
||||
"@istanbuljs/nyc-config-typescript": "^1.0.1",
|
||||
"@playwright/test": "^1.62.1",
|
||||
"@pmmmwh/react-refresh-webpack-plugin": "^0.6.2",
|
||||
"@storybook/addon-docs": "10.5.7",
|
||||
"@storybook/addon-links": "10.5.7",
|
||||
"@storybook/react-webpack5": "10.5.7",
|
||||
"@storybook/addon-docs": "10.5.8",
|
||||
"@storybook/addon-links": "10.5.8",
|
||||
"@storybook/react-webpack5": "10.5.8",
|
||||
"@storybook/test-runner": "0.24.4",
|
||||
"@svgr/webpack": "^8.1.0",
|
||||
"@swc/core": "^1.15.47",
|
||||
"@swc/plugin-emotion": "^14.15.0",
|
||||
"@swc/plugin-emotion": "^14.19.0",
|
||||
"@swc/plugin-transform-imports": "^12.5.0",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^7.0.1",
|
||||
@@ -295,7 +295,7 @@
|
||||
"babel-loader": "^10.1.1",
|
||||
"babel-plugin-dynamic-import-node": "^2.3.3",
|
||||
"babel-plugin-jsx-remove-data-test-id": "^3.0.0",
|
||||
"baseline-browser-mapping": "^2.11.13",
|
||||
"baseline-browser-mapping": "^2.11.14",
|
||||
"cheerio": "1.2.0",
|
||||
"concurrently": "^10.0.4",
|
||||
"copy-webpack-plugin": "^14.0.0",
|
||||
@@ -312,7 +312,7 @@
|
||||
"eslint-plugin-no-only-tests": "^3.4.0",
|
||||
"eslint-plugin-react-prefer-function-component": "^5.0.0",
|
||||
"eslint-plugin-react-you-might-not-need-an-effect": "^1.0.1",
|
||||
"eslint-plugin-storybook": "10.5.7",
|
||||
"eslint-plugin-storybook": "10.5.8",
|
||||
"eslint-plugin-testing-library": "^7.16.2",
|
||||
"eslint-plugin-theme-colors": "file:eslint-rules/eslint-plugin-theme-colors",
|
||||
"fetch-mock": "^12.6.0",
|
||||
@@ -343,7 +343,7 @@
|
||||
"source-map": "^0.8.0",
|
||||
"source-map-support": "^0.5.21",
|
||||
"speed-measure-webpack-plugin": "^1.6.0",
|
||||
"storybook": "10.5.7",
|
||||
"storybook": "10.5.8",
|
||||
"style-loader": "^4.0.0",
|
||||
"stylelint": "^17.14.1",
|
||||
"swc-loader": "^0.2.7",
|
||||
|
||||
@@ -130,6 +130,7 @@ export enum GenericDataType {
|
||||
String = 1,
|
||||
Temporal = 2,
|
||||
Boolean = 3,
|
||||
MultiValue = 4,
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+5
@@ -28,6 +28,7 @@ import {
|
||||
FieldBinaryOutlined,
|
||||
FieldStringOutlined,
|
||||
NumberOutlined,
|
||||
UnorderedListOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { Icons } from '@superset-ui/core/components';
|
||||
|
||||
@@ -72,6 +73,10 @@ export function ColumnTypeLabel({ type }: ColumnTypeLabelProps) {
|
||||
typeIcon = <FieldBinaryOutlined aria-label={t('boolean type icon')} />;
|
||||
} else if (type === GenericDataType.Temporal) {
|
||||
typeIcon = <ClockCircleOutlined aria-label={t('temporal type icon')} />;
|
||||
} else if (type === GenericDataType.MultiValue) {
|
||||
typeIcon = (
|
||||
<UnorderedListOutlined aria-label={t('multi-value type icon')} />
|
||||
);
|
||||
}
|
||||
|
||||
return <TypeIconWrapper>{typeIcon}</TypeIconWrapper>;
|
||||
|
||||
+17
@@ -64,4 +64,21 @@ describe('ColumnOption', () => {
|
||||
renderColumnTypeLabel({ type: GenericDataType.Temporal });
|
||||
expect(screen.getByLabelText('temporal type icon')).toBeVisible();
|
||||
});
|
||||
test('multi-value (array) type shows list icon', () => {
|
||||
renderColumnTypeLabel({ type: GenericDataType.MultiValue });
|
||||
expect(screen.getByLabelText('multi-value type icon')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GenericDataType enum parity', () => {
|
||||
// These numeric values are shared with the backend enum in
|
||||
// superset/utils/core.py (GenericDataType). They must stay in sync because
|
||||
// the backend serializes columns using these integers.
|
||||
test('values match the backend contract', () => {
|
||||
expect(GenericDataType.Numeric).toBe(0);
|
||||
expect(GenericDataType.String).toBe(1);
|
||||
expect(GenericDataType.Temporal).toBe(2);
|
||||
expect(GenericDataType.Boolean).toBe(3);
|
||||
expect(GenericDataType.MultiValue).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,8 +25,11 @@ export type Aggregate =
|
||||
| 'COUNT'
|
||||
| 'COUNT_DISTINCT'
|
||||
| 'MAX'
|
||||
| 'MEDIAN'
|
||||
| 'MIN'
|
||||
| 'SUM';
|
||||
| 'STDDEV_SAMP'
|
||||
| 'SUM'
|
||||
| 'VAR_SAMP';
|
||||
|
||||
export interface AdhocMetricBase {
|
||||
hasCustomLabel?: boolean;
|
||||
|
||||
@@ -47,6 +47,10 @@ export default defineConfig({
|
||||
// Retry logic - 2 retries in CI, 0 locally
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
|
||||
// Disable capturing Git commit info as the project's history is increasingly dense
|
||||
// and breach Playwright's default 3-seconds `git` command timeout limit
|
||||
captureGitInfo: { commit: false, diff: false },
|
||||
|
||||
// Reporter configuration - multiple reporters for better visibility
|
||||
reporter: process.env.CI
|
||||
? [
|
||||
|
||||
@@ -22,7 +22,7 @@ import { Modal } from '../core/Modal';
|
||||
|
||||
/**
|
||||
* Confirm Dialog component for Ant Design Modal.confirm dialogs.
|
||||
* These are the "OK" / "Cancel" confirmation dialogs used throughout Superset.
|
||||
* These are the "Confirm" / "Cancel" confirmation dialogs used throughout Superset.
|
||||
* Uses getByRole with name to target specific confirm dialogs when multiple are open.
|
||||
*/
|
||||
export class ConfirmDialog extends Modal {
|
||||
@@ -43,7 +43,7 @@ export class ConfirmDialog extends Modal {
|
||||
}
|
||||
|
||||
/**
|
||||
* Clicks the OK button to confirm.
|
||||
* Clicks the Confirm button to confirm.
|
||||
* @param options.timeout - If provided, silently returns if dialog doesn't appear
|
||||
* within timeout. If not provided, waits indefinitely (strict mode).
|
||||
*/
|
||||
@@ -53,7 +53,7 @@ export class ConfirmDialog extends Modal {
|
||||
state: 'visible',
|
||||
timeout: options?.timeout,
|
||||
});
|
||||
await this.clickFooterButton('OK');
|
||||
await this.clickFooterButton('Confirm');
|
||||
await this.waitForHidden();
|
||||
} catch (error) {
|
||||
// Only swallow TimeoutError when timeout was explicitly provided
|
||||
|
||||
@@ -1,58 +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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* With SOFT_DELETE enabled the delete-confirmation modal becomes recoverable:
|
||||
* it explains the object is moved to the archive (and for how long), and drops
|
||||
* the "type DELETE to confirm" friction. Non-destructive — the modal is opened
|
||||
* and dismissed without deleting anything.
|
||||
*/
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { skipUnlessFeatureEnabled } from '../../helpers/featureFlags';
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await skipUnlessFeatureEnabled(page, 'SOFT_DELETE');
|
||||
});
|
||||
|
||||
test('chart delete confirmation reflects soft-delete (archive) semantics', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('chart/list/');
|
||||
await page.locator('[data-test="chart-row-delete"]').first().waitFor();
|
||||
await page.locator('[data-test="chart-row-delete"]').first().click();
|
||||
|
||||
// The action reads as "Archive", not "Delete". Scope to the dialog: with
|
||||
// the flag on, every list row's delete action is also named "Archive", so
|
||||
// an unscoped button query is a strict-mode violation (25 rows + modal).
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog.getByText(/^Archive .+\?$/)).toBeVisible();
|
||||
await expect(dialog.getByRole('button', { name: 'Archive' })).toBeVisible();
|
||||
|
||||
// Recoverable copy instead of "Are you sure … permanently".
|
||||
await expect(page.getByText(/moved to Recently Archived/i)).toBeVisible();
|
||||
await expect(
|
||||
page.getByText(/recover it there within \d+ days/i),
|
||||
).toBeVisible();
|
||||
|
||||
// No "type DELETE to confirm" input in recoverable mode.
|
||||
await expect(page.getByTestId('delete-modal-input')).toHaveCount(0);
|
||||
|
||||
// Dismiss without deleting.
|
||||
await page.getByTestId('close-modal-btn').click();
|
||||
});
|
||||
@@ -29,7 +29,7 @@
|
||||
* restore it and asserts — via the API — that it is live again.
|
||||
*/
|
||||
import { test, expect, Page } from '@playwright/test';
|
||||
import { apiGet, apiPost } from '../../helpers/api/requests';
|
||||
import { apiGet } from '../../helpers/api/requests';
|
||||
import { extractIdFromResponse } from '../../helpers/api/assertions';
|
||||
import {
|
||||
apiPostChart,
|
||||
@@ -188,58 +188,3 @@ test('permanently deletes an archived item from the view', async ({ page }) => {
|
||||
await TYPES[0].softDelete(page, id).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
test('shows an empty message and no rows when the search matches nothing', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('archived/');
|
||||
await expect(page.getByTestId('archived-list-view')).toBeVisible();
|
||||
|
||||
const search = page.getByPlaceholder(/type a value/i);
|
||||
await search.click();
|
||||
await search.fill(`e2e_nonexistent_${Date.now()}`);
|
||||
await search.press('Enter');
|
||||
|
||||
await expect(
|
||||
page.getByText('No results match your filter criteria'),
|
||||
).toBeVisible();
|
||||
await expect(page.getByTestId('archived-row-restore')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('restoring an already-restored row surfaces an error without crashing', async ({
|
||||
page,
|
||||
}) => {
|
||||
const name = `e2e_stale_${Date.now()}`;
|
||||
const id = await TYPES[0].create(page, name);
|
||||
// Capture the uuid before soft-delete (a soft-deleted GET returns 404).
|
||||
const { uuid } = (await (await apiGetDashboard(page, id)).json()).result;
|
||||
try {
|
||||
expect((await apiDeleteDashboard(page, id)).ok()).toBeTruthy();
|
||||
|
||||
await openArchive(page, 'Dashboard', name);
|
||||
await expect(page.getByText(name, { exact: false })).toBeVisible();
|
||||
|
||||
// Simulate another actor restoring the object out from under this view.
|
||||
const restored = await apiPost(
|
||||
page,
|
||||
`api/v1/dashboard/${uuid}/restore`,
|
||||
{},
|
||||
);
|
||||
expect(restored.ok()).toBeTruthy();
|
||||
|
||||
// Clicking the now-stale row's Restore yields a 404 → danger toast, no crash.
|
||||
await page
|
||||
.getByRole('row')
|
||||
.filter({ hasText: name })
|
||||
.getByTestId('archived-row-restore')
|
||||
.click();
|
||||
await expect(
|
||||
page.getByText(`Failed to restore ${name}`, { exact: false }),
|
||||
).toBeVisible({ timeout: 15000 });
|
||||
// The page is still functional (the list view did not crash).
|
||||
await expect(page.getByTestId('archived-list-view')).toBeVisible();
|
||||
} finally {
|
||||
// Re-archive the (possibly) restored dashboard, whatever happened above.
|
||||
await apiDeleteDashboard(page, id).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -30,12 +30,12 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"d3": "^3.5.17",
|
||||
"prop-types": "^15.8.1",
|
||||
"react": "^19.2.7"
|
||||
"prop-types": "^15.8.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@apache-superset/core": "*",
|
||||
"@superset-ui/chart-controls": "*",
|
||||
"@superset-ui/core": "*"
|
||||
"@superset-ui/core": "*",
|
||||
"react": "^18.3.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,10 @@
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { getNumberFormatter } from '@superset-ui/core';
|
||||
import { render, fireEvent } from '../../../../spec/helpers/testing-library';
|
||||
import BigNumberVis from './BigNumberViz';
|
||||
|
||||
/**
|
||||
* Tests for the color threshold formatter logic in BigNumberViz.
|
||||
*
|
||||
@@ -83,3 +87,33 @@ describe('BigNumberViz color formatters', () => {
|
||||
expect(getColorFromValue).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('BigNumberViz context menu', () => {
|
||||
test('invokes onContextMenu and stops the event bubbling to ancestor handlers', () => {
|
||||
const onContextMenu = jest.fn();
|
||||
const ancestorHandler = jest.fn();
|
||||
|
||||
const { container } = render(
|
||||
<div onContextMenu={ancestorHandler}>
|
||||
<BigNumberVis
|
||||
width={200}
|
||||
height={100}
|
||||
bigNumber={42}
|
||||
headerFormatter={getNumberFormatter()}
|
||||
headerFontSize={0.3}
|
||||
subheaderFontSize={0.125}
|
||||
subtitleFontSize={0.125}
|
||||
subtitle=""
|
||||
refs={{}}
|
||||
onContextMenu={onContextMenu}
|
||||
/>
|
||||
</div>,
|
||||
);
|
||||
|
||||
const headerLine = container.querySelector('.header-line');
|
||||
fireEvent.contextMenu(headerLine!, { clientX: 10, clientY: 20 });
|
||||
|
||||
expect(onContextMenu).toHaveBeenCalledWith(10, 20);
|
||||
expect(ancestorHandler).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -224,6 +224,7 @@ function BigNumberVis({
|
||||
const handleContextMenu = (e: MouseEvent<HTMLDivElement>) => {
|
||||
if (onContextMenu) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onContextMenu(e.nativeEvent.clientX, e.nativeEvent.clientY);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -390,6 +390,7 @@ export default function transformProps(chartProps: EchartsGanttChartProps) {
|
||||
[GenericDataType.String]: undefined,
|
||||
[GenericDataType.Temporal]: tooltipTimeFormatter,
|
||||
[GenericDataType.Boolean]: undefined,
|
||||
[GenericDataType.MultiValue]: undefined,
|
||||
};
|
||||
|
||||
const echartOptions: EChartsCoreOption = {
|
||||
|
||||
+9
-6
@@ -80,6 +80,7 @@ import {
|
||||
getAnnotationData,
|
||||
} from '../utils/annotation';
|
||||
import {
|
||||
collapseForecastKeys,
|
||||
extractForecastSeriesContext,
|
||||
extractForecastValuesFromTooltipParams,
|
||||
formatForecastTooltipSeries,
|
||||
@@ -861,12 +862,14 @@ export default function transformProps(
|
||||
: params.value[0];
|
||||
const forecastValue: any[] = richTooltip ? params : [params];
|
||||
|
||||
const sortedKeys = extractTooltipKeys(
|
||||
forecastValue,
|
||||
// horizontal mode is not supported in mixed series chart
|
||||
1,
|
||||
richTooltip,
|
||||
tooltipSortByMetric,
|
||||
const sortedKeys = collapseForecastKeys(
|
||||
extractTooltipKeys(
|
||||
forecastValue,
|
||||
// horizontal mode is not supported in mixed series chart
|
||||
1,
|
||||
richTooltip,
|
||||
tooltipSortByMetric,
|
||||
),
|
||||
);
|
||||
|
||||
const rows: string[][] = [];
|
||||
|
||||
@@ -95,6 +95,7 @@ import {
|
||||
getAnnotationData,
|
||||
} from '../utils/annotation';
|
||||
import {
|
||||
collapseForecastKeys,
|
||||
extractForecastSeriesContext,
|
||||
extractForecastSeriesContexts,
|
||||
extractForecastValuesFromTooltipParams,
|
||||
@@ -1392,11 +1393,13 @@ export default function transformProps(
|
||||
const forecastValue: CallbackDataParams[] = richTooltip
|
||||
? params
|
||||
: [params];
|
||||
const sortedKeys = extractTooltipKeys(
|
||||
forecastValue,
|
||||
yIndex,
|
||||
richTooltip,
|
||||
tooltipSortByMetric,
|
||||
const sortedKeys = collapseForecastKeys(
|
||||
extractTooltipKeys(
|
||||
forecastValue,
|
||||
yIndex,
|
||||
richTooltip,
|
||||
tooltipSortByMetric,
|
||||
),
|
||||
);
|
||||
const filteredForecastValue = forecastValue.filter(
|
||||
(item: CallbackDataParams) =>
|
||||
|
||||
@@ -467,6 +467,14 @@ export function transformSeries(
|
||||
return formatter(numericValue);
|
||||
}
|
||||
if (!onlyTotal) {
|
||||
// A stacked segment with no height begins and ends at the same
|
||||
// coordinate as the top of the segment beneath it, so its label is
|
||||
// drawn over that segment's label. Zero and null have no height, so
|
||||
// they carry no label. The rich tooltip omits zero observations from
|
||||
// a stacked series for the same reason.
|
||||
if (stack && !numericValue) {
|
||||
return '';
|
||||
}
|
||||
if (
|
||||
numericValue >=
|
||||
(thresholdValues[dataIndex] || Number.MIN_SAFE_INTEGER)
|
||||
|
||||
@@ -60,6 +60,21 @@ export const extractForecastSeriesContexts = (
|
||||
{} as { [key: string]: ForecastSeriesEnum[] },
|
||||
);
|
||||
|
||||
/**
|
||||
* Collapses raw ECharts series ids onto the names used to key tooltip rows.
|
||||
*
|
||||
* Tooltip values are grouped by forecast-stripped name, so any ordering derived
|
||||
* from the raw series ids has to be expressed in the same terms before it can be
|
||||
* matched against them. This matters beyond real Prophet output: a metric simply
|
||||
* labelled `ci__yhat_lower` collapses to `ci` exactly like a forecast bound
|
||||
* does, and a chart whose every series carries such a suffix has no id that
|
||||
* survives the comparison untouched.
|
||||
*/
|
||||
export const collapseForecastKeys = (seriesIds: string[]): string[] =>
|
||||
Array.from(
|
||||
new Set(seriesIds.map(id => extractForecastSeriesContext(id).name)),
|
||||
);
|
||||
|
||||
export const extractForecastValuesFromTooltipParams = (
|
||||
params: any[],
|
||||
isHorizontal = false,
|
||||
|
||||
@@ -2529,3 +2529,64 @@ describe('EchartsTimeseries tooltip truncation', () => {
|
||||
expect(buildTooltip(undefined, longCategory)).toContain(longCategory);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tooltip for metrics whose labels end in forecast suffixes', () => {
|
||||
const marker = '<span style="background-color:#1f77b4;"></span>';
|
||||
const seriesIds = ['ci__yhat', 'ci__yhat_lower', 'ci__yhat_upper'];
|
||||
const values = [1.5, 0.5, 2.0];
|
||||
|
||||
// Metrics can be labelled `ci__yhat*` with no forecast enabled and no plain
|
||||
// observation series. Every series then collapses onto the same
|
||||
// forecast-stripped tooltip key, so no raw series id matches itself.
|
||||
const buildTooltip = (tooltipSortByMetric = false) => {
|
||||
const chartProps = createTestChartProps({
|
||||
formData: {
|
||||
x_axis: 'dt',
|
||||
metrics: seriesIds,
|
||||
groupby: [],
|
||||
richTooltip: true,
|
||||
tooltipSortByMetric,
|
||||
} as Partial<EchartsTimeseriesFormData>,
|
||||
queriesData: [
|
||||
createTestQueryData([
|
||||
{
|
||||
dt: 599616000000,
|
||||
ci__yhat: 1.5,
|
||||
ci__yhat_lower: 0.5,
|
||||
ci__yhat_upper: 2.5,
|
||||
},
|
||||
]),
|
||||
],
|
||||
});
|
||||
const tooltipFormatter = (transformProps(chartProps).echartOptions as any)
|
||||
.tooltip.formatter;
|
||||
return tooltipFormatter(
|
||||
seriesIds.map((id, i) => ({
|
||||
seriesId: id,
|
||||
seriesName: id,
|
||||
value: [599616000000, values[i]],
|
||||
data: [599616000000, values[i]],
|
||||
marker,
|
||||
})),
|
||||
);
|
||||
};
|
||||
|
||||
test('renders the collapsed series rather than falling back to "No data"', () => {
|
||||
const html = buildTooltip();
|
||||
expect(html).not.toContain('No data');
|
||||
expect(html).toContain('>ci<');
|
||||
expect(html).toContain('ŷ = 1.5 (0.5, 2.5)');
|
||||
});
|
||||
|
||||
test('renders a single row rather than one per forecast suffix', () => {
|
||||
const html = buildTooltip();
|
||||
expect(html.match(/<tr/g)).toHaveLength(1);
|
||||
expect(html).toContain('>ci<');
|
||||
});
|
||||
|
||||
test('still renders the row when the tooltip is sorted by metric', () => {
|
||||
const html = buildTooltip(true);
|
||||
expect(html).not.toContain('No data');
|
||||
expect(html).toContain('>ci<');
|
||||
});
|
||||
});
|
||||
|
||||
+69
-1
@@ -20,13 +20,14 @@ import {
|
||||
CategoricalColorScale,
|
||||
ChartProps,
|
||||
TimeGranularity,
|
||||
getNumberFormatter,
|
||||
} from '@superset-ui/core';
|
||||
import { GenericDataType } from '@apache-superset/core/common';
|
||||
import { supersetTheme } from '@apache-superset/core/theme';
|
||||
import type { SeriesOption } from 'echarts';
|
||||
import type { ScatterSeriesOption } from 'echarts/charts';
|
||||
import { EchartsTimeseriesSeriesType } from '../../src';
|
||||
import { TIMESERIES_CONSTANTS } from '../../src/constants';
|
||||
import { StackControlsValue, TIMESERIES_CONSTANTS } from '../../src/constants';
|
||||
import {
|
||||
LegendOrientation,
|
||||
EchartsTimeseriesChartProps,
|
||||
@@ -566,3 +567,70 @@ test('getPadding should handle Left position with zero margin correctly', () =>
|
||||
getChartPaddingSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* #42702: a stacked segment with no height starts and ends at the same
|
||||
* coordinate as the top of the segment beneath it, so a value label on it is
|
||||
* drawn over that segment's label. `percentage_threshold` does not filter these
|
||||
* out: it defaults to 0, and `thresholdValues[dataIndex] || MIN_SAFE_INTEGER`
|
||||
* turns a 0 threshold into "no filtering", which is intentional.
|
||||
*/
|
||||
const stackedLabel = (
|
||||
numericValue: number | null,
|
||||
opts: Record<string, unknown> = {},
|
||||
) => {
|
||||
const series = transformSeries(
|
||||
{ id: 'B', name: 'B', data: [[1, numericValue]] } as SeriesOption,
|
||||
mockColorScale,
|
||||
'B',
|
||||
{
|
||||
seriesType: EchartsTimeseriesSeriesType.Bar,
|
||||
stack: StackControlsValue.Stack,
|
||||
showValue: true,
|
||||
onlyTotal: false,
|
||||
formatter: getNumberFormatter(),
|
||||
thresholdValues: [0],
|
||||
...opts,
|
||||
},
|
||||
) as SeriesOption & {
|
||||
label: { formatter: (params: unknown) => string };
|
||||
};
|
||||
return series.label.formatter({
|
||||
value: [1, numericValue],
|
||||
dataIndex: 0,
|
||||
seriesIndex: 1,
|
||||
seriesName: 'B',
|
||||
});
|
||||
};
|
||||
|
||||
test('stacked value labels are omitted for a zero-height segment', () => {
|
||||
expect(stackedLabel(0)).toBe('');
|
||||
expect(stackedLabel(null)).toBe('');
|
||||
});
|
||||
|
||||
test('stacked value labels are kept for segments that have height', () => {
|
||||
expect(stackedLabel(32)).toBe('32');
|
||||
expect(stackedLabel(-5)).toBe('-5');
|
||||
});
|
||||
|
||||
test('a zero value keeps its label when the series is not stacked', () => {
|
||||
// Without a stack the label sits on the bar itself, so there is nothing for
|
||||
// it to collide with.
|
||||
expect(stackedLabel(0, { stack: undefined })).toBe('0');
|
||||
});
|
||||
|
||||
test('percentage_threshold still filters values below the threshold', () => {
|
||||
// 10% of a 100 total. The zero-height guard must not swallow this rule.
|
||||
expect(stackedLabel(5, { thresholdValues: [10] })).toBe('');
|
||||
expect(stackedLabel(50, { thresholdValues: [10] })).toBe('50');
|
||||
});
|
||||
|
||||
test('only-total labels are unaffected by the zero-height guard', () => {
|
||||
expect(
|
||||
stackedLabel(0, {
|
||||
onlyTotal: true,
|
||||
showValueIndexes: [1],
|
||||
totalStackedValues: [32],
|
||||
}),
|
||||
).toBe('32');
|
||||
});
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
} from '@superset-ui/core';
|
||||
import { SeriesOption } from 'echarts';
|
||||
import {
|
||||
collapseForecastKeys,
|
||||
extractForecastSeriesContext,
|
||||
extractForecastValuesFromTooltipParams,
|
||||
formatForecastTooltipSeries,
|
||||
@@ -464,3 +465,35 @@ describe('formatForecastTooltipSeries truncation', () => {
|
||||
expect(cell).toBe(`${marker}cpu`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('collapseForecastKeys', () => {
|
||||
test('leaves plain observation series untouched and in order', () => {
|
||||
expect(collapseForecastKeys(['foo', 'bar'])).toEqual(['foo', 'bar']);
|
||||
});
|
||||
|
||||
test('folds a forecast bundle down to a single key', () => {
|
||||
expect(
|
||||
collapseForecastKeys([
|
||||
'foo',
|
||||
'foo__yhat',
|
||||
'foo__yhat_lower',
|
||||
'foo__yhat_upper',
|
||||
]),
|
||||
).toEqual(['foo']);
|
||||
});
|
||||
|
||||
test('keeps a key for metrics whose labels are entirely forecast suffixes', () => {
|
||||
// Charts can carry metrics literally labelled `ci__yhat*` with no plain
|
||||
// observation series. Callers match these against forecast-stripped keys,
|
||||
// so an uncollapsed id here would match nothing and drop every row.
|
||||
expect(
|
||||
collapseForecastKeys(['ci__yhat', 'ci__yhat_lower', 'ci__yhat_upper']),
|
||||
).toEqual(['ci']);
|
||||
});
|
||||
|
||||
test('preserves the incoming order of distinct series', () => {
|
||||
expect(
|
||||
collapseForecastKeys(['b__yhat_lower', 'a__yhat', 'b__yhat']),
|
||||
).toEqual(['b', 'a']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -290,6 +290,25 @@ test('isAdditiveMetric: non-additive aggregates, SQL, and saved metrics are not
|
||||
expect(isAdditiveMetric('count')).toBe(false);
|
||||
});
|
||||
|
||||
test('isAdditiveMetric: MEDIAN/STDDEV_SAMP/VAR_SAMP are non-additive, with no dedicated code needed', () => {
|
||||
// Regression guard: MEDIAN/STDDEV_SAMP/VAR_SAMP are new system-wide metric
|
||||
// aggregates (not pivot-table-specific). They must fall outside
|
||||
// ADDITIVE_AGGREGATES so totals/subtotals route through the correct
|
||||
// DB-rollup path automatically, same as AVG/COUNT_DISTINCT already do --
|
||||
// averaging per-group medians (or variances) is exactly the class of bug
|
||||
// SIP-216 fixed for AVG, and would be equally wrong here.
|
||||
(['MEDIAN', 'STDDEV_SAMP', 'VAR_SAMP'] as const).forEach(aggregate => {
|
||||
expect(
|
||||
isAdditiveMetric({
|
||||
expressionType: 'SIMPLE',
|
||||
aggregate,
|
||||
column: { column_name: 'num' },
|
||||
label: `${aggregate.toLowerCase()}_num`,
|
||||
} as QueryFormMetric),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
test('allMetricsAdditive: all additive vs any non-additive vs empty', () => {
|
||||
const sum = {
|
||||
expressionType: 'SIMPLE',
|
||||
|
||||
@@ -390,7 +390,7 @@ const ResultSet = ({
|
||||
// provides.
|
||||
redirect(getExportCsvUrl(query.id));
|
||||
},
|
||||
confirmText: t('OK'),
|
||||
confirmText: t('Confirm'),
|
||||
cancelText: t('Close'),
|
||||
});
|
||||
}
|
||||
|
||||
+25
-1
@@ -16,7 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { render, screen } from 'spec/helpers/testing-library';
|
||||
import { render, screen, userEvent } from 'spec/helpers/testing-library';
|
||||
import SaveDatasetActionButton from 'src/SqlLab/components/SaveDatasetActionButton';
|
||||
|
||||
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
|
||||
@@ -27,6 +27,7 @@ describe('SaveDatasetActionButton', () => {
|
||||
<SaveDatasetActionButton
|
||||
setShowSave={() => true}
|
||||
onSaveAsExplore={onSaveAsExplore}
|
||||
canSaveDataset
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -41,4 +42,27 @@ describe('SaveDatasetActionButton', () => {
|
||||
expect(saveBtn).toBeVisible();
|
||||
expect(saveDatasetBtn).toBeVisible();
|
||||
});
|
||||
|
||||
test('disables the save dataset button when the query did not run successfully', async () => {
|
||||
render(
|
||||
<SaveDatasetActionButton
|
||||
setShowSave={() => true}
|
||||
onSaveAsExplore={jest.fn()}
|
||||
canSaveDataset={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
const saveDatasetBtn = screen.getByRole('button', {
|
||||
name: /save dataset/i,
|
||||
});
|
||||
expect(saveDatasetBtn).toBeDisabled();
|
||||
|
||||
// the disabled button is wrapped in a span so the tooltip still triggers
|
||||
userEvent.hover(saveDatasetBtn.parentElement as HTMLElement);
|
||||
expect(
|
||||
await screen.findByRole('tooltip', {
|
||||
name: 'You must run the query successfully first',
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,11 +23,13 @@ import { Button } from '@superset-ui/core/components';
|
||||
interface SaveDatasetActionButtonProps {
|
||||
setShowSave: (arg0: boolean) => void;
|
||||
onSaveAsExplore?: () => void;
|
||||
canSaveDataset: boolean;
|
||||
}
|
||||
|
||||
const SaveDatasetActionButton = ({
|
||||
setShowSave,
|
||||
onSaveAsExplore,
|
||||
canSaveDataset,
|
||||
}: SaveDatasetActionButtonProps) => (
|
||||
<>
|
||||
<Button
|
||||
@@ -43,8 +45,13 @@ const SaveDatasetActionButton = ({
|
||||
color="default"
|
||||
variant="text"
|
||||
onClick={() => onSaveAsExplore?.()}
|
||||
disabled={!canSaveDataset}
|
||||
icon={<Icons.TableOutlined />}
|
||||
tooltip={t('Save or Overwrite Dataset')}
|
||||
tooltip={
|
||||
canSaveDataset
|
||||
? t('Save or Overwrite Dataset')
|
||||
: t('You must run the query successfully first')
|
||||
}
|
||||
aria-label={t('Save dataset')}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -35,6 +35,7 @@ const mockedProps = {
|
||||
onSave: () => {},
|
||||
saveQueryWarning: null,
|
||||
columns: [],
|
||||
canSaveDataset: true,
|
||||
};
|
||||
|
||||
const mockState = {
|
||||
|
||||
@@ -52,6 +52,7 @@ interface SaveQueryProps {
|
||||
onUpdate: (arg0: QueryPayload, id: string) => void;
|
||||
saveQueryWarning: string | null;
|
||||
database: Partial<DatabaseObject> | undefined;
|
||||
canSaveDataset: boolean;
|
||||
}
|
||||
|
||||
export type QueryPayload = {
|
||||
@@ -81,6 +82,7 @@ const SaveQuery = ({
|
||||
saveQueryWarning,
|
||||
database,
|
||||
columns,
|
||||
canSaveDataset,
|
||||
}: SaveQueryProps) => {
|
||||
const queryEditor = useQueryEditor(queryEditorId, [
|
||||
'autorun',
|
||||
@@ -207,6 +209,7 @@ const SaveQuery = ({
|
||||
<SaveDatasetActionButton
|
||||
setShowSave={setShowSave}
|
||||
onSaveAsExplore={canExploreDatabase ? onSaveAsExplore : undefined}
|
||||
canSaveDataset={canSaveDataset}
|
||||
/>
|
||||
)}
|
||||
<SaveDatasetModal
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
isFeatureEnabled,
|
||||
getExtensionsRegistry,
|
||||
FeatureFlag,
|
||||
QueryState,
|
||||
} from '@superset-ui/core';
|
||||
import {
|
||||
act,
|
||||
@@ -334,6 +335,47 @@ describe('SqlEditor', () => {
|
||||
expect(await findByText('10 000')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const setupWithLatestQuery = (overrides: Partial<typeof latestQuery>) =>
|
||||
setup(
|
||||
mockedProps,
|
||||
createStore({
|
||||
...mockInitialState,
|
||||
sqlLab: {
|
||||
...mockInitialState.sqlLab,
|
||||
queries: {
|
||||
[latestQuery.id]: { ...latestQuery, ...overrides },
|
||||
},
|
||||
databases: {
|
||||
1991: {
|
||||
...mockInitialState.sqlLab.databases[1991],
|
||||
allows_virtual_table_explore: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
test('enables the save dataset button when the latest query succeeded', async () => {
|
||||
const { findByRole } = setupWithLatestQuery({ state: QueryState.Success });
|
||||
expect(await findByRole('button', { name: 'Save dataset' })).toBeEnabled();
|
||||
});
|
||||
|
||||
test('disables the save dataset button when the latest query failed', async () => {
|
||||
const { findByRole } = setupWithLatestQuery({
|
||||
state: QueryState.Failed,
|
||||
results: undefined,
|
||||
});
|
||||
expect(await findByRole('button', { name: 'Save dataset' })).toBeDisabled();
|
||||
});
|
||||
|
||||
test('disables the save dataset button when the results are not loaded', async () => {
|
||||
const { findByRole } = setupWithLatestQuery({
|
||||
state: QueryState.Success,
|
||||
results: undefined,
|
||||
});
|
||||
expect(await findByRole('button', { name: 'Save dataset' })).toBeDisabled();
|
||||
});
|
||||
|
||||
test('renders an Extension if provided', async () => {
|
||||
const extensionsRegistry = getExtensionsRegistry();
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
getExtensionsRegistry,
|
||||
QueryResponse,
|
||||
Query,
|
||||
QueryState,
|
||||
} from '@superset-ui/core';
|
||||
import { Alert } from '@apache-superset/core/components';
|
||||
import { css, styled, useTheme } from '@apache-superset/core/theme';
|
||||
@@ -295,6 +296,9 @@ const SqlEditor: FC<Props> = ({
|
||||
|
||||
const SqlFormExtension = extensionsRegistry.get('sqleditor.extension.form');
|
||||
|
||||
const successful = latestQuery?.state === QueryState.Success;
|
||||
const resultColumns = latestQuery?.results?.columns || [];
|
||||
|
||||
const startQuery = useCallback(
|
||||
(
|
||||
ctasArg = false,
|
||||
@@ -712,7 +716,6 @@ const SqlEditor: FC<Props> = ({
|
||||
|
||||
const getSecondaryMenuItems = () => {
|
||||
const qe = queryEditor;
|
||||
const successful = latestQuery?.state === 'success';
|
||||
const scheduleToolTip = successful
|
||||
? t('Schedule the query periodically')
|
||||
: t('You must run the query successfully first');
|
||||
@@ -858,13 +861,14 @@ const SqlEditor: FC<Props> = ({
|
||||
)}
|
||||
<SaveQuery
|
||||
queryEditorId={queryEditor.id}
|
||||
columns={latestQuery?.results?.columns || []}
|
||||
columns={resultColumns}
|
||||
onSave={onSaveQuery}
|
||||
onUpdate={(query, remoteId) =>
|
||||
dispatch(updateSavedQuery(query, remoteId))
|
||||
}
|
||||
saveQueryWarning={saveQueryWarning}
|
||||
database={database}
|
||||
canSaveDataset={successful && resultColumns.length > 0}
|
||||
/>
|
||||
<ShareSqlLabQuery queryEditorId={queryEditor.id} />
|
||||
</>
|
||||
|
||||
@@ -223,6 +223,11 @@ export default function chartReducer(
|
||||
}
|
||||
|
||||
if (action.type in actionHandlers) {
|
||||
// ADD_CHART creates the entry, so it runs without prior state; every other
|
||||
// handler reads state that is absent once the chart has been removed
|
||||
if (action.type !== actions.ADD_CHART && !charts[action.key]) {
|
||||
return charts;
|
||||
}
|
||||
return {
|
||||
...charts,
|
||||
[action.key]: actionHandlers[action.type](charts[action.key]),
|
||||
|
||||
@@ -91,4 +91,20 @@ describe('chart reducers', () => {
|
||||
expect(newState[chartKey].chartUpdateEndTime).toBeGreaterThan(0);
|
||||
expect(newState[chartKey].chartStatus).toEqual('failed');
|
||||
});
|
||||
|
||||
test('ignores an action for a chart that is no longer in state', () => {
|
||||
const action = actions.chartUpdateStopped(999, new AbortController());
|
||||
expect(() => chartReducer(charts, action)).not.toThrow();
|
||||
expect(chartReducer(charts, action)).toEqual(charts);
|
||||
});
|
||||
|
||||
test('still adds a chart that is not yet in state', () => {
|
||||
const newChartKey = 2;
|
||||
const newState = chartReducer(
|
||||
charts,
|
||||
actions.addChart({ ...chart, id: newChartKey }, newChartKey),
|
||||
);
|
||||
expect(newState[newChartKey].id).toEqual(newChartKey);
|
||||
expect(newState[chartKey]).toEqual(testChart);
|
||||
});
|
||||
});
|
||||
|
||||
+4
-4
@@ -120,7 +120,7 @@ describe('DatasourceModal', () => {
|
||||
});
|
||||
const saveButton = screen.getByTestId('datasource-modal-save');
|
||||
fireEvent.click(saveButton);
|
||||
const okButton = await screen.findByRole('button', { name: 'OK' });
|
||||
const okButton = await screen.findByRole('button', { name: 'Confirm' });
|
||||
fireEvent.click(okButton);
|
||||
await waitFor(() => {
|
||||
expect(onDatasourceSave).toHaveBeenCalled();
|
||||
@@ -142,7 +142,7 @@ describe('DatasourceModal', () => {
|
||||
|
||||
const saveButton = screen.getByTestId('datasource-modal-save');
|
||||
fireEvent.click(saveButton);
|
||||
const okButton = await screen.findByRole('button', { name: 'OK' });
|
||||
const okButton = await screen.findByRole('button', { name: 'Confirm' });
|
||||
fireEvent.click(okButton);
|
||||
|
||||
const errorElements = await screen.findAllByText('Error saving dataset');
|
||||
@@ -230,7 +230,7 @@ describe('DatasourceModal', () => {
|
||||
expect(checkbox).toBeChecked();
|
||||
|
||||
// Click OK to submit
|
||||
const okButton = screen.getByRole('button', { name: 'OK' });
|
||||
const okButton = screen.getByRole('button', { name: 'Confirm' });
|
||||
fireEvent.click(okButton);
|
||||
|
||||
// Verify the PUT request was made with override_columns=true
|
||||
@@ -297,7 +297,7 @@ describe('DatasourceModal', () => {
|
||||
expect(checkbox).not.toBeChecked();
|
||||
|
||||
// Click OK to submit
|
||||
const okButton = screen.getByRole('button', { name: 'OK' });
|
||||
const okButton = screen.getByRole('button', { name: 'Confirm' });
|
||||
fireEvent.click(okButton);
|
||||
|
||||
// Verify the PUT request was made with override_columns=false
|
||||
|
||||
@@ -395,7 +395,7 @@ const DatasourceModal: FunctionComponent<DatasourceModalProps> = ({
|
||||
show={confirmModalOpen}
|
||||
onHide={handleConfirmModalClose}
|
||||
onHandledPrimaryAction={handleConfirmSave}
|
||||
primaryButtonName={t('OK')}
|
||||
primaryButtonName={t('Confirm')}
|
||||
primaryButtonLoading={isSaving}
|
||||
>
|
||||
{getSaveDialog()}
|
||||
|
||||
+1
-3
@@ -1627,9 +1627,7 @@ function DatasourceEditor({
|
||||
{t(
|
||||
'Default URL to redirect to when accessing from the dataset list page. Accepts relative URLs such as',
|
||||
)}{' '}
|
||||
<Typography.Text code>
|
||||
/superset/dashboard/{'{id}'}/
|
||||
</Typography.Text>
|
||||
<Typography.Text code>/dashboard/{'{id}'}/</Typography.Text>
|
||||
</>
|
||||
}
|
||||
control={<TextControl controlId="default_endpoint" />}
|
||||
|
||||
+11
@@ -71,6 +71,17 @@ test('renders Tabs', async () => {
|
||||
expect(screen.getByTestId('edit-dataset-tabs')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('recommends a registered client route for the default URL', async () => {
|
||||
await asyncRender(createProps());
|
||||
|
||||
userEvent.click(screen.getByRole('tab', { name: 'Settings' }));
|
||||
|
||||
expect(await screen.findByText('/dashboard/{id}/')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText('/superset/dashboard/{id}/'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('can sync columns from source', async () => {
|
||||
const testProps = createProps();
|
||||
await asyncRender({
|
||||
|
||||
@@ -468,7 +468,7 @@ function SliceAdder({
|
||||
<AutoSizer>
|
||||
{({ height, width }: { height: number; width: number }) => (
|
||||
<List
|
||||
style={{ width, height }}
|
||||
style={{ width, height, maxHeight: height }}
|
||||
rowCount={filteredSlices.length}
|
||||
rowHeight={DEFAULT_CELL_HEIGHT}
|
||||
rowProps={listRowProps}
|
||||
|
||||
@@ -105,10 +105,17 @@ export const SamplesPane = ({
|
||||
1,
|
||||
)
|
||||
.then(response => {
|
||||
setData(ensureIsArray(response.data));
|
||||
setColnames(ensureIsArray(response.colnames));
|
||||
setColtypes(ensureIsArray(response.coltypes));
|
||||
setRowCount(response.rowcount);
|
||||
// A 200 that carries no `result` payload resolves to undefined here.
|
||||
// Read through it so the pane falls back to its empty state instead
|
||||
// of throwing a TypeError that surfaces as an internal error message.
|
||||
const rows = ensureIsArray(response?.data);
|
||||
setData(rows);
|
||||
setColnames(ensureIsArray(response?.colnames));
|
||||
setColtypes(ensureIsArray(response?.coltypes));
|
||||
// Fall back to the rows actually returned rather than to zero: the
|
||||
// controls only render when there are rows, and a hardcoded 0 would
|
||||
// label a populated table as "0 rows".
|
||||
setRowCount(response?.rowcount ?? rows.length);
|
||||
setResponseError('');
|
||||
cache.set(queryFormData, true);
|
||||
if (queryForce) {
|
||||
|
||||
@@ -60,6 +60,27 @@ describe('SamplesPane', () => {
|
||||
400,
|
||||
);
|
||||
|
||||
// A 200 response that carries no `result` payload, as reported in #36840.
|
||||
fetchMock.post(
|
||||
'end:/datasource/samples?force=false&datasource_type=table&datasource_id=37&per_page=100&page=1',
|
||||
{},
|
||||
);
|
||||
|
||||
// A 200 whose result carries rows but omits `rowcount`.
|
||||
fetchMock.post(
|
||||
'end:/datasource/samples?force=false&datasource_type=table&datasource_id=38&per_page=100&page=1',
|
||||
{
|
||||
result: {
|
||||
data: [
|
||||
{ __timestamp: 1230768000000, genre: 'Action' },
|
||||
{ __timestamp: 1230768000010, genre: 'Horror' },
|
||||
],
|
||||
colnames: ['__timestamp', 'genre'],
|
||||
coltypes: [2, 1],
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const setForceQuery = jest.fn();
|
||||
|
||||
afterAll(() => {
|
||||
@@ -114,4 +135,29 @@ describe('SamplesPane', () => {
|
||||
expect(queryByText('Action')).toBeVisible();
|
||||
expect(queryByText('Horror')).toBeVisible();
|
||||
});
|
||||
|
||||
test('renders the empty state when the response carries no result payload', async () => {
|
||||
const props = createSamplesPaneProps({ datasourceId: 37 });
|
||||
const { findByText, queryByRole } = render(<SamplesPane {...props} />, {
|
||||
useRedux: true,
|
||||
});
|
||||
|
||||
expect(
|
||||
await findByText('No samples were returned for this dataset'),
|
||||
).toBeVisible();
|
||||
// The pane should not leak an internal TypeError through the error alert.
|
||||
expect(queryByRole('alert')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('counts the returned rows when the response omits rowcount', async () => {
|
||||
const props = createSamplesPaneProps({ datasourceId: 38 });
|
||||
const { findByText, queryByText } = render(<SamplesPane {...props} />, {
|
||||
useRedux: true,
|
||||
});
|
||||
|
||||
expect(await findByText('Action')).toBeVisible();
|
||||
// Falling back to 0 here would label a populated table as "0 rows".
|
||||
expect(queryByText('0 rows')).not.toBeInTheDocument();
|
||||
expect(queryByText('2 rows')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -148,7 +148,7 @@ export const DatasourceItems = ({
|
||||
|
||||
return (
|
||||
<List
|
||||
style={{ width: width - BORDER_WIDTH, height }}
|
||||
style={{ width: width - BORDER_WIDTH, height, maxHeight: height }}
|
||||
rowHeight={rowHeight}
|
||||
rowCount={flattenedItems.length}
|
||||
rowProps={rowProps}
|
||||
|
||||
+4
-4
@@ -150,7 +150,7 @@ const waitForRender = (props?: any) =>
|
||||
test('renders with default props', async () => {
|
||||
await waitForRender();
|
||||
expect(screen.getByRole('button', { name: 'Apply' })).toBeDisabled();
|
||||
expect(screen.getByRole('button', { name: 'OK' })).toBeDisabled();
|
||||
expect(screen.getByRole('button', { name: 'Confirm' })).toBeDisabled();
|
||||
expect(screen.getByRole('button', { name: 'Cancel' })).toBeEnabled();
|
||||
});
|
||||
|
||||
@@ -188,7 +188,7 @@ test('enables apply and ok buttons', async () => {
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: 'Apply' })).toBeEnabled();
|
||||
expect(screen.getByRole('button', { name: 'OK' })).toBeEnabled();
|
||||
expect(screen.getByRole('button', { name: 'Confirm' })).toBeEnabled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -203,7 +203,7 @@ test('triggers addAnnotationLayer and close when ok button is clicked', async ()
|
||||
const addAnnotationLayer = jest.fn();
|
||||
const close = jest.fn();
|
||||
await waitForRender({ name: 'Test', value: '2x', addAnnotationLayer, close });
|
||||
userEvent.click(screen.getByRole('button', { name: 'OK' }));
|
||||
userEvent.click(screen.getByRole('button', { name: 'Confirm' }));
|
||||
expect(addAnnotationLayer).toHaveBeenCalled();
|
||||
expect(close).toHaveBeenCalled();
|
||||
});
|
||||
@@ -724,7 +724,7 @@ test('Disable apply button if formula is incorrect', async () => {
|
||||
|
||||
const formulaInput = screen.getByRole('textbox', { name: 'Formula' });
|
||||
const applyButton = screen.getByRole('button', { name: 'Apply' });
|
||||
const okButton = screen.getByRole('button', { name: 'OK' });
|
||||
const okButton = screen.getByRole('button', { name: 'Confirm' });
|
||||
|
||||
userEvent.type(formulaInput, 'x+1');
|
||||
expect(formulaInput).toHaveValue('x+1');
|
||||
|
||||
+1
-1
@@ -1303,7 +1303,7 @@ function AnnotationLayer({
|
||||
disabled={!isValid}
|
||||
onClick={submitAnnotation}
|
||||
>
|
||||
{t('OK')}
|
||||
{t('Confirm')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -251,4 +251,11 @@ export const DEFAULT_CONFIG_FORM_LAYOUT: ColumnConfigFormLayout = {
|
||||
{ name: 'horizontalAlign', override: { defaultValue: 'left' } },
|
||||
],
|
||||
],
|
||||
[GenericDataType.MultiValue]: [
|
||||
[
|
||||
'columnWidth',
|
||||
{ name: 'horizontalAlign', override: { defaultValue: 'left' } },
|
||||
],
|
||||
['truncateLongCells'],
|
||||
],
|
||||
};
|
||||
|
||||
+3
-3
@@ -187,7 +187,7 @@ async function openAndSaveChanges(
|
||||
await userEvent.click(screen.getByTestId('datasource-menu-trigger'));
|
||||
await userEvent.click(await screen.findByTestId('edit-dataset'));
|
||||
await userEvent.click(await screen.findByTestId('datasource-modal-save'));
|
||||
await userEvent.click(await screen.findByText('OK'));
|
||||
await userEvent.click(await screen.findByText('Confirm'));
|
||||
}
|
||||
|
||||
test('Should render', async () => {
|
||||
@@ -714,10 +714,10 @@ test('should handle metric save confirmation modal', async () => {
|
||||
await userEvent.click(await screen.findByTestId('datasource-modal-save'));
|
||||
|
||||
// Verify confirmation modal appears
|
||||
expect(await screen.findByText('OK')).toBeInTheDocument();
|
||||
expect(await screen.findByText('Confirm')).toBeInTheDocument();
|
||||
|
||||
// Confirm save
|
||||
await userEvent.click(screen.getByText('OK'));
|
||||
await userEvent.click(screen.getByText('Confirm'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(props.onDatasourceSave).toHaveBeenCalled();
|
||||
|
||||
+68
@@ -270,6 +270,74 @@ describe('AdhocFilter', () => {
|
||||
});
|
||||
expect(adhocFilter.comparator).toBe(undefined);
|
||||
});
|
||||
// Charts saved before #32701 persisted `==` as the operation for IS_TRUE and
|
||||
// IS_FALSE, alongside a boolean comparator. `translateToSql` and the backend
|
||||
// both key off `operator`, so dropping the comparator would render such a
|
||||
// filter as `col =` and query it as `col IS NULL`.
|
||||
test('keeps the legacy boolean comparator for IS_TRUE', () => {
|
||||
const adhocFilter = new AdhocFilter({
|
||||
expressionType: ExpressionTypes.Simple,
|
||||
subject: 'col',
|
||||
operator: '==',
|
||||
operatorId: Operators.IsTrue,
|
||||
comparator: true,
|
||||
clause: Clauses.Where,
|
||||
});
|
||||
expect(adhocFilter.operator).toBe('==');
|
||||
expect(adhocFilter.comparator).toBe(true);
|
||||
expect(adhocFilter.translateToSql()).toBe("col = 'TRUE'");
|
||||
});
|
||||
test('keeps the legacy boolean comparator for IS_FALSE', () => {
|
||||
const adhocFilter = new AdhocFilter({
|
||||
expressionType: ExpressionTypes.Simple,
|
||||
subject: 'col',
|
||||
operator: '==',
|
||||
operatorId: Operators.IsFalse,
|
||||
comparator: false,
|
||||
clause: Clauses.Where,
|
||||
});
|
||||
expect(adhocFilter.operator).toBe('==');
|
||||
expect(adhocFilter.comparator).toBe(false);
|
||||
expect(adhocFilter.translateToSql()).toBe("col = 'FALSE'");
|
||||
});
|
||||
test('restores the boolean even when the stored comparator is missing', () => {
|
||||
const adhocFilter = new AdhocFilter({
|
||||
expressionType: ExpressionTypes.Simple,
|
||||
subject: 'col',
|
||||
operator: '==',
|
||||
operatorId: Operators.IsTrue,
|
||||
clause: Clauses.Where,
|
||||
});
|
||||
expect(adhocFilter.comparator).toBe(true);
|
||||
});
|
||||
test('keeps a legacy boolean filter intact when the control re-posts it', () => {
|
||||
const stored = {
|
||||
expressionType: ExpressionTypes.Simple,
|
||||
subject: 'col',
|
||||
operator: '==',
|
||||
operatorId: Operators.IsTrue,
|
||||
comparator: true,
|
||||
clause: Clauses.Where,
|
||||
};
|
||||
// DndFilterSelect wraps props.value and hands those instances to onChange
|
||||
const posted = JSON.parse(JSON.stringify(new AdhocFilter(stored)));
|
||||
expect(posted.operator).toBe('==');
|
||||
expect(posted.comparator).toBe(true);
|
||||
expect(posted.operatorId).toBe(Operators.IsTrue);
|
||||
});
|
||||
test('leaves a genuine equality filter on a boolean value alone', () => {
|
||||
const adhocFilter = new AdhocFilter({
|
||||
expressionType: ExpressionTypes.Simple,
|
||||
subject: 'col',
|
||||
operator: '==',
|
||||
operatorId: Operators.Equals,
|
||||
comparator: true,
|
||||
clause: Clauses.Where,
|
||||
});
|
||||
expect(adhocFilter.operator).toBe('==');
|
||||
expect(adhocFilter.comparator).toBe(true);
|
||||
expect(adhocFilter.translateToSql()).toBe("col = 'TRUE'");
|
||||
});
|
||||
test('sets the label properly if subject is a string', () => {
|
||||
const adhocFilter = new AdhocFilter({
|
||||
expressionType: ExpressionTypes.Simple,
|
||||
|
||||
@@ -30,6 +30,15 @@ const CUSTOM_OPERATIONS = [...CUSTOM_OPERATORS].map(
|
||||
op => OPERATOR_ENUM_TO_OPERATOR_TYPE[op].operation,
|
||||
);
|
||||
|
||||
// Charts saved before #32701 store `==` for IS_TRUE/IS_FALSE with the boolean
|
||||
// in the comparator; blanking it makes them query `col IS NULL`. Restoring it
|
||||
// leaves the emitted SQL untouched -- reconciling `operator` to `IS TRUE`
|
||||
// would not, and Druid rejects that predicate on VARCHAR columns.
|
||||
const LEGACY_BOOLEAN_COMPARATORS = new Map<string, boolean>([
|
||||
[Operators.IsTrue, true],
|
||||
[Operators.IsFalse, false],
|
||||
]);
|
||||
|
||||
interface AdhocFilterInput {
|
||||
expressionType?: string;
|
||||
subject?: string | { column_name?: string; [key: string]: unknown } | null;
|
||||
@@ -77,6 +86,16 @@ export default class AdhocFilter {
|
||||
) {
|
||||
this.comparator = undefined;
|
||||
}
|
||||
if (
|
||||
this.operator ===
|
||||
OPERATOR_ENUM_TO_OPERATOR_TYPE[Operators.Equals].operation &&
|
||||
adhocFilter.operatorId &&
|
||||
LEGACY_BOOLEAN_COMPARATORS.has(adhocFilter.operatorId)
|
||||
) {
|
||||
this.comparator = LEGACY_BOOLEAN_COMPARATORS.get(
|
||||
adhocFilter.operatorId,
|
||||
);
|
||||
}
|
||||
this.clause = adhocFilter.clause || Clauses.Where;
|
||||
this.sqlExpression = null;
|
||||
} else if (this.expressionType === ExpressionTypes.Sql) {
|
||||
|
||||
+16
-2
@@ -367,8 +367,22 @@ function AdhocFilterEditPopover({
|
||||
</ErrorBoundary>
|
||||
),
|
||||
},
|
||||
...(datasource?.type === 'semantic_view'
|
||||
? []
|
||||
...(datasource?.type === 'semantic_view' ||
|
||||
[
|
||||
Operators.ContainsAny,
|
||||
Operators.ContainsAll,
|
||||
Operators.IsEmpty,
|
||||
Operators.IsNotEmpty,
|
||||
Operators.LengthEquals,
|
||||
Operators.LengthGreaterThan,
|
||||
Operators.LengthLessThan,
|
||||
Operators.LengthGreaterThanOrEqual,
|
||||
Operators.LengthLessThanOrEqual,
|
||||
].includes(adhocFilter.operatorId as Operators)
|
||||
? // Hide the Custom SQL tab for element-level array operators: they
|
||||
// have no portable SQL representation, and converting one would
|
||||
// silently turn the filter into invalid raw SQL.
|
||||
[]
|
||||
: [
|
||||
{
|
||||
key: ExpressionTypes.Sql,
|
||||
|
||||
+138
@@ -35,6 +35,7 @@ import {
|
||||
} from 'src/explore/constants';
|
||||
import AdhocMetric from 'src/explore/components/controls/MetricControl/AdhocMetric';
|
||||
import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core';
|
||||
import { GenericDataType } from '@apache-superset/core/common';
|
||||
import fetchMock from 'fetch-mock';
|
||||
|
||||
import { TestDataset, Dataset } from '@superset-ui/chart-controls';
|
||||
@@ -252,6 +253,78 @@ test('shows boolean only operators when subject is number', () => {
|
||||
].map(operator => expect(isOperatorRelevant(operator, 'value')).toBe(true));
|
||||
});
|
||||
|
||||
test('shows array operators (tier 1 + tier 2) when subject is multi-value', () => {
|
||||
const props = setup({
|
||||
adhocFilter: new AdhocFilter({
|
||||
expressionType: ExpressionTypes.Simple,
|
||||
subject: 'skills',
|
||||
operatorId: undefined,
|
||||
operator: undefined,
|
||||
comparator: undefined,
|
||||
clause: undefined,
|
||||
}),
|
||||
datasource: {
|
||||
columns: [
|
||||
{
|
||||
id: 3,
|
||||
column_name: 'skills',
|
||||
type: 'Array(String)',
|
||||
type_generic: GenericDataType.MultiValue,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const { isOperatorRelevant } = useSimpleTabFilterProps(
|
||||
props as unknown as Props,
|
||||
);
|
||||
// Tier 1 (whole-array) + Tier 2 (element-level) are all relevant.
|
||||
[
|
||||
Operators.Equals,
|
||||
Operators.NotEquals,
|
||||
Operators.In,
|
||||
Operators.NotIn,
|
||||
Operators.IsNull,
|
||||
Operators.IsNotNull,
|
||||
Operators.ContainsAny,
|
||||
Operators.ContainsAll,
|
||||
Operators.IsEmpty,
|
||||
Operators.IsNotEmpty,
|
||||
].forEach(operator =>
|
||||
expect(isOperatorRelevant(operator, 'skills')).toBe(true),
|
||||
);
|
||||
// scalar-only operators are hidden for array columns
|
||||
[Operators.GreaterThan, Operators.LessThan, Operators.Like].forEach(
|
||||
operator => expect(isOperatorRelevant(operator, 'skills')).toBe(false),
|
||||
);
|
||||
});
|
||||
|
||||
test('hides element-level array operators for non multi-value columns', () => {
|
||||
const props = setup({
|
||||
adhocFilter: new AdhocFilter({
|
||||
expressionType: ExpressionTypes.Simple,
|
||||
subject: 'value',
|
||||
operatorId: undefined,
|
||||
operator: undefined,
|
||||
comparator: undefined,
|
||||
clause: undefined,
|
||||
}),
|
||||
datasource: {
|
||||
columns: [{ id: 3, column_name: 'value', type: 'STRING' }],
|
||||
},
|
||||
});
|
||||
const { isOperatorRelevant } = useSimpleTabFilterProps(
|
||||
props as unknown as Props,
|
||||
);
|
||||
[
|
||||
Operators.ContainsAny,
|
||||
Operators.ContainsAll,
|
||||
Operators.IsEmpty,
|
||||
Operators.IsNotEmpty,
|
||||
].forEach(operator =>
|
||||
expect(isOperatorRelevant(operator, 'value')).toBe(false),
|
||||
);
|
||||
});
|
||||
|
||||
test('will convert from individual comparator to array if the operator changes to multi', () => {
|
||||
const props = setup();
|
||||
const { onOperatorChange } = useSimpleTabFilterProps(
|
||||
@@ -309,6 +382,49 @@ test('will convert from array to individual comparators if the operator changes
|
||||
);
|
||||
});
|
||||
|
||||
test('resets the comparator when switching between array value families', () => {
|
||||
// Equal to (whole-array literal) -> Contains all (individual elements):
|
||||
// the value spaces are incompatible, so the stale value must be cleared.
|
||||
const wholeArrayFilter = new AdhocFilter({
|
||||
expressionType: ExpressionTypes.Simple,
|
||||
subject: 'scores',
|
||||
operatorId: Operators.Equals,
|
||||
operator: OPERATOR_ENUM_TO_OPERATOR_TYPE[Operators.Equals].operation,
|
||||
comparator: '[5,6,7]',
|
||||
clause: Clauses.Where,
|
||||
});
|
||||
const props = setup({ adhocFilter: wholeArrayFilter });
|
||||
const { onOperatorChange } = useSimpleTabFilterProps(
|
||||
props as unknown as Props,
|
||||
);
|
||||
onOperatorChange(Operators.ContainsAll);
|
||||
const lastCall =
|
||||
props.onChange.mock.calls[props.onChange.mock.calls.length - 1][0];
|
||||
expect(lastCall.operatorId).toEqual(Operators.ContainsAll);
|
||||
expect(lastCall.comparator).toBeUndefined();
|
||||
});
|
||||
|
||||
test('keeps the value when switching within the element family', () => {
|
||||
// Contains any <-> Contains all both take individual elements, so the
|
||||
// selected elements should carry over.
|
||||
const elementFilter = new AdhocFilter({
|
||||
expressionType: ExpressionTypes.Simple,
|
||||
subject: 'scores',
|
||||
operatorId: Operators.ContainsAny,
|
||||
operator: OPERATOR_ENUM_TO_OPERATOR_TYPE[Operators.ContainsAny].operation,
|
||||
comparator: ['5', '6'],
|
||||
clause: Clauses.Where,
|
||||
});
|
||||
const props = setup({ adhocFilter: elementFilter });
|
||||
const { onOperatorChange } = useSimpleTabFilterProps(
|
||||
props as unknown as Props,
|
||||
);
|
||||
onOperatorChange(Operators.ContainsAll);
|
||||
const lastCall =
|
||||
props.onChange.mock.calls[props.onChange.mock.calls.length - 1][0];
|
||||
expect(lastCall.comparator).toEqual(['5', '6']);
|
||||
});
|
||||
|
||||
test('passes the new adhocFilter to onChange after onComparatorChange', () => {
|
||||
const props = setup();
|
||||
const { onComparatorChange } = useSimpleTabFilterProps(
|
||||
@@ -399,6 +515,28 @@ test('will not display boolean operators when column type is string', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test.each(['STRING', 'DATE'])(
|
||||
'will not display boolean operators when an expression column declares type %s',
|
||||
type => {
|
||||
const props = setup({
|
||||
datasource: {
|
||||
type: 'table' as const,
|
||||
datasource_name: 'table1',
|
||||
schema: 'schema',
|
||||
columns: [{ column_name: 'value', type, expression: '"value"' }],
|
||||
},
|
||||
adhocFilter: simpleAdhocFilter,
|
||||
});
|
||||
const { isOperatorRelevant } = useSimpleTabFilterProps(
|
||||
props as unknown as Props,
|
||||
);
|
||||
const booleanOnlyOperators = [Operators.IsTrue, Operators.IsFalse];
|
||||
booleanOnlyOperators.forEach(operator => {
|
||||
expect(isOperatorRelevant(operator, 'value')).toBe(false);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
test('will display boolean operators when column is an expression', () => {
|
||||
const props = setup({
|
||||
datasource: {
|
||||
|
||||
+112
-14
@@ -32,6 +32,7 @@ import {
|
||||
isDefined,
|
||||
SupersetClient,
|
||||
} from '@superset-ui/core';
|
||||
import { GenericDataType } from '@apache-superset/core/common';
|
||||
import { styled, useTheme, css } from '@apache-superset/core/theme';
|
||||
import {
|
||||
Operators,
|
||||
@@ -118,6 +119,8 @@ export const useSimpleTabFilterProps = (props: Props) => {
|
||||
const isColumnNumber =
|
||||
!!column && (column.type === 'INT' || column.type === 'INTEGER');
|
||||
const isColumnFunction = !!column && !!column.expression;
|
||||
const isColumnMultiValue =
|
||||
!!column && column.type_generic === GenericDataType.MultiValue;
|
||||
|
||||
if (operator && operator === Operators.LatestPartition) {
|
||||
const { partitionColumn } = props;
|
||||
@@ -127,8 +130,41 @@ export const useSimpleTabFilterProps = (props: Props) => {
|
||||
// hide the TEMPORAL_RANGE operator
|
||||
return false;
|
||||
}
|
||||
// Element-level array operators only apply to multi-value columns.
|
||||
const arrayElementOperators = [
|
||||
Operators.ContainsAny,
|
||||
Operators.ContainsAll,
|
||||
Operators.IsEmpty,
|
||||
Operators.IsNotEmpty,
|
||||
Operators.LengthEquals,
|
||||
Operators.LengthGreaterThan,
|
||||
Operators.LengthLessThan,
|
||||
Operators.LengthGreaterThanOrEqual,
|
||||
Operators.LengthLessThanOrEqual,
|
||||
];
|
||||
if (arrayElementOperators.includes(operator)) {
|
||||
return isColumnMultiValue;
|
||||
}
|
||||
if (isColumnMultiValue) {
|
||||
// Array columns support whole-array operators (=, !=, In, Not in, null
|
||||
// checks) plus the element-level operators above. Scalar-only operators
|
||||
// (Like, <, >, <=, >=) are hidden because they aren't valid on an array.
|
||||
return [
|
||||
Operators.Equals,
|
||||
Operators.NotEquals,
|
||||
Operators.In,
|
||||
Operators.NotIn,
|
||||
Operators.IsNull,
|
||||
Operators.IsNotNull,
|
||||
...arrayElementOperators,
|
||||
].includes(operator);
|
||||
}
|
||||
if (operator === Operators.IsTrue || operator === Operators.IsFalse) {
|
||||
return isColumnBoolean || isColumnNumber || isColumnFunction;
|
||||
// An expression column may evaluate to a boolean, but that is only a
|
||||
// safe assumption while its type is unknown; a declared type wins.
|
||||
return (
|
||||
isColumnBoolean || isColumnNumber || (isColumnFunction && !column?.type)
|
||||
);
|
||||
}
|
||||
if (isColumnBoolean) {
|
||||
return operator === Operators.IsNull || operator === Operators.IsNotNull;
|
||||
@@ -167,9 +203,19 @@ export const useSimpleTabFilterProps = (props: Props) => {
|
||||
].operation
|
||||
: null;
|
||||
if (!isDefined(operator)) {
|
||||
// if operator is `null`, use the `IN` and reset the comparator.
|
||||
operator = Operators.In;
|
||||
operatorId = Operators.In;
|
||||
// The previous operator is not relevant for the new subject; pick a
|
||||
// sensible default and reset the comparator. Multi-value (array) columns
|
||||
// default to "Contains any" (element membership) rather than the
|
||||
// scalar-only IN.
|
||||
const newColumn = props.datasource.columns?.find(
|
||||
col => col.column_name === subject,
|
||||
);
|
||||
const defaultOperator =
|
||||
newColumn?.type_generic === GenericDataType.MultiValue
|
||||
? Operators.ContainsAny
|
||||
: Operators.In;
|
||||
operator = defaultOperator;
|
||||
operatorId = defaultOperator;
|
||||
comparator = undefined;
|
||||
}
|
||||
|
||||
@@ -193,10 +239,38 @@ export const useSimpleTabFilterProps = (props: Props) => {
|
||||
};
|
||||
const onOperatorChange = (operatorId: Operators) => {
|
||||
const currentComparator = props.adhocFilter.comparator;
|
||||
// The value space differs between operator families: element-level array
|
||||
// ops (Contains any/all) take individual elements, whole-array/scalar ops
|
||||
// (=, In, …) take whole arrays or scalars, Length ops take a count, and the
|
||||
// unary ops take nothing. A value from one family is meaningless in another,
|
||||
// so reset the value when the family changes (e.g. Equal to -> Contains all).
|
||||
const comparatorKind = (op?: Operators): string => {
|
||||
if (!op) return 'none';
|
||||
if (op === Operators.ContainsAny || op === Operators.ContainsAll) {
|
||||
return 'element';
|
||||
}
|
||||
if (
|
||||
op === Operators.LengthEquals ||
|
||||
op === Operators.LengthGreaterThan ||
|
||||
op === Operators.LengthLessThan ||
|
||||
op === Operators.LengthGreaterThanOrEqual ||
|
||||
op === Operators.LengthLessThanOrEqual
|
||||
) {
|
||||
return 'length';
|
||||
}
|
||||
if (DISABLE_INPUT_OPERATORS.includes(op)) return 'none';
|
||||
return 'value';
|
||||
};
|
||||
const valueFamilyChanged =
|
||||
comparatorKind(props.adhocFilter.operatorId as Operators | undefined) !==
|
||||
comparatorKind(operatorId);
|
||||
|
||||
let newComparator;
|
||||
// convert between list of comparators and individual comparators
|
||||
// (e.g. `in ('North America', 'Africa')` to `== 'North America'`)
|
||||
if (MULTI_OPERATORS.has(operatorId)) {
|
||||
if (valueFamilyChanged) {
|
||||
newComparator = undefined;
|
||||
} else if (MULTI_OPERATORS.has(operatorId)) {
|
||||
// convert between list of comparators and individual comparators
|
||||
// (e.g. `in ('North America', 'Africa')` to `== 'North America'`)
|
||||
newComparator = Array.isArray(currentComparator)
|
||||
? currentComparator
|
||||
: [currentComparator].filter(element => element != null);
|
||||
@@ -433,19 +507,42 @@ const AdhocFilterEditPopoverSimpleTabContent: FC<Props> = props => {
|
||||
if (loadingComparatorSuggestions) {
|
||||
controller.abort();
|
||||
}
|
||||
// Element-level array operators (Contains any / Contains all) search
|
||||
// inside the array, so suggest individual elements; whole-array
|
||||
// operators (=, In, …) keep the default distinct-array suggestions.
|
||||
const { operatorId } = props.adhocFilter;
|
||||
const arrayElements =
|
||||
operatorId === Operators.ContainsAny ||
|
||||
operatorId === Operators.ContainsAll;
|
||||
setLoadingComparatorSuggestions(true);
|
||||
SupersetClient.get({
|
||||
signal,
|
||||
endpoint: `/api/v1/datasource/${datasource.type}/${datasource.id}/column/${col}/values/`,
|
||||
endpoint: `/api/v1/datasource/${datasource.type}/${datasource.id}/column/${col}/values/${
|
||||
arrayElements ? '?array_elements=true' : ''
|
||||
}`,
|
||||
})
|
||||
.then(({ json }) => {
|
||||
setSuggestions(
|
||||
json.result.map(
|
||||
(suggestion: null | number | boolean | string) => ({
|
||||
value: suggestion,
|
||||
label: optionLabel(suggestion),
|
||||
}),
|
||||
),
|
||||
json.result.map((suggestion: unknown) => {
|
||||
// Complex column values arrive as JS arrays or objects: whole
|
||||
// arrays for MULTI_VALUE columns (e.g. [5, 6, 7]) and Map/Tuple
|
||||
// objects for nested-container columns (e.g. {"a": ["x","y"]}).
|
||||
// A raw array/object is neither a valid single-select value
|
||||
// (antd collapses an array to its first element) nor renderable
|
||||
// as a React child (an object throws). Render it as its literal
|
||||
// string, which is also exactly what the backend's
|
||||
// parse_array_literal expects for the whole-array operators.
|
||||
if (suggestion !== null && typeof suggestion === 'object') {
|
||||
const literal = JSON.stringify(suggestion);
|
||||
return { value: literal, label: literal };
|
||||
}
|
||||
return {
|
||||
value: suggestion as null | number | boolean | string,
|
||||
label: optionLabel(
|
||||
suggestion as null | number | boolean | string,
|
||||
),
|
||||
};
|
||||
}),
|
||||
);
|
||||
setLoadingComparatorSuggestions(false);
|
||||
})
|
||||
@@ -464,6 +561,7 @@ const AdhocFilterEditPopoverSimpleTabContent: FC<Props> = props => {
|
||||
}, [
|
||||
props.adhocFilter.subject,
|
||||
props.adhocFilter.clause,
|
||||
props.adhocFilter.operatorId,
|
||||
props.datasource,
|
||||
datePicker,
|
||||
]);
|
||||
|
||||
+11
@@ -44,6 +44,17 @@ export const OPERATORS_TO_SQL = {
|
||||
'IS NULL': 'IS NULL',
|
||||
'IS TRUE': 'IS TRUE',
|
||||
'IS FALSE': 'IS FALSE',
|
||||
// Element-level array operators (shown as filter labels; not executable SQL —
|
||||
// the Custom SQL tab is hidden for these).
|
||||
CONTAINS_ANY: 'CONTAINS ANY',
|
||||
CONTAINS_ALL: 'CONTAINS ALL',
|
||||
IS_EMPTY: 'IS EMPTY',
|
||||
IS_NOT_EMPTY: 'IS NOT EMPTY',
|
||||
LENGTH_EQUALS: 'LENGTH =',
|
||||
LENGTH_GREATER_THAN: 'LENGTH >',
|
||||
LENGTH_LESS_THAN: 'LENGTH <',
|
||||
LENGTH_GREATER_THAN_OR_EQUALS: 'LENGTH >=',
|
||||
LENGTH_LESS_THAN_OR_EQUALS: 'LENGTH <=',
|
||||
'LATEST PARTITION': ({
|
||||
datasource,
|
||||
}: {
|
||||
|
||||
@@ -191,6 +191,29 @@ describe('AdhocMetric', () => {
|
||||
expect(adhocMetric2.inferSqlExpressionAggregate()).toBeNull();
|
||||
});
|
||||
|
||||
test('can infer the new extended aggregates (STDDEV_SAMP/VAR_SAMP/MEDIAN) from sql expressions', () => {
|
||||
const stddevSamp = new AdhocMetric({
|
||||
expressionType: EXPRESSION_TYPES.SQL,
|
||||
sqlExpression: 'STDDEV_SAMP(my_column)',
|
||||
});
|
||||
expect(stddevSamp.inferSqlExpressionColumn()).toBe('my_column');
|
||||
expect(stddevSamp.inferSqlExpressionAggregate()).toBe('STDDEV_SAMP');
|
||||
|
||||
const varSamp = new AdhocMetric({
|
||||
expressionType: EXPRESSION_TYPES.SQL,
|
||||
sqlExpression: 'VAR_SAMP(my_column)',
|
||||
});
|
||||
expect(varSamp.inferSqlExpressionColumn()).toBe('my_column');
|
||||
expect(varSamp.inferSqlExpressionAggregate()).toBe('VAR_SAMP');
|
||||
|
||||
const median = new AdhocMetric({
|
||||
expressionType: EXPRESSION_TYPES.SQL,
|
||||
sqlExpression: 'MEDIAN(my_column)',
|
||||
});
|
||||
expect(median.inferSqlExpressionColumn()).toBe('my_column');
|
||||
expect(median.inferSqlExpressionAggregate()).toBe('MEDIAN');
|
||||
});
|
||||
|
||||
test('will infer columns and aggregates when converting to a simple expression', () => {
|
||||
const adhocMetric = new AdhocMetric({
|
||||
expressionType: EXPRESSION_TYPES.SQL,
|
||||
@@ -248,4 +271,20 @@ describe('AdhocMetric', () => {
|
||||
).toBe('COUNT_DISTINCT');
|
||||
expect(emptyColumnName.getDefaultLabel()).toBe('COUNT_DISTINCT');
|
||||
});
|
||||
|
||||
test('should prefill a portable MEDIAN expression for the Custom SQL tab, but keep the raw label', () => {
|
||||
const median = new AdhocMetric({
|
||||
column: valueColumn,
|
||||
aggregate: AGGREGATES.MEDIAN,
|
||||
hasCustomLabel: false,
|
||||
});
|
||||
// MEDIAN(column) isn't valid SQL on every engine this PR verifies it
|
||||
// for (e.g. PostgreSQL has no MEDIAN function), so the editable Custom
|
||||
// SQL tab is prefilled with the portable, standards-based spelling.
|
||||
expect(median.translateToSql({ transformCountDistinct: true })).toBe(
|
||||
'PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY value)',
|
||||
);
|
||||
// The display label stays the concise, human-readable form.
|
||||
expect(median.getDefaultLabel()).toBe('MEDIAN(value)');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -153,6 +153,19 @@ export default class AdhocMetric {
|
||||
) {
|
||||
return `COUNT(DISTINCT ${column.slice(1, -1)})`;
|
||||
}
|
||||
// MEDIAN(column) isn't a real function on every engine this PR
|
||||
// verifies it for -- PostgreSQL/Redshift compile it to
|
||||
// PERCENTILE_CONT(0.5) WITHIN GROUP instead. `transformCountDistinct`
|
||||
// signals this call is prefilling the *editable, executable* Custom
|
||||
// SQL tab (not just a display label), so use the portable,
|
||||
// standards-based spelling there instead of the raw aggregate name.
|
||||
if (
|
||||
params.transformCountDistinct &&
|
||||
aggregate === AGGREGATES.MEDIAN &&
|
||||
/^\(.*\)$/.test(column)
|
||||
) {
|
||||
return `PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY ${column.slice(1, -1)})`;
|
||||
}
|
||||
return aggregate + column;
|
||||
}
|
||||
if (this.expressionType === EXPRESSION_TYPES.SQL) {
|
||||
|
||||
+2
-1
@@ -36,6 +36,7 @@ import {
|
||||
import sqlKeywords from 'src/SqlLab/utils/sqlKeywords';
|
||||
import { noOp } from 'src/utils/common';
|
||||
import {
|
||||
AGGREGATES_LABELS,
|
||||
AGGREGATES_OPTIONS,
|
||||
POPOVER_INITIAL_HEIGHT,
|
||||
POPOVER_INITIAL_WIDTH,
|
||||
@@ -548,7 +549,7 @@ function AdhocMetricEditPopover({
|
||||
<Select
|
||||
options={AGGREGATES_OPTIONS.map(option => ({
|
||||
value: option,
|
||||
label: option,
|
||||
label: AGGREGATES_LABELS[option] ?? option,
|
||||
key: option,
|
||||
}))}
|
||||
{...aggregateSelectProps}
|
||||
|
||||
@@ -23,11 +23,22 @@ export const AGGREGATES = {
|
||||
COUNT: 'COUNT',
|
||||
COUNT_DISTINCT: 'COUNT_DISTINCT',
|
||||
MAX: 'MAX',
|
||||
MEDIAN: 'MEDIAN',
|
||||
MIN: 'MIN',
|
||||
STDDEV_SAMP: 'STDDEV_SAMP',
|
||||
SUM: 'SUM',
|
||||
VAR_SAMP: 'VAR_SAMP',
|
||||
};
|
||||
export const AGGREGATES_OPTIONS = Object.values(AGGREGATES);
|
||||
|
||||
// Human-readable labels for aggregates whose raw enum value isn't
|
||||
// self-explanatory in the UI. Aggregates absent here (AVG, COUNT, MAX,
|
||||
// MEDIAN, MIN, SUM, ...) are already clear as their raw value.
|
||||
export const AGGREGATES_LABELS: Record<string, string> = {
|
||||
STDDEV_SAMP: t('Sample Standard Deviation'),
|
||||
VAR_SAMP: t('Sample Variance'),
|
||||
};
|
||||
|
||||
export enum Operators {
|
||||
Equals = 'EQUALS',
|
||||
NotEquals = 'NOT_EQUALS',
|
||||
@@ -45,6 +56,17 @@ export enum Operators {
|
||||
IsTrue = 'IS_TRUE',
|
||||
IsFalse = 'IS_FALSE',
|
||||
TemporalRange = 'TEMPORAL_RANGE',
|
||||
// Element-level operators for multi-value (array) columns
|
||||
ContainsAny = 'CONTAINS_ANY',
|
||||
ContainsAll = 'CONTAINS_ALL',
|
||||
IsEmpty = 'IS_EMPTY',
|
||||
IsNotEmpty = 'IS_NOT_EMPTY',
|
||||
// Length (element-count) comparison operators for array columns
|
||||
LengthEquals = 'LENGTH_EQUALS',
|
||||
LengthGreaterThan = 'LENGTH_GREATER_THAN',
|
||||
LengthLessThan = 'LENGTH_LESS_THAN',
|
||||
LengthGreaterThanOrEqual = 'LENGTH_GREATER_THAN_OR_EQUALS',
|
||||
LengthLessThanOrEqual = 'LENGTH_LESS_THAN_OR_EQUALS',
|
||||
}
|
||||
|
||||
export interface OperatorType {
|
||||
@@ -89,6 +111,39 @@ export const OPERATOR_ENUM_TO_OPERATOR_TYPE: {
|
||||
display: t('TEMPORAL_RANGE'),
|
||||
operation: 'TEMPORAL_RANGE',
|
||||
},
|
||||
[Operators.ContainsAny]: {
|
||||
display: t('Contains any'),
|
||||
operation: 'CONTAINS_ANY',
|
||||
},
|
||||
[Operators.ContainsAll]: {
|
||||
display: t('Contains all'),
|
||||
operation: 'CONTAINS_ALL',
|
||||
},
|
||||
[Operators.IsEmpty]: { display: t('Is empty'), operation: 'IS_EMPTY' },
|
||||
[Operators.IsNotEmpty]: {
|
||||
display: t('Is not empty'),
|
||||
operation: 'IS_NOT_EMPTY',
|
||||
},
|
||||
[Operators.LengthEquals]: {
|
||||
display: t('Length equals (=)'),
|
||||
operation: 'LENGTH_EQUALS',
|
||||
},
|
||||
[Operators.LengthGreaterThan]: {
|
||||
display: t('Length greater than (>)'),
|
||||
operation: 'LENGTH_GREATER_THAN',
|
||||
},
|
||||
[Operators.LengthLessThan]: {
|
||||
display: t('Length less than (<)'),
|
||||
operation: 'LENGTH_LESS_THAN',
|
||||
},
|
||||
[Operators.LengthGreaterThanOrEqual]: {
|
||||
display: t('Length greater or equal (>=)'),
|
||||
operation: 'LENGTH_GREATER_THAN_OR_EQUALS',
|
||||
},
|
||||
[Operators.LengthLessThanOrEqual]: {
|
||||
display: t('Length less or equal (<=)'),
|
||||
operation: 'LENGTH_LESS_THAN_OR_EQUALS',
|
||||
},
|
||||
};
|
||||
|
||||
export const OPERATORS_OPTIONS = Object.values(Operators) as Operators[];
|
||||
@@ -105,7 +160,12 @@ export const HAVING_OPERATORS = [
|
||||
Operators.GreaterThan,
|
||||
Operators.GreaterThanOrEqual,
|
||||
];
|
||||
export const MULTI_OPERATORS = new Set([Operators.In, Operators.NotIn]);
|
||||
export const MULTI_OPERATORS = new Set([
|
||||
Operators.In,
|
||||
Operators.NotIn,
|
||||
Operators.ContainsAny,
|
||||
Operators.ContainsAll,
|
||||
]);
|
||||
// CUSTOM_OPERATORS will show operator in simple mode,
|
||||
// but will generate customized sqlExpression
|
||||
export const CUSTOM_OPERATORS = new Set([
|
||||
@@ -120,12 +180,14 @@ export const DISABLE_INPUT_OPERATORS = [
|
||||
Operators.LatestPartition,
|
||||
Operators.IsTrue,
|
||||
Operators.IsFalse,
|
||||
Operators.IsEmpty,
|
||||
Operators.IsNotEmpty,
|
||||
];
|
||||
|
||||
export const sqlaAutoGeneratedMetricNameRegex =
|
||||
/^(sum|min|max|avg|count|count_distinct)__.*$/i;
|
||||
export const sqlaAutoGeneratedMetricRegex =
|
||||
/^(LONG|DOUBLE|FLOAT)?(SUM|AVG|MAX|MIN|COUNT)\([A-Z0-9_."]*\)$/i;
|
||||
/^(LONG|DOUBLE|FLOAT)?(SUM|AVG|MAX|MIN|COUNT|MEDIAN|STDDEV_SAMP|VAR_SAMP)\([A-Z0-9_."]*\)$/i;
|
||||
|
||||
export const TIME_FILTER_LABELS = {
|
||||
time_range: t('Time range'),
|
||||
|
||||
@@ -82,3 +82,14 @@ test('Should handle boolean true comparator as a string value', () => {
|
||||
"subject operator 'TRUE'",
|
||||
);
|
||||
});
|
||||
|
||||
test('Should render array-literal comparators as-is (not quoted)', () => {
|
||||
// Whole-array = filter: the pasted array literal is shown unquoted.
|
||||
expect(getSimpleSQLExpression('ingredients', '=', "['1 large egg']")).toBe(
|
||||
"ingredients = ['1 large egg']",
|
||||
);
|
||||
// IN with multiple array literals.
|
||||
expect(
|
||||
getSimpleSQLExpression('ingredients', Operators.In, ["['a']", "['b']"]),
|
||||
).toBe(`ingredients ${Operators.In} (['a'], ['b'])`);
|
||||
});
|
||||
|
||||
@@ -461,10 +461,15 @@ export const getSimpleSQLExpression = (
|
||||
if (comparatorArray.length > 0 && showComparator) {
|
||||
const formattedComparators = comparatorArray
|
||||
.map(val => optionLabel(val))
|
||||
.map(
|
||||
val =>
|
||||
`${quote}${isString ? String(val).replace(/'/g, "''") : val}${quote}`,
|
||||
);
|
||||
.map(val => {
|
||||
// Array-literal values (e.g. ['a', 'b']) are shown as-is rather than
|
||||
// quoted/escaped as a string, so array-column filters read naturally.
|
||||
const asString = String(val);
|
||||
if (asString.startsWith('[') && asString.endsWith(']')) {
|
||||
return asString;
|
||||
}
|
||||
return `${quote}${isString ? asString.replace(/'/g, "''") : val}${quote}`;
|
||||
});
|
||||
expression += ` ${prefix}${formattedComparators.join(', ')}${suffix}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* 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 { createMemoryHistory, type Update } from 'history';
|
||||
import { Router } from 'react-router-dom';
|
||||
import { isFeatureEnabled } from '@superset-ui/core';
|
||||
import { render, screen, fireEvent } from 'spec/helpers/testing-library';
|
||||
import type Chart from 'src/types/Chart';
|
||||
import ChartCard from './ChartCard';
|
||||
|
||||
jest.mock('@superset-ui/core', () => ({
|
||||
...jest.requireActual('@superset-ui/core'),
|
||||
isFeatureEnabled: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockChart = {
|
||||
id: 1,
|
||||
slice_name: 'Sample Chart',
|
||||
url: '/explore/?slice_id=1',
|
||||
changed_on_delta_humanized: '2 days ago',
|
||||
datasource_name_text: 'Sample dataset',
|
||||
thumbnail_url: '/thumbnail.png',
|
||||
} as Chart;
|
||||
|
||||
const renderCard = (history: ReturnType<typeof createMemoryHistory>) =>
|
||||
render(
|
||||
<Router history={history}>
|
||||
<ChartCard
|
||||
chart={mockChart}
|
||||
hasPerm={() => true}
|
||||
openChartEditModal={jest.fn()}
|
||||
bulkSelectEnabled={false}
|
||||
addDangerToast={jest.fn()}
|
||||
addSuccessToast={jest.fn()}
|
||||
refreshData={jest.fn()}
|
||||
saveFavoriteStatus={jest.fn()}
|
||||
favoriteStatus={false}
|
||||
showThumbnails
|
||||
handleBulkChartExport={jest.fn()}
|
||||
/>
|
||||
</Router>,
|
||||
);
|
||||
|
||||
const recordNavigations = (
|
||||
history: ReturnType<typeof createMemoryHistory>,
|
||||
): string[] => {
|
||||
const navigations: string[] = [];
|
||||
history.listen(({ action, location }: Update) =>
|
||||
navigations.push(`${action} ${location.pathname}${location.search}`),
|
||||
);
|
||||
return navigations;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
(isFeatureEnabled as jest.Mock).mockReturnValue(true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
(isFeatureEnabled as jest.Mock).mockReset();
|
||||
});
|
||||
|
||||
test('renders the chart title', () => {
|
||||
renderCard(createMemoryHistory());
|
||||
expect(screen.getByText('Sample Chart')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('clicking the thumbnail navigates to the chart exactly once', () => {
|
||||
// The cover is a router link and the whole card is clickable, so a click on
|
||||
// the cover used to be handled twice and pushed two identical entries. That
|
||||
// left the Back button popping the duplicate instead of returning the user to
|
||||
// the page they came from.
|
||||
const history = createMemoryHistory({
|
||||
initialEntries: ['/superset/welcome/'],
|
||||
});
|
||||
renderCard(history);
|
||||
const navigations = recordNavigations(history);
|
||||
|
||||
fireEvent.click(screen.getByRole('link'));
|
||||
|
||||
expect(navigations).toEqual(['PUSH /explore/?slice_id=1']);
|
||||
});
|
||||
|
||||
test('clicking the card outside the thumbnail navigates to the chart', () => {
|
||||
const history = createMemoryHistory({
|
||||
initialEntries: ['/superset/welcome/'],
|
||||
});
|
||||
renderCard(history);
|
||||
const navigations = recordNavigations(history);
|
||||
|
||||
fireEvent.click(screen.getByText('Sample Chart'));
|
||||
|
||||
expect(navigations).toEqual(['PUSH /explore/?slice_id=1']);
|
||||
});
|
||||
@@ -32,7 +32,11 @@ import {
|
||||
import Chart from 'src/types/Chart';
|
||||
import { SubjectPile } from 'src/features/subjects/SubjectPile';
|
||||
import { KebabMenuButton } from 'src/components';
|
||||
import { handleChartDelete, CardStyles } from 'src/views/CRUD/utils';
|
||||
import {
|
||||
handleChartDelete,
|
||||
CardStyles,
|
||||
isNavigationHandledByLink,
|
||||
} from 'src/views/CRUD/utils';
|
||||
import { assetUrl } from 'src/utils/assetUrl';
|
||||
import type { ListViewFetchDataConfig as FetchDataConfig } from 'src/components';
|
||||
import { TableTab } from 'src/views/CRUD/types';
|
||||
@@ -208,8 +212,12 @@ export default function ChartCard({
|
||||
|
||||
return (
|
||||
<CardStyles
|
||||
onClick={() => {
|
||||
if (!bulkSelectEnabled && chart.url) {
|
||||
onClick={event => {
|
||||
if (
|
||||
!bulkSelectEnabled &&
|
||||
chart.url &&
|
||||
!isNavigationHandledByLink(event)
|
||||
) {
|
||||
history.push(chart.url);
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -17,10 +17,16 @@
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { createMemoryHistory, type Update } from 'history';
|
||||
import { MemoryRouter, Router } from 'react-router-dom';
|
||||
import { isFeatureEnabled } from '@superset-ui/core';
|
||||
|
||||
import { render, screen } from 'spec/helpers/testing-library';
|
||||
import {
|
||||
render,
|
||||
screen,
|
||||
fireEvent,
|
||||
within,
|
||||
} from 'spec/helpers/testing-library';
|
||||
import { SubjectType } from 'src/types/Subject';
|
||||
|
||||
import DashboardCard from './DashboardCard';
|
||||
@@ -63,6 +69,10 @@ afterAll(() => {
|
||||
mockedIsFeatureEnabled.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
@@ -101,6 +111,43 @@ test('Renders the modified date', () => {
|
||||
expect(modifiedDateElement).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('clicking the thumbnail navigates to the dashboard exactly once', () => {
|
||||
// The cover is a router link and the whole card is clickable, so a click on
|
||||
// the cover used to be handled twice and pushed two identical entries, which
|
||||
// left the Back button popping the duplicate rather than returning the user
|
||||
// to the page they came from.
|
||||
jest.spyOn(global, 'fetch').mockResolvedValue({
|
||||
blob: () => Promise.resolve(new Blob([''], { type: 'image/png' })),
|
||||
} as Response);
|
||||
const history = createMemoryHistory({
|
||||
initialEntries: ['/superset/welcome/'],
|
||||
});
|
||||
const { container } = render(
|
||||
<Router history={history}>
|
||||
<DashboardCard
|
||||
dashboard={mockDashboard}
|
||||
hasPerm={mockHasPerm}
|
||||
bulkSelectEnabled={false}
|
||||
loading={false}
|
||||
showThumbnails
|
||||
openDashboardEditModal={mockOpenDashboardEditModal}
|
||||
saveFavoriteStatus={mockSaveFavoriteStatus}
|
||||
favoriteStatus={false}
|
||||
handleBulkDashboardExport={mockHandleBulkDashboardExport}
|
||||
onDelete={mockOnDelete}
|
||||
/>
|
||||
</Router>,
|
||||
);
|
||||
const navigations: string[] = [];
|
||||
history.listen(({ action, location }: Update) =>
|
||||
navigations.push(`${action} ${location.pathname}`),
|
||||
);
|
||||
|
||||
fireEvent.click(within(container).getByRole('link'));
|
||||
|
||||
expect(navigations).toEqual(['PUSH /dashboard/1']);
|
||||
});
|
||||
|
||||
describe('thumbnail URL construction', () => {
|
||||
let fetchSpy: jest.SpyInstance;
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ import { Link, useHistory } from 'react-router-dom';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { isFeatureEnabled, FeatureFlag } from '@superset-ui/core';
|
||||
import { css } from '@apache-superset/core/theme';
|
||||
import { CardStyles } from 'src/views/CRUD/utils';
|
||||
import { CardStyles, isNavigationHandledByLink } from 'src/views/CRUD/utils';
|
||||
import {
|
||||
FaveStar,
|
||||
Icons,
|
||||
@@ -169,8 +169,8 @@ function DashboardCard({
|
||||
|
||||
return (
|
||||
<CardStyles
|
||||
onClick={() => {
|
||||
if (!bulkSelectEnabled) {
|
||||
onClick={event => {
|
||||
if (!bulkSelectEnabled && !isNavigationHandledByLink(event)) {
|
||||
history.push(dashboard.url);
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -31,7 +31,12 @@ import {
|
||||
import { Group, Role, UserObject } from 'src/pages/UsersList/types';
|
||||
import { Actions } from 'src/constants';
|
||||
import { BaseUserListModalProps, FormValues } from './types';
|
||||
import { createUser, updateUser, atLeastOneRoleOrGroup } from './utils';
|
||||
import {
|
||||
createUser,
|
||||
updateUser,
|
||||
atLeastOneRoleOrGroup,
|
||||
handleUserError,
|
||||
} from './utils';
|
||||
|
||||
export interface UserModalProps extends BaseUserListModalProps {
|
||||
roles: Role[];
|
||||
@@ -51,36 +56,6 @@ function UserListModal({
|
||||
}: UserModalProps) {
|
||||
const { addDangerToast, addSuccessToast } = useToasts();
|
||||
const handleFormSubmit = async (values: FormValues) => {
|
||||
const handleError = async (
|
||||
err: any,
|
||||
action: Actions.CREATE | Actions.UPDATE,
|
||||
) => {
|
||||
let errorMessage =
|
||||
action === Actions.CREATE
|
||||
? t('There was an error creating the user. Please, try again.')
|
||||
: t('There was an error updating the user. Please, try again.');
|
||||
|
||||
if (err.status === 422) {
|
||||
const errorData = await err.json();
|
||||
const detail = errorData?.message || '';
|
||||
|
||||
if (detail.includes('duplicate key value')) {
|
||||
if (detail.includes('ab_user_username_key')) {
|
||||
errorMessage = t(
|
||||
'This username is already taken. Please choose another one.',
|
||||
);
|
||||
} else if (detail.includes('ab_user_email_key')) {
|
||||
errorMessage = t(
|
||||
'This email is already associated with an account. Please choose another one.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
addDangerToast(errorMessage);
|
||||
throw err;
|
||||
};
|
||||
|
||||
if (isEditMode) {
|
||||
if (!user) {
|
||||
throw new Error('User is required in edit mode');
|
||||
@@ -89,14 +64,14 @@ function UserListModal({
|
||||
await updateUser(user.id, values);
|
||||
addSuccessToast(t('The user has been updated successfully.'));
|
||||
} catch (err) {
|
||||
await handleError(err, Actions.UPDATE);
|
||||
await handleUserError(err as Response, Actions.UPDATE, addDangerToast);
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
await createUser(values);
|
||||
addSuccessToast(t('The user has been created successfully.'));
|
||||
} catch (err) {
|
||||
await handleError(err, Actions.CREATE);
|
||||
await handleUserError(err as Response, Actions.CREATE, addDangerToast);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* 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 { Actions } from 'src/constants';
|
||||
import { handleUserError } from './utils';
|
||||
|
||||
test('shows the password validation message from a 400 response', async () => {
|
||||
const error = new Response(
|
||||
JSON.stringify({
|
||||
message: {
|
||||
password: ['Password must be at least 8 characters long.'],
|
||||
},
|
||||
}),
|
||||
{ status: 400 },
|
||||
);
|
||||
const addDangerToast = jest.fn();
|
||||
|
||||
await expect(
|
||||
handleUserError(error, Actions.CREATE, addDangerToast),
|
||||
).rejects.toBe(error);
|
||||
expect(addDangerToast).toHaveBeenCalledWith(
|
||||
'Password must be at least 8 characters long.',
|
||||
);
|
||||
});
|
||||
|
||||
test('shows a plain string message from a 400 response', async () => {
|
||||
const error = new Response(
|
||||
JSON.stringify({ message: 'User must have at least one role or group!' }),
|
||||
{ status: 400 },
|
||||
);
|
||||
const addDangerToast = jest.fn();
|
||||
|
||||
await expect(
|
||||
handleUserError(error, Actions.UPDATE, addDangerToast),
|
||||
).rejects.toBe(error);
|
||||
expect(addDangerToast).toHaveBeenCalledWith(
|
||||
'User must have at least one role or group!',
|
||||
);
|
||||
});
|
||||
|
||||
test('keeps the duplicate username message for a 422 response', async () => {
|
||||
const error = new Response(
|
||||
JSON.stringify({
|
||||
message:
|
||||
'duplicate key value violates unique constraint "ab_user_username_key"',
|
||||
}),
|
||||
{ status: 422 },
|
||||
);
|
||||
const addDangerToast = jest.fn();
|
||||
|
||||
await expect(
|
||||
handleUserError(error, Actions.CREATE, addDangerToast),
|
||||
).rejects.toBe(error);
|
||||
expect(addDangerToast).toHaveBeenCalledWith(
|
||||
'This username is already taken. Please choose another one.',
|
||||
);
|
||||
});
|
||||
|
||||
test('shows the generic message when a 422 response has no message', async () => {
|
||||
const error = new Response(JSON.stringify({ foo: 'bar' }), { status: 422 });
|
||||
const addDangerToast = jest.fn();
|
||||
|
||||
await expect(
|
||||
handleUserError(error, Actions.CREATE, addDangerToast),
|
||||
).rejects.toBe(error);
|
||||
expect(addDangerToast).toHaveBeenCalledWith(
|
||||
'There was an error creating the user. Please, try again.',
|
||||
);
|
||||
});
|
||||
|
||||
test('shows the generic message when a 400 response is not JSON', async () => {
|
||||
const error = new Response('<html>Bad request</html>', {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'text/html' },
|
||||
});
|
||||
const addDangerToast = jest.fn();
|
||||
|
||||
await expect(
|
||||
handleUserError(error, Actions.CREATE, addDangerToast),
|
||||
).rejects.toBe(error);
|
||||
expect(addDangerToast).toHaveBeenCalledWith(
|
||||
'There was an error creating the user. Please, try again.',
|
||||
);
|
||||
});
|
||||
@@ -17,10 +17,49 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { SupersetClient } from '@superset-ui/core';
|
||||
import { getClientErrorObject, SupersetClient } from '@superset-ui/core';
|
||||
import { SelectOption } from 'src/components/ListView';
|
||||
import { Actions } from 'src/constants';
|
||||
import { FormValues } from './types';
|
||||
|
||||
type AddDangerToast = (message: string) => void;
|
||||
|
||||
export const handleUserError = async (
|
||||
err: Response,
|
||||
action: Actions.CREATE | Actions.UPDATE,
|
||||
addDangerToast: AddDangerToast,
|
||||
): Promise<never> => {
|
||||
let errorMessage =
|
||||
action === Actions.CREATE
|
||||
? t('There was an error creating the user. Please, try again.')
|
||||
: t('There was an error updating the user. Please, try again.');
|
||||
|
||||
if (err.status === 400 || err.status === 422) {
|
||||
const errorData = await getClientErrorObject(err);
|
||||
const message: unknown = errorData.message;
|
||||
|
||||
if (err.status === 400 && message && errorData.error) {
|
||||
errorMessage = errorData.error;
|
||||
} else if (
|
||||
err.status === 422 &&
|
||||
errorData.error?.includes('duplicate key value')
|
||||
) {
|
||||
if (errorData.error.includes('ab_user_username_key')) {
|
||||
errorMessage = t(
|
||||
'This username is already taken. Please choose another one.',
|
||||
);
|
||||
} else if (errorData.error.includes('ab_user_email_key')) {
|
||||
errorMessage = t(
|
||||
'This email is already associated with an account. Please choose another one.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
addDangerToast(errorMessage);
|
||||
throw err;
|
||||
};
|
||||
|
||||
export const createUser = async (values: FormValues) => {
|
||||
const { confirmPassword: _confirmPassword, ...payload } = values;
|
||||
if (payload.active == null) {
|
||||
|
||||
@@ -141,6 +141,7 @@ const renderArchivedList = (withStore = store) =>
|
||||
beforeEach(() => {
|
||||
fetchMock.removeRoutes();
|
||||
fetchMock.clearHistory();
|
||||
mockAddDangerToast.mockClear();
|
||||
});
|
||||
|
||||
test('renders archived rows with Name and Type columns', async () => {
|
||||
@@ -204,6 +205,31 @@ test('restore failure surfaces an error and leaves the row in place', async () =
|
||||
expect(screen.getByText('Deleted Chart One')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('restoring an already-restored row (404) surfaces an error without crashing', async () => {
|
||||
// Simulates another actor having restored the object out from under this
|
||||
// view: the server answers 404 to the now-stale row's restore request.
|
||||
mockRoutes(404);
|
||||
renderArchivedList();
|
||||
await screen.findByTestId('archived-list-view');
|
||||
|
||||
const restoreButtons = await screen.findAllByTestId('archived-row-restore');
|
||||
fireEvent.click(restoreButtons[0]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock.callHistory.calls(/chart\/uuid-1\/restore/)).toHaveLength(
|
||||
1,
|
||||
);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(mockAddDangerToast).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Failed to restore Deleted Chart One'),
|
||||
);
|
||||
});
|
||||
expect(mockAddDangerToast).toHaveBeenCalledTimes(1);
|
||||
// The page is still functional -- the list view did not crash.
|
||||
expect(screen.getByTestId('archived-list-view')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('row actions are keyboard-operable (Enter restores)', async () => {
|
||||
mockRoutes();
|
||||
renderArchivedList();
|
||||
@@ -273,6 +299,45 @@ test('name search refetches with a contains filter on the name field', async ()
|
||||
});
|
||||
});
|
||||
|
||||
test('a search that matches nothing shows the empty-state and no restore actions', async () => {
|
||||
// The initial load returns real rows; only the search-triggered request
|
||||
// answers empty. If the list were empty from the start, this test could
|
||||
// pass even if the search never fired a request at all -- so the request
|
||||
// itself is asserted below before trusting the rendered empty state.
|
||||
fetchMock.get(infoEndpoint, { permissions: ['can_read', 'can_write'] });
|
||||
fetchMock.getOnce(listEndpoint, {
|
||||
result: mockCharts,
|
||||
count: mockCharts.length,
|
||||
});
|
||||
fetchMock.get(listEndpoint, { result: [], count: 0 });
|
||||
renderArchivedList();
|
||||
await screen.findByText('Deleted Chart One');
|
||||
|
||||
const searchInput = screen.getByPlaceholderText(/type a value/i);
|
||||
fireEvent.change(searchInput, { target: { value: 'e2e_nonexistent' } });
|
||||
fireEvent.keyDown(searchInput, { key: 'Enter', keyCode: 13 });
|
||||
|
||||
await waitFor(() => {
|
||||
const hit = fetchMock.callHistory
|
||||
.calls(/chart\/\?q/)
|
||||
.find(call =>
|
||||
call.url.includes(
|
||||
'(col:slice_name,opr:chart_all_text,value:e2e_nonexistent)',
|
||||
),
|
||||
);
|
||||
expect(hit).toBeTruthy();
|
||||
});
|
||||
|
||||
// ListView renders this hardcoded copy whenever a filter is active and the
|
||||
// result set is empty, overriding the page's own `emptyState` prop
|
||||
// entirely (see ListView.tsx) -- so this is the actual rendered text, not
|
||||
// the page's "No archived items" default.
|
||||
expect(
|
||||
await screen.findByText('No results match your filter criteria'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryAllByTestId('archived-row-restore')).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('switching Type fetches the newly selected resource with its deleted-state filter', async () => {
|
||||
mockRoutes();
|
||||
renderArchivedList();
|
||||
|
||||
@@ -239,6 +239,40 @@ describe('ChartList', () => {
|
||||
screen.getByRole('button', { name: 'Bulk select' }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('archive (soft-delete) confirmation reflects recoverable semantics, not delete', async () => {
|
||||
// With SOFT_DELETE on, the same delete affordance becomes reversible: the
|
||||
// dialog reads "Archive", not "Delete", and drops the "type DELETE to
|
||||
// confirm" gate -- that friction is reserved for the permanent purge in
|
||||
// the Recently Archived view, not this one.
|
||||
(
|
||||
isFeatureEnabled as jest.MockedFunction<typeof isFeatureEnabled>
|
||||
).mockImplementation((feature: string) => feature === 'SOFT_DELETE');
|
||||
|
||||
// isUserEditorOrAdmin requires `username` + `permissions` to recognize an
|
||||
// Admin role (see src/types/bootstrapTypes.ts's isUserWithPermissionsAndRoles);
|
||||
// mockUser lacks both, so row actions would otherwise render disabled.
|
||||
const adminUser = { ...mockUser, username: 'admin', permissions: {} };
|
||||
renderChartList(adminUser);
|
||||
await screen.findByTestId('chart-list-view');
|
||||
|
||||
const deleteButtons = await screen.findAllByTestId('chart-row-delete');
|
||||
fireEvent.click(deleteButtons[0]);
|
||||
|
||||
const dialog = await screen.findByRole('dialog');
|
||||
expect(
|
||||
within(dialog).getByText(`Archive ${mockCharts[0].slice_name}?`),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
within(dialog).getByRole('button', { name: 'Archive' }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
within(dialog).getByText(/moved to Recently Archived/i),
|
||||
).toBeInTheDocument();
|
||||
expect(within(dialog).getByText(/recover it there/i)).toBeInTheDocument();
|
||||
|
||||
expect(screen.queryByTestId('delete-modal-input')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
|
||||
|
||||
@@ -1157,6 +1157,34 @@ test('dataset links use internal routing when PREVENT_UNSAFE_DEFAULT_URLS_ON_DAT
|
||||
});
|
||||
});
|
||||
|
||||
test('legacy dashboard default URLs use the registered client route', async () => {
|
||||
const dataset = {
|
||||
...mockDatasets[0],
|
||||
explore_url: '/superset/dashboard/123/?standalone=1#section',
|
||||
};
|
||||
mockDatasetListEndpoints({ result: [dataset], count: 1 });
|
||||
|
||||
renderDatasetList(
|
||||
mockAdminUser,
|
||||
{},
|
||||
{
|
||||
common: {
|
||||
conf: {
|
||||
PREVENT_UNSAFE_DEFAULT_URLS_ON_DATASET: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const datasetLink = await screen.findByRole('link', {
|
||||
name: dataset.table_name,
|
||||
});
|
||||
expect(datasetLink).toHaveAttribute(
|
||||
'href',
|
||||
'/dashboard/123/?standalone=1#section',
|
||||
);
|
||||
});
|
||||
|
||||
// Note: These delete error tests verify that the modal doesn't open when fetching
|
||||
// related_objects fails. The component's openDatasetDeleteModal error handler
|
||||
// (index.tsx:262-268) returns a string but doesn't call addDangerToast(), so no
|
||||
|
||||
@@ -54,10 +54,18 @@ import {
|
||||
|
||||
const APP_ROOT = '/superset';
|
||||
|
||||
const renderUnderSubdirectory = () => {
|
||||
const renderUnderSubdirectory = (preventUnsafeDefaultUrls = false) => {
|
||||
const defaultState = createDefaultStoreState(mockAdminUser);
|
||||
const store = createMockStore({
|
||||
...createDefaultStoreState(mockAdminUser),
|
||||
...defaultState,
|
||||
user: mockAdminUser,
|
||||
common: {
|
||||
...defaultState.common,
|
||||
conf: {
|
||||
...defaultState.common?.conf,
|
||||
PREVENT_UNSAFE_DEFAULT_URLS_ON_DATASET: preventUnsafeDefaultUrls,
|
||||
},
|
||||
},
|
||||
});
|
||||
return render(
|
||||
<Provider store={store}>
|
||||
@@ -115,6 +123,31 @@ test('explore link is single-prefixed under a subdirectory deployment', async ()
|
||||
expect(exploreLink.getAttribute('href')).not.toContain('/superset/superset');
|
||||
});
|
||||
|
||||
test('legacy dashboard default URL uses the router basename once', async () => {
|
||||
// A subdirectory user pastes the full browser path, so the saved value
|
||||
// carries both the application root and the legacy `/superset` prefix.
|
||||
// stripAppRoot removes the root and the legacy normalization removes the
|
||||
// prefix, leaving the basename to re-add the root exactly once.
|
||||
const dataset = {
|
||||
...mockDatasets[0],
|
||||
explore_url: `${APP_ROOT}/superset/dashboard/123/?standalone=1#section`,
|
||||
};
|
||||
mockDatasetListEndpoints({ result: [dataset], count: 1 });
|
||||
|
||||
renderUnderSubdirectory(true);
|
||||
|
||||
const dashboardLink = await screen.findByRole('link', {
|
||||
name: dataset.table_name,
|
||||
});
|
||||
expect(dashboardLink).toHaveAttribute(
|
||||
'href',
|
||||
`${APP_ROOT}/dashboard/123/?standalone=1#section`,
|
||||
);
|
||||
expect(dashboardLink.getAttribute('href')).not.toContain(
|
||||
'/superset/superset',
|
||||
);
|
||||
});
|
||||
|
||||
test('external default_endpoint passes through unprefixed', async () => {
|
||||
const dataset = {
|
||||
...mockDatasets[0],
|
||||
|
||||
@@ -87,7 +87,6 @@ import withToasts from 'src/components/MessageToasts/withToasts';
|
||||
import { Icons } from '@superset-ui/core/components/Icons';
|
||||
import WarningIconWithTooltip from '@superset-ui/core/components/WarningIconWithTooltip';
|
||||
import { isUserEditorOrAdmin } from 'src/dashboard/util/permissionUtils';
|
||||
|
||||
import {
|
||||
PAGE_SIZE,
|
||||
SORT_BY,
|
||||
@@ -114,6 +113,10 @@ import type {
|
||||
} from 'src/types/bootstrapTypes';
|
||||
import type User from 'src/types/User';
|
||||
|
||||
// Keep saved Default URLs compatible with the prefix-free SPA route.
|
||||
const normalizeLegacyDashboardUrl = (url: string) =>
|
||||
url.replace(/^\/superset(?=\/dashboard(?:\/|$))/, '');
|
||||
|
||||
const SEMANTIC_LAYERS_FLAG = 'SEMANTIC_LAYERS' as FeatureFlag;
|
||||
type DatasetExtra = {
|
||||
certification?: {
|
||||
@@ -722,7 +725,9 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
|
||||
// Router basename, which re-prefixes the root — so strip it here to
|
||||
// avoid a doubled `/superset/superset/...`. External
|
||||
// `default_endpoint` URLs pass through unchanged.
|
||||
const exploreTo = stripAppRoot(exploreURL);
|
||||
const exploreTo = normalizeLegacyDashboardUrl(
|
||||
stripAppRoot(exploreURL),
|
||||
);
|
||||
let titleLink: JSX.Element;
|
||||
if (PREVENT_UNSAFE_DEFAULT_URLS_ON_DATASET) {
|
||||
titleLink = (
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
* under the License.
|
||||
*/
|
||||
import thunk from 'redux-thunk';
|
||||
import configureStore from 'redux-mock-store';
|
||||
import configureStore, { MockStoreEnhanced } from 'redux-mock-store';
|
||||
import fetchMock from 'fetch-mock';
|
||||
import {
|
||||
render,
|
||||
@@ -29,6 +29,7 @@ import { MemoryRouter, useLocation } from 'react-router-dom';
|
||||
import { QueryParamProvider } from 'use-query-params';
|
||||
import { ReactRouter5Adapter } from 'use-query-params/adapters/react-router-5';
|
||||
import * as getBootstrapData from 'src/utils/getBootstrapData';
|
||||
import { ADD_TOAST } from 'src/components/MessageToasts/actions';
|
||||
import SavedQueryList from '.';
|
||||
|
||||
// Renders the current router pathname+search so tests can assert navigation.
|
||||
@@ -92,8 +93,15 @@ fetchMock.post(permalinkEndpoint, {
|
||||
|
||||
fetchMock.delete(queryEndpoint, {}, { name: queryEndpoint });
|
||||
|
||||
const renderList = (props = {}, storeOverrides = {}) =>
|
||||
render(
|
||||
const renderList = (props = {}, storeOverrides = {}) => {
|
||||
const store = configureStore([thunk])({
|
||||
user: {
|
||||
...mockUser,
|
||||
roles: { Admin: [['can_write', 'SavedQuery']] },
|
||||
},
|
||||
...storeOverrides,
|
||||
});
|
||||
const utils = render(
|
||||
<MemoryRouter>
|
||||
<QueryParamProvider adapter={ReactRouter5Adapter}>
|
||||
<SavedQueryList user={mockUser} {...props} />
|
||||
@@ -102,15 +110,19 @@ const renderList = (props = {}, storeOverrides = {}) =>
|
||||
</MemoryRouter>,
|
||||
{
|
||||
useRedux: true,
|
||||
store: configureStore([thunk])({
|
||||
user: {
|
||||
...mockUser,
|
||||
roles: { Admin: [['can_write', 'SavedQuery']] },
|
||||
},
|
||||
...storeOverrides,
|
||||
}),
|
||||
store,
|
||||
},
|
||||
);
|
||||
return { ...utils, store };
|
||||
};
|
||||
|
||||
// Finds any dispatched toast action whose text matches, regardless of
|
||||
// toast type -- the regression this guards against could resurface the
|
||||
// copy confirmation as any toast variant, not just a success toast.
|
||||
const findToastAction = (store: MockStoreEnhanced<unknown>, text: string) =>
|
||||
store
|
||||
.getActions()
|
||||
.find(action => action.type === ADD_TOAST && action.payload?.text === text);
|
||||
|
||||
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
|
||||
describe('SavedQueryList', () => {
|
||||
@@ -287,4 +299,113 @@ describe('SavedQueryList', () => {
|
||||
applicationRootSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
test('opens a saved query in SQL Lab without copying a link', async () => {
|
||||
// A prior test in this suite permanently swaps this route to read-only
|
||||
// permissions, which would hide the edit action this test depends on.
|
||||
fetchMock.removeRoute(queriesInfoEndpoint);
|
||||
fetchMock.get(
|
||||
queriesInfoEndpoint,
|
||||
{ permissions: ['can_write', 'can_read', 'can_export'] },
|
||||
{ name: queriesInfoEndpoint },
|
||||
);
|
||||
|
||||
const clipboardCallback = jest.fn();
|
||||
const originalClipboard = { ...global.navigator.clipboard };
|
||||
// @ts-expect-error -- overriding a read-only browser API for the test
|
||||
global.navigator.clipboard = {
|
||||
write: clipboardCallback,
|
||||
writeText: clipboardCallback,
|
||||
};
|
||||
|
||||
try {
|
||||
const { store } = renderList();
|
||||
await screen.findByTestId('saved_query-list-view');
|
||||
|
||||
const editButtons = await screen.findAllByTestId('edit-action');
|
||||
fireEvent.click(editButtons[0]);
|
||||
|
||||
await waitFor(() => {
|
||||
const location = screen.getByTestId('location-display').textContent;
|
||||
expect(location).toMatch(/^\/sqllab\?savedQueryId=\d+$/);
|
||||
});
|
||||
|
||||
expect(clipboardCallback).not.toHaveBeenCalled();
|
||||
expect(findToastAction(store, 'Link Copied!')).toBeUndefined();
|
||||
} finally {
|
||||
// @ts-expect-error -- restoring the read-only browser API after the test
|
||||
global.navigator.clipboard = originalClipboard;
|
||||
}
|
||||
});
|
||||
|
||||
test('opens a saved query from the preview modal without copying a link', async () => {
|
||||
const savedQueryDetailEndpoint = /\/api\/v1\/saved_query\/\d+$/;
|
||||
fetchMock.get(
|
||||
savedQueryDetailEndpoint,
|
||||
{ result: mockQueries[0] },
|
||||
{ name: 'saved-query-detail' },
|
||||
);
|
||||
|
||||
const clipboardCallback = jest.fn();
|
||||
const originalClipboard = { ...global.navigator.clipboard };
|
||||
// @ts-expect-error -- overriding a read-only browser API for the test
|
||||
global.navigator.clipboard = {
|
||||
write: clipboardCallback,
|
||||
writeText: clipboardCallback,
|
||||
};
|
||||
|
||||
try {
|
||||
const { store } = renderList();
|
||||
await screen.findByTestId('saved_query-list-view');
|
||||
|
||||
const previewButtons = await screen.findAllByTestId('preview-action');
|
||||
fireEvent.click(previewButtons[0]);
|
||||
|
||||
const openInSqlLabButton = await screen.findByTestId('open-in-sql-lab');
|
||||
fireEvent.click(openInSqlLabButton);
|
||||
|
||||
await waitFor(() => {
|
||||
const location = screen.getByTestId('location-display').textContent;
|
||||
expect(location).toMatch(/^\/sqllab\?savedQueryId=\d+$/);
|
||||
});
|
||||
|
||||
expect(clipboardCallback).not.toHaveBeenCalled();
|
||||
expect(findToastAction(store, 'Link Copied!')).toBeUndefined();
|
||||
} finally {
|
||||
// @ts-expect-error -- restoring the read-only browser API after the test
|
||||
global.navigator.clipboard = originalClipboard;
|
||||
fetchMock.removeRoute('saved-query-detail');
|
||||
}
|
||||
});
|
||||
|
||||
test('copies a permalink to the clipboard when using the copy action', async () => {
|
||||
const clipboardCallback = jest.fn();
|
||||
const originalClipboard = { ...global.navigator.clipboard };
|
||||
// @ts-expect-error -- overriding a read-only browser API for the test
|
||||
global.navigator.clipboard = {
|
||||
write: clipboardCallback,
|
||||
writeText: clipboardCallback,
|
||||
};
|
||||
|
||||
try {
|
||||
const { store } = renderList();
|
||||
await screen.findByTestId('saved_query-list-view');
|
||||
|
||||
const copyButtons = await screen.findAllByTestId('copy-action');
|
||||
fireEvent.click(copyButtons[0]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(clipboardCallback).toHaveBeenCalledWith(
|
||||
'http://localhost/permalink',
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(findToastAction(store, 'Link Copied!')).toBeDefined();
|
||||
});
|
||||
} finally {
|
||||
// @ts-expect-error -- restoring the read-only browser API after the test
|
||||
global.navigator.clipboard = originalClipboard;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -61,12 +61,11 @@ import { QueryObjectColumns, SavedQueryObject } from 'src/views/CRUD/types';
|
||||
import { TagTypeEnum } from 'src/components/Tag/TagType';
|
||||
import { loadTags } from 'src/components/Tag/utils';
|
||||
import { Icons } from '@superset-ui/core/components/Icons';
|
||||
import copyTextToClipboard from 'src/utils/copy';
|
||||
import type User from 'src/types/User';
|
||||
import { UserWithPermissionsAndRoles } from 'src/types/bootstrapTypes';
|
||||
import SavedQueryPreviewModal from 'src/features/queries/SavedQueryPreviewModal';
|
||||
import { findPermission } from 'src/utils/findPermission';
|
||||
import { getShareableUrl, openInNewTab } from 'src/utils/navigationUtils';
|
||||
import { openInNewTab } from 'src/utils/navigationUtils';
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
const PASSWORDS_NEEDED_MESSAGE = t(
|
||||
@@ -245,13 +244,6 @@ function SavedQueryList({
|
||||
// Action methods
|
||||
const openInSqlLab = (id: number, openInNewWindow: boolean) => {
|
||||
const path = `/sqllab?savedQueryId=${id}`;
|
||||
copyTextToClipboard(() => Promise.resolve(getShareableUrl(path)))
|
||||
.then(() => {
|
||||
addSuccessToast(t('Link Copied!'));
|
||||
})
|
||||
.catch(() => {
|
||||
addDangerToast(t('Sorry, your browser does not support copying.'));
|
||||
});
|
||||
if (openInNewWindow) {
|
||||
openInNewTab(path);
|
||||
} else {
|
||||
@@ -263,6 +255,7 @@ function SavedQueryList({
|
||||
|
||||
const copyQueryLink = useCallback(
|
||||
async (savedQuery: SavedQueryObject) => {
|
||||
let permalink: string;
|
||||
try {
|
||||
const payload = {
|
||||
dbId: savedQuery.db_id,
|
||||
@@ -280,12 +273,19 @@ function SavedQueryList({
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
const { url: permalink } = response.json;
|
||||
({ url: permalink } = response.json);
|
||||
} catch (error) {
|
||||
addDangerToast(t('There was an error generating the permalink.'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(permalink);
|
||||
addSuccessToast(t('Link Copied!'));
|
||||
} catch (error) {
|
||||
addDangerToast(t('There was an error generating the permalink.'));
|
||||
addDangerToast(
|
||||
t('The link was generated but could not be copied: %s', permalink),
|
||||
);
|
||||
}
|
||||
},
|
||||
[addDangerToast, addSuccessToast],
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
getSSHPrivateKeyPasswordsNeeded,
|
||||
hasTerminalValidation,
|
||||
isAlreadyExists,
|
||||
isNavigationHandledByLink,
|
||||
isNeedsEncryptedExtraField,
|
||||
isNeedsPassword,
|
||||
isNeedsSSHPassword,
|
||||
@@ -259,6 +260,37 @@ const encryptedExtraFieldNoLabelErrors = {
|
||||
],
|
||||
};
|
||||
|
||||
test('identifies clicks a link has already navigated', () => {
|
||||
document.body.innerHTML = `
|
||||
<div id="card">
|
||||
<a id="cover" href="/explore/?slice_id=1"><img id="thumbnail" alt="" /></a>
|
||||
<span id="title">Chart</span>
|
||||
<a id="anchorWithoutHref"><span id="inertLabel">Label</span></a>
|
||||
</div>
|
||||
`;
|
||||
const target = (id: string) => ({ target: document.getElementById(id) });
|
||||
|
||||
// the link itself and anything nested inside it
|
||||
expect(isNavigationHandledByLink(target('cover'))).toBe(true);
|
||||
expect(isNavigationHandledByLink(target('thumbnail'))).toBe(true);
|
||||
|
||||
// the rest of the card still navigates through its own click handler
|
||||
expect(isNavigationHandledByLink(target('title'))).toBe(false);
|
||||
expect(isNavigationHandledByLink(target('card'))).toBe(false);
|
||||
|
||||
// an anchor with no href does not navigate, so it must not suppress the card
|
||||
expect(isNavigationHandledByLink(target('anchorWithoutHref'))).toBe(false);
|
||||
expect(isNavigationHandledByLink(target('inertLabel'))).toBe(false);
|
||||
|
||||
// targets that are not elements
|
||||
expect(isNavigationHandledByLink({ target: null })).toBe(false);
|
||||
expect(
|
||||
isNavigationHandledByLink({ target: document.createTextNode('text') }),
|
||||
).toBe(false);
|
||||
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
test('identifies error payloads indicating that password is needed', () => {
|
||||
let needsPassword;
|
||||
|
||||
|
||||
@@ -483,6 +483,18 @@ export const CardStyles = styled.div`
|
||||
}
|
||||
`;
|
||||
|
||||
/**
|
||||
* Cards make their whole surface clickable, but `ListViewCard` also renders its
|
||||
* cover as a router `<Link>`. A click on the cover is therefore handled twice —
|
||||
* once by the link and once by the card wrapper — pushing two identical history
|
||||
* entries for a single click, so the Back button only pops the duplicate and
|
||||
* leaves the user on the page they tried to leave. Let the link win in that case.
|
||||
*/
|
||||
export const isNavigationHandledByLink = (event: {
|
||||
target: EventTarget | null;
|
||||
}): boolean =>
|
||||
Boolean((event.target as HTMLElement | null)?.closest?.('a[href]'));
|
||||
|
||||
export /* eslint-disable no-underscore-dangle */
|
||||
const isNeedsPassword = (payload: any) =>
|
||||
typeof payload === 'object' &&
|
||||
|
||||
Generated
+4
-4
@@ -28,7 +28,7 @@
|
||||
"@typescript-eslint/parser": "^8.67.0",
|
||||
"eslint": "^10.8.1",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"globals": "^17.9.0",
|
||||
"globals": "^17.11.0",
|
||||
"oxfmt": "^0.63.0",
|
||||
"tscw-config": "^1.1.2",
|
||||
"typescript": "^6.0.3",
|
||||
@@ -2053,9 +2053,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/globals": {
|
||||
"version": "17.9.0",
|
||||
"resolved": "https://registry.npmjs.org/globals/-/globals-17.9.0.tgz",
|
||||
"integrity": "sha512-m/MvAW61QVU5VDNF1Vj8axt016h8w7L5TU1e9zlab7XIttAT2YAlCwl75K1fOqvMM9apmD7lbCIRhpfkhmxhCg==",
|
||||
"version": "17.11.0",
|
||||
"resolved": "https://registry.npmjs.org/globals/-/globals-17.11.0.tgz",
|
||||
"integrity": "sha512-Z2I8hM+PbJDXQDq3Icgpzv+mPdwr68iZUU9d5WW4FuXfDUQfkZaZuvjMv42/5crNyw154+9+VWXbYrUgDXbxNw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
"@typescript-eslint/parser": "^8.67.0",
|
||||
"eslint": "^10.8.1",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"globals": "^17.9.0",
|
||||
"globals": "^17.11.0",
|
||||
"oxfmt": "^0.63.0",
|
||||
"tscw-config": "^1.1.2",
|
||||
"typescript": "^6.0.3",
|
||||
|
||||
@@ -238,24 +238,17 @@ class AsyncQueryManager:
|
||||
secret so the value is unguessable to outside callers.
|
||||
"""
|
||||
token = guest_user.guest_token
|
||||
# ``iat`` uniquely identifies a guest token issuance, so it provides
|
||||
# per-token isolation while remaining stable across the lifetime of a
|
||||
# single embedded session.
|
||||
message = json.dumps(
|
||||
{
|
||||
"user": token.get("user"),
|
||||
"resources": token.get("resources"),
|
||||
"iat": token.get("iat"),
|
||||
"exp": token.get("exp"),
|
||||
"aud": token.get("aud"),
|
||||
# ``datasets`` and ``rev`` are optional scope claims, so tokens
|
||||
# that differ only in their dataset allowlist or revocation
|
||||
# version still derive distinct channels.
|
||||
"datasets": token.get("datasets"),
|
||||
"rev": token.get("rev"),
|
||||
},
|
||||
sort_keys=True,
|
||||
).encode("utf-8")
|
||||
# HMAC over the complete claim set so that tokens differing in *any*
|
||||
# claim derive distinct channels. Enumerating claims here is unsafe:
|
||||
# omitting one that scopes the session -- most importantly
|
||||
# ``rls_rules``, the primary tenant-isolation mechanism for embedded
|
||||
# dashboards -- would let two tenants' tokens minted in the same
|
||||
# second with identical user/resources collide on one channel,
|
||||
# exposing job events (including error strings) and cross-tenant
|
||||
# cancellation. ``iat`` uniquely identifies a token issuance, so it
|
||||
# provides per-token isolation while remaining stable across the
|
||||
# lifetime of a single embedded session.
|
||||
message = json.dumps(token, sort_keys=True).encode("utf-8")
|
||||
digest = hmac.new(
|
||||
self._jwt_secret.encode("utf-8"), message, hashlib.sha256
|
||||
).hexdigest()
|
||||
|
||||
@@ -413,6 +413,12 @@ class ChartDataRestApi(ChartRestApi):
|
||||
# for async queries with jinja context
|
||||
set_form_data(cached_data)
|
||||
query_context = self._create_query_context_from_form(cached_data)
|
||||
# Mark as a cache replay so _sql_filters_modified skips the
|
||||
# SQL-extras check. The original request already passed the
|
||||
# full security check, cache keys are opaque SHA-256 hashes
|
||||
# (unguessable), and force_cached only serves pre-computed
|
||||
# data — no new SQL is executed.
|
||||
query_context._from_cache_replay = True
|
||||
command = ChartDataCommand(query_context)
|
||||
command.validate()
|
||||
except ChartDataCacheLoadError:
|
||||
|
||||
+43
-11
@@ -17,12 +17,19 @@
|
||||
# pylint: disable=too-many-lines
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
from typing import Any, TYPE_CHECKING
|
||||
|
||||
from flask import current_app
|
||||
from flask_babel import gettext as _
|
||||
from marshmallow import EXCLUDE, fields, post_load, Schema, validate
|
||||
from marshmallow import (
|
||||
EXCLUDE,
|
||||
fields,
|
||||
post_load,
|
||||
Schema,
|
||||
validate,
|
||||
validates,
|
||||
ValidationError,
|
||||
)
|
||||
from marshmallow.validate import Length, Range
|
||||
from marshmallow_union import Union
|
||||
|
||||
@@ -35,6 +42,7 @@ from superset.utils import pandas_postprocessing, schema as utils
|
||||
from superset.utils.core import (
|
||||
AnnotationType,
|
||||
DatasourceType,
|
||||
EXTENDED_METRIC_AGGREGATES,
|
||||
FilterOperator,
|
||||
PostProcessingBoxplotWhiskerType,
|
||||
PostProcessingContributionOrientation,
|
||||
@@ -427,7 +435,15 @@ class ChartDataAdhocMetricSchema(Schema):
|
||||
"Only required for simple expression types."
|
||||
},
|
||||
validate=validate.OneOf(
|
||||
choices=("AVG", "COUNT", "COUNT_DISTINCT", "MAX", "MIN", "SUM")
|
||||
choices=(
|
||||
"AVG",
|
||||
"COUNT",
|
||||
"COUNT_DISTINCT",
|
||||
"MAX",
|
||||
"MIN",
|
||||
"SUM",
|
||||
*sorted(EXTENDED_METRIC_AGGREGATES),
|
||||
)
|
||||
),
|
||||
)
|
||||
column = fields.Nested(ChartDataColumnSchema)
|
||||
@@ -972,21 +988,37 @@ class ChartDataGeodeticParseOptionsSchema(
|
||||
|
||||
|
||||
class ChartDataPostProcessingOperationSchema(Schema):
|
||||
_builtin_ops = pandas_postprocessing.__all__
|
||||
|
||||
operation = fields.String(
|
||||
metadata={
|
||||
"description": "Post processing operation type",
|
||||
"example": "aggregate",
|
||||
},
|
||||
required=True,
|
||||
validate=validate.OneOf(
|
||||
choices=[
|
||||
name
|
||||
for name, value in inspect.getmembers(
|
||||
pandas_postprocessing, inspect.isfunction
|
||||
)
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
@validates("operation")
|
||||
def validate_operation(self, value: str, **kwargs: object) -> None:
|
||||
# Built-in operations validate without reading the config, so schemas can
|
||||
# still be loaded outside of an app context.
|
||||
if value in self._builtin_ops:
|
||||
return
|
||||
|
||||
try:
|
||||
extra = current_app.config.get("EXTRA_PANDAS_POSTPROCESSING_OPS", [])
|
||||
except RuntimeError:
|
||||
# Outside app context, only built-in operations are known
|
||||
extra = []
|
||||
|
||||
allowed = set(self._builtin_ops) | set(
|
||||
pandas_postprocessing.build_extra_ops_map(extra)
|
||||
)
|
||||
if value not in allowed:
|
||||
raise ValidationError(
|
||||
f"Must be one of: {sorted(allowed)!r}.",
|
||||
)
|
||||
|
||||
options = fields.Dict(
|
||||
metadata={
|
||||
"description": "Options specifying how to perform the operation. Please "
|
||||
|
||||
@@ -280,9 +280,6 @@ def test_sqlalchemy_dialect(
|
||||
"""
|
||||
Test the SQLAlchemy dialect, making sure it supports everything Superset needs.
|
||||
"""
|
||||
if "future" not in engine_kwargs:
|
||||
engine_kwargs["future"] = True
|
||||
|
||||
engine = create_engine(sqlalchemy_uri, **engine_kwargs)
|
||||
dialect = engine.dialect
|
||||
|
||||
|
||||
@@ -227,7 +227,7 @@ class BaseStreamingCSVExportCommand(BaseCommand):
|
||||
delimiter = csv_export_config.get("sep", ",")
|
||||
decimal_separator = csv_export_config.get("decimal", ".")
|
||||
|
||||
with db.session(future=True) as session:
|
||||
with db.session() as session:
|
||||
# Merge database to prevent DetachedInstanceError
|
||||
merged_database = session.merge(database)
|
||||
|
||||
|
||||
@@ -291,19 +291,30 @@ class QueryContextFactory: # pylint: disable=too-few-public-methods
|
||||
),
|
||||
None,
|
||||
)
|
||||
# Replaces x-axis column values with granularity
|
||||
# Point the x-axis at the overridden Time Column (granularity).
|
||||
if x_axis_column:
|
||||
if isinstance(x_axis_column, dict):
|
||||
# Only swap the underlying expression, keeping the
|
||||
# column's original label. The temporal offset join
|
||||
# (``processing_time_offsets``), the post-processing
|
||||
# pivot ``index`` and the frontend all reference this
|
||||
# column by its label; renaming it to the granularity
|
||||
# here desynchronizes those consumers from the label
|
||||
# the saved chart still advertises, which — with a Time
|
||||
# Comparison offset — collapses the series into a single
|
||||
# point.
|
||||
x_axis_column["sqlExpression"] = granularity
|
||||
x_axis_column["label"] = granularity
|
||||
else:
|
||||
# A bare string x-axis has no distinct label, so it is
|
||||
# replaced wholesale and the pivot ``index`` must be
|
||||
# realigned to the overridden column.
|
||||
query_object.columns = [
|
||||
granularity if column == x_axis_column else column
|
||||
for column in query_object.columns
|
||||
]
|
||||
for post_processing in query_object.post_processing:
|
||||
if post_processing.get("operation") == "pivot":
|
||||
post_processing["options"]["index"] = [granularity]
|
||||
for post_processing in query_object.post_processing:
|
||||
if post_processing.get("operation") == "pivot":
|
||||
post_processing["options"]["index"] = [granularity]
|
||||
|
||||
# If no temporal x-axis, then get the default temporal filter
|
||||
if not filter_to_remove:
|
||||
|
||||
+106
-10
@@ -17,11 +17,13 @@
|
||||
# pylint: disable=invalid-name
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from pprint import pformat
|
||||
from typing import Any, NamedTuple, TYPE_CHECKING
|
||||
|
||||
from flask import current_app
|
||||
from flask_babel import gettext as _
|
||||
from jinja2.exceptions import TemplateError
|
||||
from pandas import DataFrame
|
||||
@@ -205,8 +207,93 @@ class QueryObject: # pylint: disable=too-many-instance-attributes
|
||||
def _set_post_processing(
|
||||
self, post_processing: list[dict[str, Any] | None] | None
|
||||
) -> None:
|
||||
post_processing = post_processing or []
|
||||
self.post_processing = [post_proc for post_proc in post_processing if post_proc]
|
||||
self.post_processing = [
|
||||
self._drop_unsupported_options(post_proc)
|
||||
for post_proc in post_processing or []
|
||||
if post_proc
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def _drop_unsupported_options(post_proc: dict[str, Any]) -> dict[str, Any]:
|
||||
"""
|
||||
Drop options that the post-processing operation no longer accepts.
|
||||
|
||||
A chart's ``query_context`` is written when the chart is saved and is
|
||||
never rewritten afterwards, while Explore rebuilds the query from
|
||||
``form_data`` at every render. A chart saved by an older version of
|
||||
Superset can therefore reference an option that has since been removed
|
||||
from the operation. ``exec_post_processing`` passes the stored options
|
||||
as keyword arguments, so that option raises a bare ``TypeError`` on
|
||||
every path that replays the stored ``query_context`` -- the chart data
|
||||
endpoint, alerts and reports, thumbnails, CSV export -- while the same
|
||||
chart still renders correctly in Explore.
|
||||
|
||||
Comparing against the signature avoids a hard-coded list of removed
|
||||
option names, which would need extending at each release.
|
||||
|
||||
Only the built-in operations in ``pandas_postprocessing.__all__`` are
|
||||
inspected. The module also exposes helpers, imported submodules and
|
||||
typing aliases, none of which are operations; and options belonging to a
|
||||
callable registered through ``EXTRA_PANDAS_POSTPROCESSING_OPS`` are the
|
||||
operator's to manage, so both are passed through untouched.
|
||||
"""
|
||||
operation = post_proc.get("operation")
|
||||
function = (
|
||||
getattr(pandas_postprocessing, operation, None)
|
||||
if isinstance(operation, str) and operation in pandas_postprocessing.__all__
|
||||
else None
|
||||
)
|
||||
if function is None:
|
||||
# A missing, unknown or operator-registered operation is left
|
||||
# untouched, so that exec_post_processing either dispatches it or
|
||||
# reports it as InvalidPostProcessingError.
|
||||
return post_proc
|
||||
|
||||
parameters = inspect.signature(function).parameters
|
||||
if any(
|
||||
parameter.kind is inspect.Parameter.VAR_KEYWORD
|
||||
for parameter in parameters.values()
|
||||
):
|
||||
return post_proc
|
||||
|
||||
# `exec_post_processing` calls the operation as `operation(df, **options)`,
|
||||
# so an option can only reach a parameter that a caller may fill by
|
||||
# keyword. That excludes the first parameter, which receives the
|
||||
# DataFrame positionally, and any positional-only or `*args` parameter.
|
||||
keyword_parameters = {
|
||||
name
|
||||
for position, (name, parameter) in enumerate(parameters.items())
|
||||
if position > 0
|
||||
and parameter.kind
|
||||
in (
|
||||
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
||||
inspect.Parameter.KEYWORD_ONLY,
|
||||
)
|
||||
}
|
||||
|
||||
options = post_proc.get("options") or {}
|
||||
unsupported = {key for key in options if key not in keyword_parameters}
|
||||
if not unsupported:
|
||||
return post_proc
|
||||
|
||||
# Logged at info: a chart saved before the option was removed hits this
|
||||
# on every render, so a warning would repeat for as long as the chart
|
||||
# is not resaved, without anything new to report.
|
||||
logger.info(
|
||||
"Dropping unsupported option(s) %s of post-processing operation "
|
||||
"`%s`. The chart's stored query_context predates the current "
|
||||
"signature of that operation.",
|
||||
sorted(unsupported),
|
||||
operation,
|
||||
)
|
||||
return {
|
||||
**post_proc,
|
||||
"options": {
|
||||
key: value
|
||||
for key, value in options.items()
|
||||
if key in keyword_parameters
|
||||
},
|
||||
}
|
||||
|
||||
def _init_series_columns(
|
||||
self,
|
||||
@@ -544,13 +631,22 @@ class QueryObject: # pylint: disable=too-many-instance-attributes
|
||||
raise InvalidPostProcessingError(
|
||||
_("`operation` property of post processing object undefined")
|
||||
)
|
||||
if not hasattr(pandas_postprocessing, operation):
|
||||
raise InvalidPostProcessingError(
|
||||
_(
|
||||
"Unsupported post processing operation: %(operation)s",
|
||||
type=operation,
|
||||
)
|
||||
# ``__all__`` is the authoritative list of built-in operations.
|
||||
# ``hasattr`` would also match module internals (helpers, imported
|
||||
# submodules, typing aliases), shadowing a like-named custom op.
|
||||
if operation in pandas_postprocessing.__all__:
|
||||
func = getattr(pandas_postprocessing, operation)
|
||||
else:
|
||||
extra_ops = pandas_postprocessing.build_extra_ops_map(
|
||||
current_app.config.get("EXTRA_PANDAS_POSTPROCESSING_OPS", [])
|
||||
)
|
||||
options = post_process.get("options", {})
|
||||
df = getattr(pandas_postprocessing, operation)(df, **options)
|
||||
if operation not in extra_ops:
|
||||
raise InvalidPostProcessingError(
|
||||
_(
|
||||
"Unsupported post processing operation: %(operation)s",
|
||||
operation=operation,
|
||||
)
|
||||
)
|
||||
func = extra_ops[operation]
|
||||
df = func(df, **post_process.get("options", {}))
|
||||
return df
|
||||
|
||||
@@ -358,6 +358,17 @@ SQLALCHEMY_ENCRYPTED_FIELD_ENGINE: Literal["aes", "aes-gcm"] = "aes"
|
||||
# Extends the default SQLGlot dialects with additional dialects
|
||||
SQLGLOT_DIALECTS_EXTENSIONS: DialectExtensions | Callable[[], DialectExtensions] = {}
|
||||
|
||||
# Extra pandas post-processing operations to register alongside the built-in ones.
|
||||
# Each entry must be a named callable (i.e. have a __name__ attribute) with the
|
||||
# signature:
|
||||
# def my_op(df: pandas.DataFrame, **options: Any) -> pandas.DataFrame
|
||||
# The function is registered under its __name__ as the operation name. Callables
|
||||
# without __name__ (e.g. functools.partial, lambda) are silently ignored.
|
||||
# Example:
|
||||
# from mypackage.ops import my_custom_op
|
||||
# EXTRA_PANDAS_POSTPROCESSING_OPS = [my_custom_op]
|
||||
EXTRA_PANDAS_POSTPROCESSING_OPS: list[Callable[..., Any]] = []
|
||||
|
||||
# The limit of queries fetched for query search
|
||||
QUERY_SEARCH_LIMIT = 1000
|
||||
|
||||
|
||||
@@ -957,7 +957,13 @@ class AnnotationDatasource(BaseDatasource):
|
||||
def get_query_str(self, query_obj: QueryObjectDict) -> str:
|
||||
raise NotImplementedError()
|
||||
|
||||
def values_for_column(self, column_name: str, limit: int = 10000) -> list[Any]:
|
||||
def values_for_column(
|
||||
self,
|
||||
column_name: str,
|
||||
limit: int = 10000,
|
||||
denormalize_column: bool = False,
|
||||
array_elements: bool = False,
|
||||
) -> list[Any]:
|
||||
raise NotImplementedError()
|
||||
|
||||
|
||||
@@ -1892,11 +1898,6 @@ class SqlaTable(
|
||||
|
||||
if expression_type == utils.AdhocMetricExpressionType.SIMPLE:
|
||||
aggregate: Any = metric.get("aggregate")
|
||||
if (
|
||||
not isinstance(aggregate, str)
|
||||
or aggregate not in self.sqla_aggregations
|
||||
):
|
||||
raise QueryObjectValidationError(_("Adhoc metric aggregate is invalid"))
|
||||
metric_column = metric.get("column") or {}
|
||||
column_name = cast(str, metric_column.get("column_name"))
|
||||
table_column: TableColumn | None = columns_by_name.get(column_name)
|
||||
@@ -1906,7 +1907,27 @@ class SqlaTable(
|
||||
)
|
||||
else:
|
||||
sqla_column = column(column_name)
|
||||
sqla_metric = self.sqla_aggregations[aggregate](sqla_column)
|
||||
|
||||
if isinstance(aggregate, str) and aggregate in self.sqla_aggregations:
|
||||
sqla_metric = self.sqla_aggregations[aggregate](sqla_column)
|
||||
elif isinstance(aggregate, str) and (
|
||||
extended_func := self.db_engine_spec.get_extended_aggregation_func(
|
||||
aggregate
|
||||
)
|
||||
):
|
||||
sqla_metric = extended_func(sqla_column)
|
||||
elif (
|
||||
isinstance(aggregate, str)
|
||||
and aggregate in utils.EXTENDED_METRIC_AGGREGATES
|
||||
):
|
||||
raise QueryObjectValidationError(
|
||||
_(
|
||||
"The %(aggregate)s aggregate is not supported on this database",
|
||||
aggregate=aggregate,
|
||||
)
|
||||
)
|
||||
else:
|
||||
raise QueryObjectValidationError(_("Adhoc metric aggregate is invalid"))
|
||||
elif expression_type == utils.AdhocMetricExpressionType.SQL:
|
||||
expression: str | None = metric.get("sqlExpression")
|
||||
if not isinstance(expression, str) or not expression.strip():
|
||||
|
||||
@@ -133,6 +133,9 @@ class DatasourceRestApi(BaseSupersetApi):
|
||||
|
||||
row_limit = apply_max_row_limit(app.config["FILTER_SELECT_ROW_LIMIT"])
|
||||
denormalize_column = not datasource.normalize_columns
|
||||
# Element-level operators (Contains any / Contains all) request the
|
||||
# distinct array *elements* rather than distinct whole arrays.
|
||||
array_elements = parse_boolean_string(request.args.get("array_elements"))
|
||||
|
||||
# Cache distinct column-value results so a dashboard with many filters
|
||||
# backed by the same (often heavy) virtual dataset doesn't re-execute
|
||||
@@ -165,6 +168,7 @@ class DatasourceRestApi(BaseSupersetApi):
|
||||
"col": column_name,
|
||||
"limit": row_limit,
|
||||
"denorm": denormalize_column,
|
||||
"elements": array_elements,
|
||||
"rls": security_manager.get_rls_cache_key(datasource),
|
||||
"changed_on": str(getattr(datasource, "changed_on", "")),
|
||||
},
|
||||
@@ -189,6 +193,7 @@ class DatasourceRestApi(BaseSupersetApi):
|
||||
column_name=column_name,
|
||||
limit=row_limit,
|
||||
denormalize_column=denormalize_column,
|
||||
array_elements=array_elements,
|
||||
)
|
||||
except KeyError:
|
||||
return self.response(
|
||||
|
||||
@@ -55,7 +55,13 @@ from sqlalchemy.engine.reflection import Inspector
|
||||
from sqlalchemy.engine.url import URL
|
||||
from sqlalchemy.ext.compiler import compiles
|
||||
from sqlalchemy.sql import literal_column, quoted_name, text
|
||||
from sqlalchemy.sql.expression import BinaryExpression, ColumnClause, Select, TextClause
|
||||
from sqlalchemy.sql.expression import (
|
||||
BinaryExpression,
|
||||
ColumnClause,
|
||||
ColumnElement,
|
||||
Select,
|
||||
TextClause,
|
||||
)
|
||||
from sqlalchemy.types import TypeEngine
|
||||
|
||||
from superset import db
|
||||
@@ -528,6 +534,11 @@ class BaseEngineSpec: # pylint: disable=too-many-public-methods
|
||||
time_groupby_inline = False
|
||||
limit_method = LimitMethod.FORCE_LIMIT
|
||||
supports_multivalues_insert = False
|
||||
# Whether this engine supports first-class multi-value (array-typed) columns.
|
||||
# When True, array columns are classified as ``GenericDataType.MULTI_VALUE`` and
|
||||
# the ``array_*`` capability methods below must be implemented. Defaults to
|
||||
# False so engines that have not opted in keep treating arrays as strings.
|
||||
supports_multivalue_columns = False
|
||||
allows_joins = True
|
||||
allows_subqueries = True
|
||||
allows_alias_in_select = True
|
||||
@@ -621,6 +632,33 @@ class BaseEngineSpec: # pylint: disable=too-many-public-methods
|
||||
# issuing one query per level. Conservative default of False; engines opt in.
|
||||
supports_grouping_sets = False
|
||||
|
||||
# SQL-generating callables for metric aggregates that have no safe, universal
|
||||
# cross-dialect spelling -- unlike SUM/COUNT/AVG/MIN/MAX/COUNT_DISTINCT (see
|
||||
# `SqlaTable.sqla_aggregations`), which SQLAlchemy's generic `sa.func` can emit
|
||||
# unchanged on every engine. Keyed by `Aggregate` name (see
|
||||
# `superset-frontend/packages/superset-ui-core/src/query/types/Metric.ts`);
|
||||
# each value takes a SQLAlchemy column and returns the aggregate expression.
|
||||
# Absent by default: an aggregate not present here is unsupported on this
|
||||
# engine, and callers must surface a clear "not supported" error rather than
|
||||
# emit unverified SQL (a wrong statistic returned silently is worse than an
|
||||
# error). Engines opt in via `get_extended_aggregation_func` below once the
|
||||
# expression has been verified against real engine behavior, not assumed
|
||||
# from syntax alone -- see the MySQL engine spec for a concrete example of
|
||||
# why this distinction matters (its `VARIANCE()` computes the *population*
|
||||
# variance, not the *sample* variance `VAR_SAMP` denotes).
|
||||
_extended_aggregations: dict[str, Callable[[ColumnElement], ColumnElement]] = {}
|
||||
|
||||
@classmethod
|
||||
def get_extended_aggregation_func(
|
||||
cls, aggregate: str
|
||||
) -> Callable[[ColumnElement], ColumnElement] | None:
|
||||
"""
|
||||
SQL-generating callable for an aggregate not handled by the generic
|
||||
`sa.func` mapping (e.g. MEDIAN, STDDEV_SAMP, VAR_SAMP). Returns None if
|
||||
this engine has no verified, correct expression for it.
|
||||
"""
|
||||
return cls._extended_aggregations.get(aggregate)
|
||||
|
||||
# Is the DB engine spec able to change the default schema? This requires implementing # noqa: E501
|
||||
# a custom `adjust_engine_params` method.
|
||||
supports_dynamic_schema = False
|
||||
@@ -2571,6 +2609,105 @@ class BaseEngineSpec: # pylint: disable=too-many-public-methods
|
||||
logger.error(ex, exc_info=True)
|
||||
raise
|
||||
|
||||
@classmethod
|
||||
def array_contains_any(cls, col: ColumnElement, values: list[Any]) -> ColumnElement:
|
||||
"""
|
||||
Build a boolean expression testing whether array column ``col`` contains
|
||||
**any** of ``values`` (element-level membership, like ``IN``). Engines
|
||||
that set ``supports_multivalue_columns = True`` must override this with
|
||||
their native function (e.g. ClickHouse ``hasAny``).
|
||||
|
||||
:param col: SQLAlchemy column element for the array column
|
||||
:param values: element values to look for inside the array
|
||||
:return: a SQLAlchemy boolean expression
|
||||
"""
|
||||
raise NotImplementedError(
|
||||
f"{cls.engine} does not support multi-value (array) columns"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def array_contains_all(cls, col: ColumnElement, values: list[Any]) -> ColumnElement:
|
||||
"""
|
||||
Build a boolean expression testing whether array column ``col`` contains
|
||||
**all** of ``values``. Engines that set
|
||||
``supports_multivalue_columns = True`` must override this with their
|
||||
native function (e.g. ClickHouse ``hasAll``).
|
||||
|
||||
:param col: SQLAlchemy column element for the array column
|
||||
:param values: element values that must all be present
|
||||
:return: a SQLAlchemy boolean expression
|
||||
"""
|
||||
raise NotImplementedError(
|
||||
f"{cls.engine} does not support multi-value (array) columns"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def array_length(cls, col: ColumnElement) -> ColumnElement:
|
||||
"""
|
||||
Build a numeric expression returning the number of elements in array
|
||||
column ``col``. Engines that set ``supports_multivalue_columns = True``
|
||||
must override this with their native array-length function. Used both for
|
||||
the ``Length`` filter and the ``Is empty`` / ``Is not empty`` operators.
|
||||
|
||||
:param col: SQLAlchemy column element for the array column
|
||||
:return: a SQLAlchemy numeric expression
|
||||
"""
|
||||
raise NotImplementedError(
|
||||
f"{cls.engine} does not support multi-value (array) columns"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def array_literal(cls, values: list[Any]) -> ColumnElement:
|
||||
"""
|
||||
Build an array-literal expression from ``values`` (e.g. ClickHouse
|
||||
``array(v1, v2)`` == ``[v1, v2]``). Used for the whole-array (column-
|
||||
level) operators ``=`` / ``!=`` / ``IN`` / ``NOT IN`` where the array is
|
||||
compared as a single value. Engines that set
|
||||
``supports_multivalue_columns = True`` must override this.
|
||||
|
||||
:param values: element values that make up the array
|
||||
:return: a SQLAlchemy array-literal expression
|
||||
"""
|
||||
raise NotImplementedError(
|
||||
f"{cls.engine} does not support multi-value (array) columns"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def array_explode(cls, col: ColumnElement) -> ColumnElement:
|
||||
"""
|
||||
Build an expression that expands array column ``col`` into one row per
|
||||
element (e.g. ClickHouse ``arrayJoin``). Used to source **element-level**
|
||||
value suggestions (``SELECT DISTINCT array_explode(col)``) for the
|
||||
``Contains any`` / ``Contains all`` filter operators, so the picker offers
|
||||
individual elements rather than whole arrays. Engines that set
|
||||
``supports_multivalue_columns = True`` must override this.
|
||||
|
||||
:param col: SQLAlchemy column element for the array column
|
||||
:return: a SQLAlchemy expression yielding one element per row
|
||||
"""
|
||||
raise NotImplementedError(
|
||||
f"{cls.engine} does not support multi-value (array) columns"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_array_element_type( # pylint: disable=unused-argument
|
||||
cls, native_type: str | None
|
||||
) -> GenericDataType | None:
|
||||
"""
|
||||
Return the generic type of an array column's **element** type, derived
|
||||
from its native type string (e.g. ClickHouse ``Array(Int32)`` ->
|
||||
``NUMERIC``), or ``None`` when the engine has no array support or the
|
||||
element type cannot be resolved.
|
||||
|
||||
Callers use this to coerce filter values to the element type before
|
||||
building array expressions, so, for example, a ``Contains any`` filter on
|
||||
a numeric array compares against numbers rather than quoted strings.
|
||||
|
||||
:param native_type: native column type string of the array column
|
||||
:return: the element's :class:`GenericDataType`, or ``None``
|
||||
"""
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def get_column_spec( # pylint: disable=unused-argument
|
||||
cls,
|
||||
|
||||
@@ -26,8 +26,9 @@ from flask import current_app as app
|
||||
from flask_babel import gettext as __
|
||||
from marshmallow import fields, Schema
|
||||
from marshmallow.validate import Range
|
||||
from sqlalchemy import types
|
||||
from sqlalchemy import func, types
|
||||
from sqlalchemy.engine.url import URL
|
||||
from sqlalchemy.sql.expression import ColumnElement
|
||||
from urllib3.exceptions import NewConnectionError
|
||||
|
||||
from superset.databases.utils import make_url_safe
|
||||
@@ -55,6 +56,7 @@ class ClickHouseBaseEngineSpec(BaseEngineSpec):
|
||||
|
||||
time_groupby_inline = True
|
||||
supports_multivalues_insert = True
|
||||
supports_multivalue_columns = True
|
||||
|
||||
# ClickHouse doesn't support IS true/false syntax, use = true/false instead
|
||||
use_equality_for_boolean_filters = True
|
||||
@@ -128,12 +130,18 @@ class ClickHouseBaseEngineSpec(BaseEngineSpec):
|
||||
|
||||
column_type_mappings = (
|
||||
(
|
||||
re.compile(r".*Enum.*", re.IGNORECASE),
|
||||
# Anchor to the start so only top-level arrays match. This must be
|
||||
# ordered before the ``Enum`` entry below: ``Array(Enum8(...))`` is a
|
||||
# real array and should classify as MULTI_VALUE, not STRING. The
|
||||
# anchor also prevents over-matching nested arrays such as
|
||||
# ``Map(String, Array(String))`` or ``Tuple(Array(String))``, which
|
||||
# are not themselves array columns and must keep their own type.
|
||||
re.compile(r"^Array\(", re.IGNORECASE),
|
||||
types.String(),
|
||||
GenericDataType.STRING,
|
||||
GenericDataType.MULTI_VALUE,
|
||||
),
|
||||
(
|
||||
re.compile(r".*Array.*", re.IGNORECASE),
|
||||
re.compile(r".*Enum.*", re.IGNORECASE),
|
||||
types.String(),
|
||||
GenericDataType.STRING,
|
||||
),
|
||||
@@ -174,6 +182,56 @@ class ClickHouseBaseEngineSpec(BaseEngineSpec):
|
||||
),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def array_contains_any(cls, col: ColumnElement, values: list[Any]) -> ColumnElement:
|
||||
# ClickHouse: hasAny(arr, [v1, v2]) -> 1 if arr shares any element.
|
||||
# func.array(*values) renders as array(v1, v2) == [v1, v2].
|
||||
return func.hasAny(col, func.array(*values))
|
||||
|
||||
@classmethod
|
||||
def array_contains_all(cls, col: ColumnElement, values: list[Any]) -> ColumnElement:
|
||||
# ClickHouse: hasAll(arr, [v1, v2]) -> 1 if arr contains all elements.
|
||||
return func.hasAll(col, func.array(*values))
|
||||
|
||||
@classmethod
|
||||
def array_length(cls, col: ColumnElement) -> ColumnElement:
|
||||
# ClickHouse: length(arr) -> number of elements
|
||||
return func.length(col)
|
||||
|
||||
@classmethod
|
||||
def array_literal(cls, values: list[Any]) -> ColumnElement:
|
||||
# ClickHouse: array(v1, v2) is equivalent to the literal [v1, v2].
|
||||
return func.array(*values)
|
||||
|
||||
@classmethod
|
||||
def array_explode(cls, col: ColumnElement) -> ColumnElement:
|
||||
# ClickHouse: arrayJoin(arr) yields one row per element, so
|
||||
# SELECT DISTINCT arrayJoin(arr) returns the distinct elements.
|
||||
return func.arrayJoin(col)
|
||||
|
||||
# Matches the element type inside a top-level ``Array(...)`` column, e.g.
|
||||
# ``Array(Int32)`` -> ``Int32``, ``Array(Nullable(String))`` -> ``String``.
|
||||
_ARRAY_ELEMENT_RE = re.compile(r"^Array\((?P<inner>.+)\)$", re.IGNORECASE)
|
||||
# Element-type wrappers that don't change the underlying generic type.
|
||||
_ELEMENT_WRAPPER_RE = re.compile(
|
||||
r"^(?:Nullable|LowCardinality)\((?P<inner>.+)\)$", re.IGNORECASE
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_array_element_type(cls, native_type: str | None) -> GenericDataType | None:
|
||||
if not native_type:
|
||||
return None
|
||||
match = cls._ARRAY_ELEMENT_RE.match(native_type.strip())
|
||||
if not match:
|
||||
return None
|
||||
inner = match.group("inner").strip()
|
||||
# Peel wrappers (Nullable/LowCardinality) that don't alter the generic
|
||||
# type so the inner scalar type drives classification.
|
||||
while wrapper := cls._ELEMENT_WRAPPER_RE.match(inner):
|
||||
inner = wrapper.group("inner").strip()
|
||||
spec = cls.get_column_spec(inner)
|
||||
return spec.generic_type if spec else None
|
||||
|
||||
@classmethod
|
||||
def epoch_to_dttm(cls) -> str:
|
||||
return "{col}"
|
||||
|
||||
@@ -15,9 +15,10 @@
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
from sqlalchemy import types
|
||||
from sqlalchemy.sql.elements import ColumnElement
|
||||
|
||||
from superset.db_engine_specs.base import DatabaseCategory
|
||||
from superset.db_engine_specs.postgres import PostgresEngineSpec
|
||||
@@ -27,6 +28,12 @@ class CockroachDbEngineSpec(PostgresEngineSpec):
|
||||
engine = "cockroachdb"
|
||||
engine_name = "CockroachDB"
|
||||
|
||||
# `PostgresEngineSpec._extended_aggregations` (MEDIAN/STDDEV_SAMP/VAR_SAMP)
|
||||
# is verified against real Postgres behavior, not CockroachDB's distributed
|
||||
# query engine; disable it here until someone confirms the same expressions
|
||||
# against a live CockroachDB instance.
|
||||
_extended_aggregations: dict[str, Callable[[ColumnElement], ColumnElement]] = {}
|
||||
|
||||
metadata = {
|
||||
"description": (
|
||||
"CockroachDB is a distributed SQL database built for cloud applications."
|
||||
|
||||
@@ -17,13 +17,14 @@
|
||||
import logging
|
||||
import re
|
||||
from re import Pattern
|
||||
from typing import Any, Optional
|
||||
from typing import Any, Callable, Optional
|
||||
from urllib import parse
|
||||
|
||||
from flask_babel import gettext as __
|
||||
from sqlalchemy import Float, Integer, Numeric, String, TEXT, text, types
|
||||
from sqlalchemy.engine.reflection import Inspector
|
||||
from sqlalchemy.engine.url import URL
|
||||
from sqlalchemy.sql.elements import ColumnElement
|
||||
from sqlalchemy.sql.type_api import TypeEngine
|
||||
|
||||
from superset.db_engine_specs.base import DatabaseCategory
|
||||
@@ -121,6 +122,11 @@ class DorisEngineSpec(MySQLEngineSpec):
|
||||
# while technically supported by Doris, this generates invalid table identifiers
|
||||
supports_cross_catalog_queries = False
|
||||
|
||||
# `MySQLEngineSpec._extended_aggregations` (STDDEV_SAMP/VAR_SAMP) is verified
|
||||
# against real MySQL behavior, not Doris's OLAP query engine; disable it here
|
||||
# until someone confirms the same expressions against a live Doris instance.
|
||||
_extended_aggregations: dict[str, Callable[[ColumnElement], ColumnElement]] = {}
|
||||
|
||||
metadata = {
|
||||
"description": (
|
||||
"Apache Doris is a high-performance real-time analytical database."
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user