Compare commits

..
Author SHA1 Message Date
Joe Li d27500a08f Merge remote-tracking branch 'origin/master' into HEAD 2026-09-08 09:30:35 -07:00
Joe Li 83e3dcd932 Merge remote-tracking branch 'origin/master' into HEAD 2026-09-07 16:33:06 -07:00
Joe Li bb60298b88 Merge remote-tracking branch 'origin/master' into HEAD 2026-09-04 07:18:24 -07:00
Joe Li c10f74ba7c Merge remote-tracking branch 'origin/master' into HEAD 2026-09-03 07:06:30 -07:00
Joe Li aaf76687f9 Merge branch 'master' into showtime-master 2026-09-02 11:56:19 -07:00
Joe Li 7e51e3a0ed Merge remote-tracking branch 'origin/master' into HEAD 2026-09-02 07:20:07 -07:00
Joe Li 0b35b9ae98 Merge remote-tracking branch 'origin/master' into HEAD 2026-09-01 07:08:24 -07:00
Joe Li a211aa7b39 Merge remote-tracking branch 'origin/master' into HEAD 2026-08-31 07:08:54 -07:00
Joe Li ec11e17bdb Merge remote-tracking branch 'origin/master' into HEAD 2026-08-28 07:18:58 -07:00
Joe Li 1068e253d4 Merge remote-tracking branch 'origin/master' into HEAD 2026-08-27 07:08:51 -07:00
Joe Li 38cc25b889 Merge remote-tracking branch 'origin/master' into HEAD 2026-08-26 07:19:19 -07:00
Joe Li 8706b68971 Merge remote-tracking branch 'origin/master' into HEAD 2026-08-25 07:10:33 -07:00
Joe Li c917b3d441 Merge remote-tracking branch 'origin/master' into HEAD 2026-08-24 07:18:24 -07:00
Joe Li c033806300 Merge remote-tracking branch 'origin/master' into HEAD 2026-08-21 07:10:13 -07:00
Joe Li 744c81fb27 Merge remote-tracking branch 'origin/master' into HEAD 2026-08-20 07:06:34 -07:00
Joe Li 3e69650828 Merge remote-tracking branch 'origin/master' into HEAD 2026-08-19 07:05:20 -07:00
Joe Li 11cabd7f02 Merge remote-tracking branch 'origin/master' into HEAD 2026-08-18 07:17:42 -07:00
Joe Li e1ef4f06cc Merge remote-tracking branch 'origin/master' into HEAD 2026-08-17 07:09:31 -07:00
Joe Li 74a364dc13 Merge remote-tracking branch 'origin/master' into HEAD 2026-08-14 07:05:15 -07:00
Joe Li e4bb1bc825 Merge remote-tracking branch 'origin/master' into HEAD 2026-08-13 07:04:11 -07:00
Joe Li 05938977ab Merge remote-tracking branch 'origin/master' into HEAD 2026-08-12 07:05:21 -07:00
Joe Li 411f25859e Merge remote-tracking branch 'origin/master' into HEAD 2026-08-11 07:04:36 -07:00
Joe Li 8bd06c462c Merge remote-tracking branch 'origin/master' into HEAD 2026-08-10 07:05:32 -07:00
Joe Li fd95e60abd chore: fix spacing in AGENTS.md 2026-08-07 09:02:41 -07:00
136 changed files with 1023 additions and 5692 deletions
+1 -1
View File
@@ -40,7 +40,7 @@ jobs:
persist-credentials: false
- name: Check action refs against the ASF allowlist
uses: apache/infrastructure-actions/allowlist-check@df54e48ff76152790f317934c691cfa7fd7a1a46 # allowlist-check/v1.0.1
uses: apache/infrastructure-actions/allowlist-check@61dcea11f19e2bbe1263f14d72235e8da17d3ad0 # allowlist-check/v1.0.0
with:
# Default scan-glob is .github/**/*.yml, which misses .yaml files.
scan-glob: ".github/**/*.y*ml"
@@ -164,13 +164,7 @@ jobs:
with:
python-version: ${{ matrix.python-version }}
- name: Setup Postgres
# cached-dependencies is a git submodule (not a plain directory), and
# the $/ self-repository syntax resolves action files directly from
# the repository without performing a real (submodule-aware)
# checkout, so it can't see into a submodule's gitlink. Keep this one
# on the workspace-relative ./ form, consistent with every other
# workflow in the repo that references this action.
uses: ./.github/actions/cached-dependencies # zizmor: ignore[self-repository] - $/ cannot resolve an action that lives in a submodule; ./ is required here
uses: ./.github/actions/cached-dependencies
with:
run: |
setup-postgres
@@ -216,7 +210,7 @@ jobs:
persist-credentials: false
submodules: recursive
- name: Setup Python
uses: $/.github/actions/setup-backend/
uses: ./.github/actions/setup-backend/
- name: Install dependencies
uses: ./.github/actions/cached-dependencies
with:
@@ -33,7 +33,7 @@ jobs:
persist-credentials: false
- name: Check for file changes
id: check
uses: $/.github/actions/change-detector/
uses: ./.github/actions/change-detector/
with:
token: ${{ secrets.GITHUB_TOKEN }}
@@ -80,15 +80,9 @@ jobs:
persist-credentials: false
submodules: recursive
- name: Setup Python
uses: $/.github/actions/setup-backend/
uses: ./.github/actions/setup-backend/
- name: Setup Postgres
# cached-dependencies is a submodule (not a plain directory), and
# the $/ self-repository syntax resolves action files directly from
# the repository without performing a real (submodule-aware)
# checkout, so it can't see into a submodule's link. Keep this one
# on the workspace-relative ./ form, consistent with every other
# workflow in the repo that references this action.
uses: ./.github/actions/cached-dependencies # zizmor: ignore[self-repository] - $/ cannot resolve an action that lives in a submodule; ./ is required here
uses: ./.github/actions/cached-dependencies
with:
run: setup-postgres
- name: Start Celery worker
@@ -147,19 +141,13 @@ jobs:
- name: Start hadoop and hive
run: docker compose -f scripts/databases/hive/docker-compose.yml up -d
- name: Setup Python
uses: $/.github/actions/setup-backend/
uses: ./.github/actions/setup-backend/
- name: Setup Postgres
uses: ./.github/actions/cached-dependencies
with:
run: setup-postgres
- name: Start Celery worker
# cached-dependencies is a submodule (not a plain directory), and
# the $/ self-repository syntax resolves action files directly from
# the repository without performing a real (submodule-aware)
# checkout, so it can't see into a submodule's link. Keep this one
# on the workspace-relative ./ form, consistent with every other
# workflow in the repo that references this action.
uses: ./.github/actions/cached-dependencies # zizmor: ignore[self-repository] - $/ cannot resolve an action that lives in a submodule; ./ is required here
uses: ./.github/actions/cached-dependencies
with:
run: celery-worker
- name: Python unit tests (PostgreSQL)
@@ -35,7 +35,7 @@ jobs:
persist-credentials: false
- name: Check for file changes
id: check
uses: $/.github/actions/change-detector/
uses: ./.github/actions/change-detector/
with:
token: ${{ secrets.GITHUB_TOKEN }}
@@ -60,7 +60,7 @@ jobs:
persist-credentials: false
submodules: recursive
- name: Setup Python
uses: $/.github/actions/setup-backend/
uses: ./.github/actions/setup-backend/
with:
python-version: ${{ matrix.python-version }}
- name: Python unit tests
+1 -1
View File
@@ -51,7 +51,7 @@ jobs:
# checkout, so it can't see into a submodule's gitlink. Keep this one
# on the workspace-relative ./ form, consistent with every other
# workflow in the repo that references this action.
uses: ./.github/actions/cached-dependencies # zizmor: ignore[self-repository] - $/ cannot resolve an action that lives in a submodule; ./ is required here
uses: ./.github/actions/cached-dependencies
with:
run: npm-install
- name: lint
+2 -2
View File
@@ -66,7 +66,7 @@ jobs:
fetch-depth: 0
- name: Setup Docker Environment
uses: $/.github/actions/setup-docker
uses: ./.github/actions/setup-docker
with:
dockerhub-user: ${{ secrets.DOCKERHUB_USER }}
dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }}
@@ -139,7 +139,7 @@ jobs:
package-manager-cache: false
- name: Setup supersetbot
uses: $/.github/actions/setup-supersetbot/
uses: ./.github/actions/setup-supersetbot/
- name: Label the PRs with the right release-related labels
env:
+1 -1
View File
@@ -304,7 +304,7 @@ pre-commit run eslint # Frontend linting
## Platform-Specific Instructions
- **[CLAUDE.md](CLAUDE.md)** - For Claude/Anthropic tools
- **[.github/copilot-instructions.md](.github/copilot-instructions.md)** - For GitHub Copilot
- **[.github/copilot-instructions.md](.github/copilot-instructions.md)** - For GitHub Copilot
- **[GEMINI.md](GEMINI.md)** - For Google Gemini tools
- **[GPT.md](GPT.md)** - For OpenAI/ChatGPT tools
- **[.cursor/rules/dev-standard.mdc](.cursor/rules/dev-standard.mdc)** - For Cursor editor
-28
View File
@@ -24,34 +24,6 @@ assists people when migrating to a new version.
## Next
### Tagging is on by default
`TAGGING_SYSTEM` now ships **on**. The Tags menu entry, the tag columns and
filters on the chart, dashboard and saved-query lists, and the Tags field in the
chart and dashboard property modals are all visible without configuration, and
tags are included in asset export and import.
**What operators should expect:**
- **Implicit tags accrue.** Saving a chart, dashboard, dataset or saved query,
and favoriting an asset, write rows to `tag` and `tagged_object` (`type:chart`,
`editor:<user id>`, `favorited_by:<user id>`). These have always been created
when the flag was on; they are simply no longer opt-in.
- **Exports gain a `tags` key and a `tags.yaml` file.** Chart and dashboard
export bundles carry custom tags. Importers on 6.0 and later understand both;
older importers skip the unrecognized `tags.yaml` file but reject chart and
dashboard YAML that contains a `tags` key, so strip that key before importing
a bundle into Superset 5.x or earlier.
- **The flag is honored at write time.** The tagging SQLA event listeners are
always attached at startup; the ones that create tags check `TAGGING_SYSTEM`
when they fire, so the flag, including a runtime override through
`GET_FEATURE_FLAGS_FUNC` or `IS_FEATURE_ENABLED_FUNC`, takes effect without a
restart. The cleanup listeners run regardless of the flag, so deleting an
asset never leaves orphaned `tagged_object` rows behind.
Set `FEATURE_FLAGS = {"TAGGING_SYSTEM": False}` to restore the previous
behavior. Existing tag rows are left untouched.
### Global Async Queries re-platformed onto the Global Task Framework (breaking)
Global Async Queries (GAQ) no longer runs on its own bespoke async-events
@@ -486,39 +486,6 @@ Log in as an admin user to ensure you have adequate permissions.
This is the best source of information about the problem. In a docker compose deployment, you can do this with a command like `docker logs superset_worker --since 1h`.
### CSV and Excel chart-data request failures
The worker uses the saved query context to POST to the chart-data export endpoint,
falling back to the legacy GET export when a query context cannot be generated.
`ALERT_REPORTS_CSV_REQUEST_TIMEOUT` (60 seconds by default) limits socket operations;
the report execution budget and its delivery/cleanup reserves also cap the request.
Connection and read timeouts are reported as CSV/Excel generation timeouts.
These attachment timeouts are logged at error level and explicitly mark the report
task as failed, while the report execution retains its ERROR state and separate
error-notification history. Other HTTP 408 exception handling is unchanged.
To tolerate short-lived transport failures, operators can opt in with
`ALERT_REPORTS_CSV_REQUEST_RETRY = True` (default: `False`). This permits **one** retry
for transient connection/read failures and HTTP 429, 500, 502, 503, or 504. Other
HTTP statuses are not retried. Backoff is 0.5 seconds, extended to at most 2 seconds
for a numeric `Retry-After`; longer, invalid, or date-based delays are not retried
inline. Both attempts and backoff share the initial request timeout allowance and
respect the remaining execution budget. Unbounded requests are not retried.
A request that consumes its entire timeout does **not** get another full timeout.
Socket timeouts are not wall-clock cancellation: existing report task limits still
interrupt in-flight work. A timed-out server query can continue running, so enabling
retries can increase database load. Leave retries disabled unless appropriate for
your deployment; disable the setting to roll back retry behavior.
Worker diagnostics include schedule/chart identifiers, a fixed endpoint path (no
query string), error category, HTTP status, timeout, elapsed duration, and attempt.
For HTTP errors, at most 4097 response bytes are read to enforce a 4096-byte limit.
Only recognized Superset error types from up to four JSON errors are retained;
free-form messages, extra fields, and non-JSON or oversized bodies are redacted or
omitted. Cookies, authentication headers, URLs, SQL, and query payloads are not
included in these transport diagnostics. HTTP 400 therefore remains a failure to
investigate, not a reason to repeat the same request.
### Check web browser and webdriver installation
To take a screenshot, the worker visits the dashboard or chart using a headless browser, then takes a screenshot. If you are able to send a chart as CSV, XLSX, or text but can't send as PNG, your problem may lie with the browser.
@@ -540,8 +540,6 @@ MCP_STORE_CONFIG = {
When `CACHE_REDIS_URL` is set, the MCP server uses a Redis-backed EventStore for session management, allowing replicas to share state. Without Redis, each pod manages its own in-memory sessions and stateful MCP interactions may fail when requests hit different replicas.
`MCP_STATELESS_HTTP` (default `True`) controls whether requests get a fresh, ephemeral transport per HTTP round trip or a transport that stays alive for the session's lifetime. The default suits multi-pod deployments because it doesn't require session affinity -- any pod can handle any request. Its tradeoff: a client disconnecting mid-tool-call can crash not just its own session but other concurrent sessions on the same worker. Setting it to `False` avoids that, but it requires session-affinity (sticky session) routing on `Mcp-Session-Id` at the mesh/ingress layer, since a session's follow-up requests must land on the same pod that created it. See [`MCP_STATELESS_HTTP`](#core) below.
---
## Configuration Reference
@@ -557,7 +555,6 @@ All MCP settings go in `superset_config.py`. Defaults are defined in `superset/m
| `MCP_SERVICE_URL` | `None` | Public base URL for MCP-generated links (set this when behind a reverse proxy) |
| `MCP_DEBUG` | `False` | Enable debug logging |
| `MCP_DEV_USERNAME` | -- | Superset username for development mode (no auth) |
| `MCP_STATELESS_HTTP` | `True` | Streamable-HTTP session mode. `True` gives each request a fresh, ephemeral transport, torn down as soon as that request completes; a client disconnecting mid-tool-call can crash not just its own session but other concurrent sessions on the same worker. `False` keeps the transport alive for the session's lifetime, avoiding that crash, but requires session-affinity routing on `Mcp-Session-Id` for multi-pod deployments (see [Multi-Pod (Kubernetes)](#multi-pod-kubernetes)). |
| `MCP_RBAC_ENABLED` | `True` | Enforce Superset's role-based access control on MCP tool calls. When `True`, each tool checks that the authenticated user has the required FAB permission before executing. Disable only for testing or trusted-network deployments. |
| `MCP_DISABLED_TOOLS` | `set()` | Set of tool names to remove from the MCP server at startup. Disabled tools are never advertised to AI clients during tool discovery. Useful when a custom extension tool should replace a built-in Superset tool. See [Disabling built-in tools](#disabling-built-in-tools). |
| `MCP_DISABLED_CHART_PLUGINS` | `frozenset()` | Set of chart type plugin names (e.g. `"handlebars"`) to hide from `generate_chart`. Does not affect `get_chart_type_schema`. See [Disabling chart type plugins](#disabling-chart-type-plugins). |
@@ -87,7 +87,6 @@ The chart will publish appropriate services to expose the Superset UI internally
- Configure the Service as a `LoadBalancer` or `NodePort`
- Set up an `Ingress` for it - the chart includes a definition, but will need to be tuned to your needs (hostname, tls, annotations etc...)
- Set up a Gateway API `HTTPRoute` for it - see [Exposing Superset via Gateway API (HTTPRoute)](#exposing-superset-via-gateway-api-httproute) below
- Run `kubectl port-forward superset-xxxx-yyyy :8088` to directly tunnel one pod's port into your localhost
Depending how you configured external access, the URL will vary. Once you've identified the appropriate URL you can log in with:
@@ -320,53 +319,6 @@ configOverrides:
AUTH_USER_REGISTRATION_ROLE = "Admin"
```
### Exposing Superset via Gateway API (HTTPRoute)
As an alternative to `Ingress`, the chart can create a [Gateway API](https://gateway-api.sigs.k8s.io/)
`HTTPRoute` that attaches to a Gateway already running in your cluster. This requires the Gateway
API CRDs serving the configured `httproute.apiVersion` (`gateway.networking.k8s.io/v1` by default)
to be installed, along with a Gateway resource for the route to attach to. If the Gateway lives in
a different namespace than the `HTTPRoute` (as in the
example below), its listener's `allowedRoutes` must explicitly permit routes from this release's
namespace, or the `HTTPRoute` will install successfully but never attach.
```yaml
httproute:
enabled: true
parentRefs:
- name: my-gateway
namespace: gateway-system
hostnames:
- superset.example.com
rules:
- matches:
- path:
type: PathPrefix
value: /
```
- `httproute.parentRefs` lists the Gateway(s) the route attaches to.
- `httproute.hostnames` matches against the HTTP `Host` header; it's templated, so values like
`{{ .Release.Name }}` can be used.
- `httproute.rules` are routing rules backed by the Superset service; each rule accepts standard
`matches`, `filters`, and `timeouts` fields, and an optional `weight` (defaults to `1`) applied to
its single backend reference. Since each rule maps to one backend, `weight` has no traffic-splitting
effect here; it only matters if you fork the template to add multiple `backendRefs` to a rule.
`timeouts` only joined the Gateway API Standard channel in v1.2, so it requires both v1.2+ CRDs
and a supporting controller; drop it if either predates that.
- If `supersetWebsockets.enabled` is set, an extra rule routing `supersetWebsockets.ingress.path`
(default `/ws`) to the `-ws` service is appended automatically, mirroring the `Ingress` behavior.
WebSocket upgrade support is controller-dependent under Gateway API; check your Gateway
implementation's docs in case it needs an explicit protocol opt-in for global async queries to
keep working behind a Gateway.
- If `supersetMcp.enabled` and `supersetMcp.httproute.enabled` are both set, an extra rule routing
`supersetMcp.httproute.path` to the `-mcp` service is appended as well. Don't expose this route
without first enabling MCP authentication — see the
[MCP Server Deployment & Authentication](/admin-docs/configuration/mcp-server#authentication) doc;
by default the MCP server runs in dev mode with auth disabled.
- Set `httproute.apiVersion` to `gateway.networking.k8s.io/v1beta1` if your cluster's Gateway API
installation hasn't promoted `HTTPRoute` to `v1` yet.
### Enable Alerts and Reports
For this, as per the [Alerts and Reports doc](/admin-docs/configuration/alerts-reports), you will need to:
@@ -379,15 +379,6 @@ AG Grid supports server-side column filters that query the full dataset — not
AG Grid Interactive Table supports **Time Shift** (time comparison), matching the behavior of the standard Table chart. In the **Advanced Analytics** → **Time Comparison** section of the chart configuration, enter a shift expression (e.g., `1 year ago`, `minus 7 days`) to add comparison columns showing values from the offset period. Dashboard-level time range overrides apply to both the base and comparison periods.
#### Show Summary
The **Show summary** checkbox lives at the top of the **Visual formatting** section in the **Customize** tab, for both **Aggregate** and **Raw Records** query modes. Enabling it pins a summary row to the bottom of the grid whenever there is something to summarize: at least one metric in **Aggregate** mode, or at least one eligible numeric column in **Raw Records** mode. Otherwise no summary row is added.
- In **Aggregate** mode, the summary row applies each metric's own aggregation (or the **Summary aggregation** override, where available) across the full filtered dataset.
- In **Raw Records** mode, the summary row defaults to a server-side `SUM` for each numeric column that's backed by a physical or calculated dataset column; the **Summary aggregation** control can override this to `AVG` as well. Non-numeric cells and columns built from free-form SQL expressions stay blank.
In both modes, the summary is computed across the full result set, independent of the chart's row limit and pagination, and it reflects dashboard and chart-level filters. It does not reflect AG Grid's own server-side column filters (the per-column filter UI in the grid header), which are excluded from the summary query.
### Dynamic Currency Formatting
Chart metric values can display currencies dynamically rather than using a fixed currency code. To enable:
-48
View File
@@ -145,51 +145,3 @@ The following URL parameters can be passed through the `urlParams` option in `da
- **Row-level security** — pass `rls` rules in the guest token request to restrict which rows are visible to the embedded user.
- **Allowed domains** — restrict which host origins can embed a dashboard by setting **Allowed Domains** per-dashboard in the _Embed_ settings modal. Superset checks the request's `Referer` header against this list before serving the embedded view; an empty list allows any origin, so configure this explicitly for production.
- **Redacted errors** — API responses to a guest token report a generic `An error occurred while fetching the data.` instead of the underlying error, since engine errors quote catalog, schema, table and column names. Errors Superset raises itself — access denials, timeouts, payload validation — keep their message, and the full error is always available in the server logs.
## Guest-token request-header size diagnostics
A successful guest-token mint does not guarantee the token can pass through your
deployment's proxies. Limits apply to the **encoded JWT bytes plus header
overhead**, not the number of RLS rules or identifiers. A proxy can reject the
subsequent authentication request before it reaches Superset, including an HTTP
400 HTML response instead of JSON. A 400 alone does not establish a size problem.
Operators can set a deployment-specific diagnostic budget in `superset_config.py`:
```python
# Example only: choose a budget for your complete proxy path.
GUEST_TOKEN_HEADER_MAX_BYTES = 16 * 1024
```
The default is `None` (no budget warnings). Positive integer budgets count UTF-8
bytes of `GUEST_TOKEN_HEADER_NAME`, `: `, the encoded token, and `\r\n`
(four framing bytes). Only sizes **strictly greater** than the budget warn;
equality does not. This is consistent diagnostic accounting, not a prediction of
every proxy's wire-level accounting, HTTP/2 compression, or total-header limits.
Leave a safety margin and validate your actual deployment, including custom
header names. Zero, negative, non-integral, or non-numeric values (including strings and
booleans) disable budget warnings, as do values above JavaScript's maximum safe
integer (2^53 1). Whole-number floats are accepted. Convert environment-variable
strings to integers in deployment configuration to enable the budget.
Issuance audit metadata includes `token_bytes`, `header_bytes`,
`header_budget_bytes`, and `header_budget_exceeded`. Issuance remains HTTP 200
with the same token and response shape. The embedded bootstrap exposes the budget
and configured header name; reload the iframe after changing deployment config.
The embedded client measures initial and refreshed tokens and warns in the
developer console with sizes only. Initial authentication failures get a targeted
suggestion only when the request's token exceeds the budget and the failure has
no status or HTTP 400/431/494; other statuses and ambiguous in-flight
refreshes use the generic error. Refresh warnings do not restart authentication.
These diagnostics do not record JWTs, decoded claims, RLS SQL, or request headers.
[AWS Application Load Balancer quotas](https://docs.aws.amazon.com/elasticloadbalancing/latest/application/load-balancer-limits.html)
list a non-adjustable 16 K single-header limit. Increasing a Superset diagnostic
budget does not increase that limit or add large-token support.
To reduce payload size, replace large inline RLS ID lists with a compact
entitlements-table subquery where supported by your database. Keep the same
tenant/user restrictions, derive identity from your trusted token-issuing
backend, and verify equivalent row access and query performance before rollout.
Do not remove RLS or broaden entitlements to make a token smaller.
+20 -56
View File
@@ -71,17 +71,17 @@ Parses a JSON string into an object that can be used in your template.
---
#### `group`
#### `groupBy`
Groups an array of objects by a key, powered by [handlebars-group-by](https://github.com/nicktindall/handlebars-group-by). The key is passed as a `by` hash argument.
Groups an array of objects by a key, powered by [handlebars-group-by](https://github.com/nicktindall/handlebars-group-by).
```handlebars
{{#group data by="department"}}
{{#groupBy data 'department'}}
<h3>{{value}}</h3>
{{#each items}}
<p>{{this.name}}</p>
{{/each}}
{{/group}}
{{/groupBy}}
```
---
@@ -90,14 +90,6 @@ Groups an array of objects by a key, powered by [handlebars-group-by](https://gi
Superset also registers all helpers from the [just-handlebars-helpers](https://github.com/leapfrogtechnology/just-handlebars-helpers) library. These include a wide range of comparison, math, string, and conditional helpers. Commonly used ones include:
:::note
These names are specific to `just-handlebars-helpers` and differ from other
Handlebars helper libraries — notably `handlebars-helpers`, which spells the
math helpers `add`, `subtract`, `multiply` and `divide`. Calling a helper that
is not registered raises `Missing helper: "..."`, which renders the chart blank,
so it is worth checking a name against the tables below before using it.
:::
#### Comparison
| Helper | Description | Example |
@@ -105,7 +97,6 @@ so it is worth checking a name against the tables below before using it.
| `eq` | Strict equality | `{{#if (eq status "active")}}` |
| `eqw` | Weak equality | `{{#if (eqw count "5")}}` |
| `neq` | Strict inequality | `{{#if (neq role "admin")}}` |
| `neqw` | Weak inequality | `{{#if (neqw count "5")}}` |
| `lt` | Less than | `{{#if (lt score 50)}}` |
| `lte` | Less than or equal | `{{#if (lte score 100)}}` |
| `gt` | Greater than | `{{#if (gt price 0)}}` |
@@ -123,52 +114,25 @@ so it is worth checking a name against the tables below before using it.
#### String
| Helper | Description | Example |
| ----------------- | ----------------------------------------------- | ------------------------------ |
| `capitalizeFirst` | Capitalizes the first letter | `{{capitalizeFirst name}}` |
| `capitalizeEach` | Capitalizes the first letter of each word | `{{capitalizeEach title}}` |
| `uppercase` | Converts to uppercase | `{{uppercase status}}` |
| `lowercase` | Converts to lowercase | `{{lowercase email}}` |
| `excerpt` | Truncates to a length and appends an ellipsis | `{{excerpt description 100}}` |
| `sprintf` | printf-style formatting | `{{sprintf "%.1f" score}}` |
| `concat` | Concatenates values | `{{concat first " " last}}` |
| `join` | Joins an array with a separator | `{{join tags ", "}}` |
| `first` / `last` | First or last element of an array | `{{first items}}` |
| `newLineToBr` | Converts newlines to `<br>` (needs `{{{ }}}`) | `{{{newLineToBr notes}}}` |
| Helper | Description | Example |
| ------------ | ----------------------------------- | --------------------------------- |
| `capitalize` | Capitalizes first letter | `{{capitalize name}}` |
| `uppercase` | Converts to uppercase | `{{uppercase status}}` |
| `lowercase` | Converts to lowercase | `{{lowercase email}}` |
| `truncate` | Truncates a string | `{{truncate description 100}}` |
| `contains` | Checks if string contains substring | `{{#if (contains tag "urgent")}}` |
#### Math
| Helper | Description | Example |
| ---------------- | ----------------------- | ------------------------------------ |
| `sum` | Addition | `{{sum a b}}` |
| `difference` | Subtraction | `{{difference total discount}}` |
| `multiplication` | Multiplication | `{{multiplication price quantity}}` |
| `division` | Division | `{{division total count}}` |
| `remainder` | Modulo | `{{remainder index 2}}` |
| `abs` | Absolute value | `{{abs delta}}` |
| `ceil` | Ceiling | `{{ceil value}}` |
| `floor` | Floor | `{{floor value}}` |
`sum` takes exactly two arguments — it adds a pair of numbers and does not total
an array. There is no `round` helper; use `{{sprintf "%.0f" value}}` to round to
a given number of decimal places.
#### Arrays
| Helper | Description | Example |
| ---------- | ---------------------------------- | --------------------------------- |
| `includes` | Whether an array contains a value | `{{#if (includes tags "urgent")}}` |
| `empty` | Whether an array is empty | `{{#if (empty rows)}}` |
| `count` | Number of items in an array | `{{count rows}}` |
`includes` tests array membership. It returns `false` for a string, so it cannot
be used to check for a substring.
#### Formatting
| Helper | Description | Example |
| ---------------- | ---------------------------- | -------------------------------- |
| `formatCurrency` | Formats a number as currency | `{{formatCurrency revenue "$"}}` |
| Helper | Description | Example |
| ---------- | -------------- | ----------------------------- |
| `add` | Addition | `{{add a b}}` |
| `subtract` | Subtraction | `{{subtract total discount}}` |
| `multiply` | Multiplication | `{{multiply price quantity}}` |
| `divide` | Division | `{{divide total count}}` |
| `ceil` | Ceiling | `{{ceil value}}` |
| `floor` | Floor | `{{floor value}}` |
| `round` | Round | `{{round value}}` |
For the full list of available helpers, see the [just-handlebars-helpers documentation](https://github.com/leapfrogtechnology/just-handlebars-helpers).
+2 -5
View File
@@ -43,11 +43,8 @@ publish = "build"
ignore = 'if [ -n "$CACHED_COMMIT_REF" ]; then git diff --quiet "$CACHED_COMMIT_REF" HEAD -- . ../README.md; else git fetch --no-tags origin master >/dev/null 2>&1 || true; i=0; while [ "$i" -lt 10 ] && ! git merge-base origin/master HEAD >/dev/null 2>&1; do git fetch --deepen=200 origin master >/dev/null 2>&1 || break; i=$((i+1)); done; BASE="$(git merge-base origin/master HEAD 2>/dev/null || true)"; if [ -z "$BASE" ]; then exit 1; fi; git diff --quiet "$BASE" HEAD -- . ../README.md; fi'
[build.environment]
# Node version is intentionally not pinned here: Netlify auto-detects it
# from docs/.nvmrc, which is a symlink to the repo's single source of truth
# at superset-frontend/.nvmrc. Duplicating the version here previously let
# it drift out of sync (stuck on Node 20 after the repo moved to Node 24),
# breaking installs once a dependency required a newer Node engine.
# Node version matching docs/.nvmrc
NODE_VERSION = "20"
# Yarn version
YARN_VERSION = "1.22.22"
# Increase heap size for webpack bundling of Superset UI components
+6 -6
View File
@@ -98,6 +98,12 @@
"default": false,
"lifecycle": "development",
"description": "Enable Table V2 time comparison feature"
},
{
"name": "TAGGING_SYSTEM",
"default": false,
"lifecycle": "development",
"description": "Enables the tagging system for organizing assets"
}
],
"testing": [
@@ -234,12 +240,6 @@
"description": "Allow users to enable SSH tunneling when creating a DB connection. DB engine must support SSH Tunnels.",
"docs": "https://superset.apache.org/docs/configuration/setup-ssh-tunneling"
},
{
"name": "TAGGING_SYSTEM",
"default": true,
"lifecycle": "testing",
"description": "Enables the tagging system for organizing assets"
},
{
"name": "USE_ANALOGOUS_COLORS",
"default": false,
@@ -71,17 +71,17 @@ Parses a JSON string into an object that can be used in your template.
---
#### `group`
#### `groupBy`
Groups an array of objects by a key, powered by [handlebars-group-by](https://github.com/nicktindall/handlebars-group-by). The key is passed as a `by` hash argument.
Groups an array of objects by a key, powered by [handlebars-group-by](https://github.com/nicktindall/handlebars-group-by).
```handlebars
{{#group data by="department"}}
{{#groupBy data 'department'}}
<h3>{{value}}</h3>
{{#each items}}
<p>{{this.name}}</p>
{{/each}}
{{/group}}
{{/groupBy}}
```
---
@@ -90,14 +90,6 @@ Groups an array of objects by a key, powered by [handlebars-group-by](https://gi
Superset also registers all helpers from the [just-handlebars-helpers](https://github.com/leapfrogtechnology/just-handlebars-helpers) library. These include a wide range of comparison, math, string, and conditional helpers. Commonly used ones include:
:::note
These names are specific to `just-handlebars-helpers` and differ from other
Handlebars helper libraries — notably `handlebars-helpers`, which spells the
math helpers `add`, `subtract`, `multiply` and `divide`. Calling a helper that
is not registered raises `Missing helper: "..."`, which renders the chart blank,
so it is worth checking a name against the tables below before using it.
:::
#### Comparison
| Helper | Description | Example |
@@ -105,7 +97,6 @@ so it is worth checking a name against the tables below before using it.
| `eq` | Strict equality | `{{#if (eq status "active")}}` |
| `eqw` | Weak equality | `{{#if (eqw count "5")}}` |
| `neq` | Strict inequality | `{{#if (neq role "admin")}}` |
| `neqw` | Weak inequality | `{{#if (neqw count "5")}}` |
| `lt` | Less than | `{{#if (lt score 50)}}` |
| `lte` | Less than or equal | `{{#if (lte score 100)}}` |
| `gt` | Greater than | `{{#if (gt price 0)}}` |
@@ -123,52 +114,25 @@ so it is worth checking a name against the tables below before using it.
#### String
| Helper | Description | Example |
| ----------------- | ----------------------------------------------- | ------------------------------ |
| `capitalizeFirst` | Capitalizes the first letter | `{{capitalizeFirst name}}` |
| `capitalizeEach` | Capitalizes the first letter of each word | `{{capitalizeEach title}}` |
| `uppercase` | Converts to uppercase | `{{uppercase status}}` |
| `lowercase` | Converts to lowercase | `{{lowercase email}}` |
| `excerpt` | Truncates to a length and appends an ellipsis | `{{excerpt description 100}}` |
| `sprintf` | printf-style formatting | `{{sprintf "%.1f" score}}` |
| `concat` | Concatenates values | `{{concat first " " last}}` |
| `join` | Joins an array with a separator | `{{join tags ", "}}` |
| `first` / `last` | First or last element of an array | `{{first items}}` |
| `newLineToBr` | Converts newlines to `<br>` (needs `{{{ }}}`) | `{{{newLineToBr notes}}}` |
| Helper | Description | Example |
| ------------ | ----------------------------------- | --------------------------------- |
| `capitalize` | Capitalizes first letter | `{{capitalize name}}` |
| `uppercase` | Converts to uppercase | `{{uppercase status}}` |
| `lowercase` | Converts to lowercase | `{{lowercase email}}` |
| `truncate` | Truncates a string | `{{truncate description 100}}` |
| `contains` | Checks if string contains substring | `{{#if (contains tag "urgent")}}` |
#### Math
| Helper | Description | Example |
| ---------------- | ----------------------- | ------------------------------------ |
| `sum` | Addition | `{{sum a b}}` |
| `difference` | Subtraction | `{{difference total discount}}` |
| `multiplication` | Multiplication | `{{multiplication price quantity}}` |
| `division` | Division | `{{division total count}}` |
| `remainder` | Modulo | `{{remainder index 2}}` |
| `abs` | Absolute value | `{{abs delta}}` |
| `ceil` | Ceiling | `{{ceil value}}` |
| `floor` | Floor | `{{floor value}}` |
`sum` takes exactly two arguments — it adds a pair of numbers and does not total
an array. There is no `round` helper; use `{{sprintf "%.0f" value}}` to round to
a given number of decimal places.
#### Arrays
| Helper | Description | Example |
| ---------- | ---------------------------------- | --------------------------------- |
| `includes` | Whether an array contains a value | `{{#if (includes tags "urgent")}}` |
| `empty` | Whether an array is empty | `{{#if (empty rows)}}` |
| `count` | Number of items in an array | `{{count rows}}` |
`includes` tests array membership. It returns `false` for a string, so it cannot
be used to check for a substring.
#### Formatting
| Helper | Description | Example |
| ---------------- | ---------------------------- | -------------------------------- |
| `formatCurrency` | Formats a number as currency | `{{formatCurrency revenue "$"}}` |
| Helper | Description | Example |
| ---------- | -------------- | ----------------------------- |
| `add` | Addition | `{{add a b}}` |
| `subtract` | Subtraction | `{{subtract total discount}}` |
| `multiply` | Multiplication | `{{multiply price quantity}}` |
| `divide` | Division | `{{divide total count}}` |
| `ceil` | Ceiling | `{{ceil value}}` |
| `floor` | Floor | `{{floor value}}` |
| `round` | Round | `{{round value}}` |
For the full list of available helpers, see the [just-handlebars-helpers documentation](https://github.com/leapfrogtechnology/just-handlebars-helpers).
+72 -72
View File
@@ -4969,85 +4969,85 @@
resolved "https://registry.yarnpkg.com/@swc/counter/-/counter-0.1.3.tgz#cc7463bd02949611c6329596fccd2b0ec782b0e9"
integrity sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==
"@swc/html-darwin-arm64@1.16.2":
version "1.16.2"
resolved "https://registry.yarnpkg.com/@swc/html-darwin-arm64/-/html-darwin-arm64-1.16.2.tgz#dfe45d70266262a59aaaa0d93740b6161803f192"
integrity sha512-SNBUxkxLBXD0ATwnOG1rF8mpSrRtFDfqWnEUmbm/g4KwmCt7NuHHv9YYqA3lqfq90Ucc+Xlk7afx8KAW/utz4A==
"@swc/html-darwin-arm64@1.15.43":
version "1.15.43"
resolved "https://registry.yarnpkg.com/@swc/html-darwin-arm64/-/html-darwin-arm64-1.15.43.tgz#c88069f140ead901724018f96ad709779526a368"
integrity sha512-+PFbHbeeN+zB0zfvR1V1NmvPriuWPI+sijQXpI+wq/nLIujxvtENWjOKVHgouC9TIN/uKmL2zu9HAq6L6YxnPA==
"@swc/html-darwin-x64@1.16.2":
version "1.16.2"
resolved "https://registry.yarnpkg.com/@swc/html-darwin-x64/-/html-darwin-x64-1.16.2.tgz#72066bda0d843c024dd5230aa013445d10037cde"
integrity sha512-WVBgn6yrBPMZu+DL95/XGAXYcgd1nhd67Ml1UjMtFoFMVKY+VRpCq8JpTZTMXhWbVoRENUHk+3PHu0nNjlE/Fg==
"@swc/html-darwin-x64@1.15.43":
version "1.15.43"
resolved "https://registry.yarnpkg.com/@swc/html-darwin-x64/-/html-darwin-x64-1.15.43.tgz#9bc8171494d3b98eac017b5b73f0f63054558626"
integrity sha512-LQJ2U8Oxcx4T1rRF25y4h+/p05nn58FugTe/uGxC5OT3K83c2MftcSZLYaahOu4GVHRZeS1NI94CkSvQV++TVw==
"@swc/html-linux-arm-gnueabihf@1.16.2":
version "1.16.2"
resolved "https://registry.yarnpkg.com/@swc/html-linux-arm-gnueabihf/-/html-linux-arm-gnueabihf-1.16.2.tgz#efa7dd85b03b941ac173ae2870355f9721993905"
integrity sha512-V9F/Akd2TXrf5nUhdLgdy3FoVFxQbw8pA2AOyqnEOa2Mbm1R7DZJJ0GdShEMcoyMyMDB9r/4pWuWfxNtP4mFHA==
"@swc/html-linux-arm-gnueabihf@1.15.43":
version "1.15.43"
resolved "https://registry.yarnpkg.com/@swc/html-linux-arm-gnueabihf/-/html-linux-arm-gnueabihf-1.15.43.tgz#271a5d345719fa2c381df45355f70870faaaf35c"
integrity sha512-DKIen6DuIRO7Xc5gAbgBT5QyRHJGEGXreIdM1VBosYWTGnnrQ//Hwd7bLD6UbT8X8eU1vqvpXwQ1E24QRqRaBQ==
"@swc/html-linux-arm64-gnu@1.16.2":
version "1.16.2"
resolved "https://registry.yarnpkg.com/@swc/html-linux-arm64-gnu/-/html-linux-arm64-gnu-1.16.2.tgz#4b6a02b42a40463e9ed9a96e0c42b7095b244f48"
integrity sha512-jonZVtHc6BesMjC/muUEJGzE1L2kVdgiPVuHc7CL79MrUm0Hjf8LS4Wmtjqe2bLTfRcaMfaYl/60ZcRXHCaYSQ==
"@swc/html-linux-arm64-gnu@1.15.43":
version "1.15.43"
resolved "https://registry.yarnpkg.com/@swc/html-linux-arm64-gnu/-/html-linux-arm64-gnu-1.15.43.tgz#93cd3202f204351e7279efd07abc280fe0b2d193"
integrity sha512-0AuHiyfcE86CZ/CajFIszLzZVzbM2wn5p01oet8Q9RikflCGwyH79Nv9TrAKD1Cx7juUrONzDk+f2b/x73wLTg==
"@swc/html-linux-arm64-musl@1.16.2":
version "1.16.2"
resolved "https://registry.yarnpkg.com/@swc/html-linux-arm64-musl/-/html-linux-arm64-musl-1.16.2.tgz#0334e071cb8a01e0423fe7da27204b71f1afed66"
integrity sha512-dvki9/sgacHk9ouORmnIok5FbpeE9zUE8yqGGhL1kitNJi6/TKzfnMOpRxSxeDk1/ccvJTAdjRGDIGkT45+b3Q==
"@swc/html-linux-arm64-musl@1.15.43":
version "1.15.43"
resolved "https://registry.yarnpkg.com/@swc/html-linux-arm64-musl/-/html-linux-arm64-musl-1.15.43.tgz#a6c0c2a1646755b1ee53a6bddbee68674f8edaea"
integrity sha512-TweIdl/g9ugkoiYvcL/qbu+gbglDY3TqNxfXH84WXc4rSqEP20owVlxLya2NjVct8LIP2wDrtutpOwAXWC+Eew==
"@swc/html-linux-ppc64-gnu@1.16.2":
version "1.16.2"
resolved "https://registry.yarnpkg.com/@swc/html-linux-ppc64-gnu/-/html-linux-ppc64-gnu-1.16.2.tgz#6c0d7293a1f7f7631e975c767755eb5236769193"
integrity sha512-6m0vVWHl9MW7cmWKVgKlFW6yhRv0uahMEaDxNIvXrPC3LdbbiiYZui+ryhyQGIYeVps3OMujzUjc0GihNz/afQ==
"@swc/html-linux-ppc64-gnu@1.15.43":
version "1.15.43"
resolved "https://registry.yarnpkg.com/@swc/html-linux-ppc64-gnu/-/html-linux-ppc64-gnu-1.15.43.tgz#51319cc1a4184b788613e0e556fc33ff77af0c52"
integrity sha512-4oue1pB38/W6mbudp+w0q1jbwxuwdbdbaOj85ay0pisCs213WkgP+MPN8Zqa5VVPjQnVk2CTY9kmEc74XQI/sA==
"@swc/html-linux-s390x-gnu@1.16.2":
version "1.16.2"
resolved "https://registry.yarnpkg.com/@swc/html-linux-s390x-gnu/-/html-linux-s390x-gnu-1.16.2.tgz#853018d7b57377e2f3b23d369a1571862c34d2b9"
integrity sha512-TOlz6wgKyZjg4THJsNZfDz/rAMO+rBa0s2eewTeHEfuJhI+jGu7H6Co6bdbMpN3oyDvTMG7N1f1ktSbkE0erAg==
"@swc/html-linux-s390x-gnu@1.15.43":
version "1.15.43"
resolved "https://registry.yarnpkg.com/@swc/html-linux-s390x-gnu/-/html-linux-s390x-gnu-1.15.43.tgz#a612115a5f2f6c9df438a52ab2cf8bc4fd3865b1"
integrity sha512-/tceMNvAxK70SKUZtcn3X+K0vcElMGk3i8Sz0CmPdtooso8MZ7WfAvVP1qi3TWgh1rpQ3cC+Al3433AHlET6+w==
"@swc/html-linux-x64-gnu@1.16.2":
version "1.16.2"
resolved "https://registry.yarnpkg.com/@swc/html-linux-x64-gnu/-/html-linux-x64-gnu-1.16.2.tgz#a347921e256f6ecb8847956bbacd4a23e1476adb"
integrity sha512-5EduoVpsnuAAkG9BW8COxcIKAe5swgNAEo+BVkAJCOy1ZMZm0krQYBdvlaDCsGGE9yLDKVPm7rpYIi7vTTZTbA==
"@swc/html-linux-x64-gnu@1.15.43":
version "1.15.43"
resolved "https://registry.yarnpkg.com/@swc/html-linux-x64-gnu/-/html-linux-x64-gnu-1.15.43.tgz#028980da812e1797316f0a06a758a49aca699318"
integrity sha512-YE7ltlTt5ZFl59GsoHTDrIHnCBY8EDBio66CVj4bqkElFXbE/28xmpVE5ksdGoI5c5aQ/8byUCfHxqzCzQQSVg==
"@swc/html-linux-x64-musl@1.16.2":
version "1.16.2"
resolved "https://registry.yarnpkg.com/@swc/html-linux-x64-musl/-/html-linux-x64-musl-1.16.2.tgz#ebfbbdb05f2f991a34bc500dce0ca10bfbcdfc3b"
integrity sha512-c0Z84dvBd0oh1ZcBHnM18itmvJFLbCZBKFF2lEDHsGBSLQ/1sPbggEKsVO4KgWkkhwQV2l9AB4jnsw1HrwZJCg==
"@swc/html-linux-x64-musl@1.15.43":
version "1.15.43"
resolved "https://registry.yarnpkg.com/@swc/html-linux-x64-musl/-/html-linux-x64-musl-1.15.43.tgz#363ac7ce3866b664db0c35d78a6a90636f280139"
integrity sha512-nS20HmbOk+dEEzdosJqqxAeyjMIiS5yrCAti8LUf0+dgr4eRmjkH4MlkjfPjf49aayR8o+eMJ1jsDZ7whx4zog==
"@swc/html-win32-arm64-msvc@1.16.2":
version "1.16.2"
resolved "https://registry.yarnpkg.com/@swc/html-win32-arm64-msvc/-/html-win32-arm64-msvc-1.16.2.tgz#69207d99cd80e55fe1ea722c8c8d5174b3d3cd2a"
integrity sha512-Aq7V2B5gS23X59DzV2z892c4NBHYtJbwhvsCjJN1MBMx723htjgNE9KVIJp9dQaJBr2PrNfb/u3QFwnWV2tAoQ==
"@swc/html-win32-arm64-msvc@1.15.43":
version "1.15.43"
resolved "https://registry.yarnpkg.com/@swc/html-win32-arm64-msvc/-/html-win32-arm64-msvc-1.15.43.tgz#d0aa3f99c091577aaaa1aaf51a3695c98278564d"
integrity sha512-Yz7aQQhXT/Yc6QcuMDQDZP9jqf2phkVyU+qSu8ZRWEcJgIorrPL6q7YLqMk+MB5PpZyu5XJEODvc1/UVDE1Kyg==
"@swc/html-win32-ia32-msvc@1.16.2":
version "1.16.2"
resolved "https://registry.yarnpkg.com/@swc/html-win32-ia32-msvc/-/html-win32-ia32-msvc-1.16.2.tgz#9473a17f22c65ec20059533bd8cc78b32c82a711"
integrity sha512-9gslPcsfXxKvAZtOvDkxGuEbM7lqBrONzLAyRsyUtw8KxFcSYkGIO48RDTstGWOkgTgKjjAq/WWqt9qr/NcE3A==
"@swc/html-win32-ia32-msvc@1.15.43":
version "1.15.43"
resolved "https://registry.yarnpkg.com/@swc/html-win32-ia32-msvc/-/html-win32-ia32-msvc-1.15.43.tgz#e2cfe4dd26ce8b8c5787ca1fdd69ef6b5dc94822"
integrity sha512-muUgfsSQRZk6YBRuhaGKSLvXy0bV9BW6/mHLI0N/06btWuf0hekoHhIzR7dUmS98NXKCA7Hv+buBPE/0vXUwyA==
"@swc/html-win32-x64-msvc@1.16.2":
version "1.16.2"
resolved "https://registry.yarnpkg.com/@swc/html-win32-x64-msvc/-/html-win32-x64-msvc-1.16.2.tgz#7c7aeaa21b8848a765be2d4f7ee2ee3fecfd0f52"
integrity sha512-Kdb4VdC8FyF5s1MQaFUNeASLckHECrb/oYy/6OCtU+hbgxQ/o/JCgE4uCe8YAg0LCWSOjhx73PCZDGwPf1TpKw==
"@swc/html-win32-x64-msvc@1.15.43":
version "1.15.43"
resolved "https://registry.yarnpkg.com/@swc/html-win32-x64-msvc/-/html-win32-x64-msvc-1.15.43.tgz#ee1a8a7fe4d928595268c214cf17c72867ee8f0f"
integrity sha512-tuLDy4MxPXsLi6jW+ozCdFWO61AoMMnlhePWJxMafefC2Ojm+iILxP2zI2Hgfu6F16y1q7ITdXdpEuqptu5fHw==
"@swc/html@^1.15.40":
version "1.16.2"
resolved "https://registry.yarnpkg.com/@swc/html/-/html-1.16.2.tgz#91ee34374e4c926c6c8a8f43561688192b8a1725"
integrity sha512-RmWH8m5dePWDFpHpmFKquZCRe5SyD/Sb0FBPxWcWv/tsjtlJl6oHeaxBsTL2edvaHuW385Fy5nPuTjDD/a+GEA==
version "1.15.43"
resolved "https://registry.yarnpkg.com/@swc/html/-/html-1.15.43.tgz#421da1ffc3226d149fd57c73f73755c6e6148427"
integrity sha512-SKbkbdGi9SDO9cTdV+6H0/AYifnb2nDOlz5BlWxlWMXACV3kmX6WwZDo0bBdyGlO/G4jCVWdR5r84qfotU2now==
dependencies:
"@swc/counter" "^0.1.3"
optionalDependencies:
"@swc/html-darwin-arm64" "1.16.2"
"@swc/html-darwin-x64" "1.16.2"
"@swc/html-linux-arm-gnueabihf" "1.16.2"
"@swc/html-linux-arm64-gnu" "1.16.2"
"@swc/html-linux-arm64-musl" "1.16.2"
"@swc/html-linux-ppc64-gnu" "1.16.2"
"@swc/html-linux-s390x-gnu" "1.16.2"
"@swc/html-linux-x64-gnu" "1.16.2"
"@swc/html-linux-x64-musl" "1.16.2"
"@swc/html-win32-arm64-msvc" "1.16.2"
"@swc/html-win32-ia32-msvc" "1.16.2"
"@swc/html-win32-x64-msvc" "1.16.2"
"@swc/html-darwin-arm64" "1.15.43"
"@swc/html-darwin-x64" "1.15.43"
"@swc/html-linux-arm-gnueabihf" "1.15.43"
"@swc/html-linux-arm64-gnu" "1.15.43"
"@swc/html-linux-arm64-musl" "1.15.43"
"@swc/html-linux-ppc64-gnu" "1.15.43"
"@swc/html-linux-s390x-gnu" "1.15.43"
"@swc/html-linux-x64-gnu" "1.15.43"
"@swc/html-linux-x64-musl" "1.15.43"
"@swc/html-win32-arm64-msvc" "1.15.43"
"@swc/html-win32-ia32-msvc" "1.15.43"
"@swc/html-win32-x64-msvc" "1.15.43"
"@swc/types@^0.1.28":
version "0.1.28"
@@ -6924,9 +6924,9 @@ color-name@~1.1.4:
integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==
colord@^2.9.3:
version "2.10.0"
resolved "https://registry.yarnpkg.com/colord/-/colord-2.10.0.tgz#56c9050e6b06b4b6c62ddec366a48d65ef57e860"
integrity sha512-AidJptpBJmjTclAp9BkLwJi0T93fo5epJnbaZslpg6QVzpHjAiveF55mE9AcUJiGMqRHgMDY8soMsQtuNYMHfw==
version "2.9.3"
resolved "https://registry.yarnpkg.com/colord/-/colord-2.9.3.tgz#4f8ce919de456f1d5c1c368c307fe20f3e59fb43"
integrity sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==
colorette@^1.2.0:
version "1.4.0"
@@ -9646,9 +9646,9 @@ jiti@^1.20.0:
integrity sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==
joi@^17.9.2:
version "17.13.7"
resolved "https://registry.yarnpkg.com/joi/-/joi-17.13.7.tgz#92e212c50dbbbcb1a1592424f84083eb265cc778"
integrity sha512-MF80Dm5Y2veNy8QWVx9Bj3ui4mo7+VPSPsR1M+oaHXV0Gx6zGX9a2F+OZG3Blby9tOlzU9Rs5FUimlEhbKtfnQ==
version "17.13.4"
resolved "https://registry.yarnpkg.com/joi/-/joi-17.13.4.tgz#ad6153d97ce558eb3a3b593e0d43eab51df1c474"
integrity sha512-1RuuER6kmt8K8I3nIWvPZKi5RQCb568ZPyY4Pwjlua+yo+63ZTmIwxLZH0heBmiKN4uxjvCiarDrjaeH84xicQ==
dependencies:
"@hapi/hoek" "^9.3.0"
"@hapi/topo" "^5.1.0"
@@ -14184,9 +14184,9 @@ svg-parser@^2.0.4:
integrity sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ==
svgo@^3.0.2, svgo@^3.2.0:
version "3.3.5"
resolved "https://registry.yarnpkg.com/svgo/-/svgo-3.3.5.tgz#8a3d9557ab2f386eca7e24760385849554985a1c"
integrity sha512-8SQMzdrvWaD8deUmrnYB+ASyxBVgWUOilg+A75nE/76WdLpj6LopCwiAVvkzkcqy/9b7t2Mg7faFLjg0ZRcZ3w==
version "3.3.4"
resolved "https://registry.yarnpkg.com/svgo/-/svgo-3.3.4.tgz#fd2aa10ff585b3bd2b83ce3602f5582bc0718bb5"
integrity sha512-GsNRis4e8jxn2Y9ENz/8lbJ93CstG8svtMnuRaHbiF2LTJ5tK0/q3t/URPq9Zc7zVWBJnNnJMIp6bevK7bSmNg==
dependencies:
commander "^7.2.0"
css-select "^5.1.0"
+1 -1
View File
@@ -54,7 +54,7 @@ dependencies = [
"deprecation>=2.1.0, <2.2.0",
"flask>=2.2.5, <4.0.0",
"flask-appbuilder>=5.2.2, <6.0.0",
"flask-caching>=2.5.0, <3",
"flask-caching>=2.4.1, <3",
"flask-compress>=1.13, <2.0",
"flask-talisman>=1.0.0, <2.0",
"flask-login>=0.6.0, < 1.0",
+4 -5
View File
@@ -40,7 +40,7 @@ brotli==1.2.0
# via
# -r requirements/base.in
# flask-compress
cachelib==0.17.0
cachelib==0.13.0
# via
# flask-caching
# flask-session
@@ -105,7 +105,7 @@ et-xmlfile==2.0.0
# via openpyxl
filelock==3.20.3
# via -r requirements/base.in
flask==3.1.3
flask==2.3.3
# via
# apache-superset (pyproject.toml)
# flask-appbuilder
@@ -124,9 +124,9 @@ flask-appbuilder==5.2.2
# via
# apache-superset (pyproject.toml)
# apache-superset-core
flask-babel==4.0.0
flask-babel==3.1.0
# via flask-appbuilder
flask-caching==2.5.0
flask-caching==2.4.1
# via apache-superset (pyproject.toml)
flask-compress==1.24
# via apache-superset (pyproject.toml)
@@ -218,7 +218,6 @@ markdown-it-py==3.0.0
# via rich
markupsafe==3.0.2
# via
# flask
# jinja2
# mako
# werkzeug
+4 -5
View File
@@ -94,7 +94,7 @@ brotli==1.2.0
# via
# -c requirements/base-constraint.txt
# flask-compress
cachelib==0.17.0
cachelib==0.13.0
# via
# -c requirements/base-constraint.txt
# flask-caching
@@ -247,7 +247,7 @@ filelock==3.20.3
# via
# -c requirements/base-constraint.txt
# virtualenv
flask==3.1.3
flask==2.3.3
# via
# -c requirements/base-constraint.txt
# apache-superset
@@ -269,11 +269,11 @@ flask-appbuilder==5.2.2
# -c requirements/base-constraint.txt
# apache-superset
# apache-superset-core
flask-babel==4.0.0
flask-babel==3.1.0
# via
# -c requirements/base-constraint.txt
# flask-appbuilder
flask-caching==2.5.0
flask-caching==2.4.1
# via
# -c requirements/base-constraint.txt
# apache-superset
@@ -524,7 +524,6 @@ markdown-it-py==3.0.0
markupsafe==3.0.2
# via
# -c requirements/base-constraint.txt
# flask
# jinja2
# mako
# werkzeug
+1 -1
View File
@@ -14,7 +14,7 @@
"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.
under the License
-->
# Change Log
+111 -86
View File
@@ -8007,12 +8007,11 @@
}
},
"node_modules/@mapbox/jsonlint-lines-primitives": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.3.tgz",
"integrity": "sha512-0SElaV0uMxEnxzBhhX9WTuPyUeMsAN/SS0i16tjuba4/mio63MG9khjC1a0JAiPGXAwvwm4UfHJURCN7nyudQg==",
"license": "MIT",
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.2.tgz",
"integrity": "sha512-rY0o9A5ECsTQRVhv7tL/OyDpGAoUB4tTvLiW1DSzQGq4bvTPhNw1VpSNjDJc5GFZ2XuyOtSWSVN05qOtcD71qQ==",
"engines": {
"node": ">= 22"
"node": ">= 0.6"
}
},
"node_modules/@mapbox/martini": {
@@ -8028,9 +8027,9 @@
"license": "ISC"
},
"node_modules/@mapbox/tiny-sdf": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.2.0.tgz",
"integrity": "sha512-LVL4wgI9YAum5V+LNVQO6QgFBPw7/MIIY4XJPNsPDMrjEwcE+JfKk1LuIl8GnF197ejVdC9QdPaxrx5gfgdGXg==",
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.1.0.tgz",
"integrity": "sha512-uFJhNh36BR4OCuWIEiWaEix9CA2WzT6CAIcqVjWYpnx8+QDtS+oC4QehRrx5cX4mgWs37MmKnwUejeHxVymzNg==",
"license": "BSD-2-Clause"
},
"node_modules/@mapbox/unitbezier": {
@@ -8048,23 +8047,29 @@
"@mapbox/point-geometry": "~0.1.0"
}
},
"node_modules/@maplibre/geojson-vt": {
"version": "6.1.1",
"resolved": "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-6.1.1.tgz",
"integrity": "sha512-FVMOcmSP/yqol45t7StApEyTL5/vmqBCuFhH9n+fFuINenhaX+YgHHIt1yJ86S8kln3uJLcMvmEU2cfn6E2eCQ==",
"node_modules/@mapbox/whoots-js": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@mapbox/whoots-js/-/whoots-js-3.1.0.tgz",
"integrity": "sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==",
"license": "ISC",
"dependencies": {
"kdbush": "^4.1.0"
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@maplibre/geojson-vt": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-5.0.4.tgz",
"integrity": "sha512-KGg9sma45S+stfH9vPCJk1J0lSDLWZgCT9Y8u8qWZJyjFlP8MNP1WGTxIMYJZjDvVT3PDn05kN1C95Sut1HpgQ==",
"license": "ISC"
},
"node_modules/@maplibre/maplibre-gl-style-spec": {
"version": "26.4.2",
"resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-26.4.2.tgz",
"integrity": "sha512-6J0vZqMZvRAJKtdWJdDGHEh1YJ2ZHG08/GOur8gCArYhO8ZkM//OXqtI2AAEz4jc2G+Wq4MfcePg8qNK5TZ4Kg==",
"version": "24.8.5",
"resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-24.8.5.tgz",
"integrity": "sha512-EzEJmMt6thioRH7GI9LWS7ahXTcAhAPGWCe6oTP2Ps4YnsXOOAfeqx854lZaiDnwURfHmcCKV1mr6oo0i23x6w==",
"license": "ISC",
"dependencies": {
"@mapbox/jsonlint-lines-primitives": "^2.0.3",
"@mapbox/unitbezier": "^1.0.0",
"@mapbox/jsonlint-lines-primitives": "~2.0.2",
"@mapbox/unitbezier": "^0.0.1",
"json-stringify-pretty-compact": "^4.0.0",
"minimist": "^1.2.8",
"quickselect": "^3.0.0",
@@ -8076,16 +8081,10 @@
"gl-style-validate": "dist/gl-style-validate.mjs"
}
},
"node_modules/@maplibre/maplibre-gl-style-spec/node_modules/@mapbox/unitbezier": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-1.0.0.tgz",
"integrity": "sha512-fqd515fjBmANKGGsQ286E2Wvj/XvDFpGzwJxq4CI6jMQue6Oy04uCKp+JWKF00xRTmk6cEu1jPJ9p3xqH8YWqQ==",
"license": "BSD-2-Clause"
},
"node_modules/@maplibre/mlt": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@maplibre/mlt/-/mlt-1.2.1.tgz",
"integrity": "sha512-5n5dgolE2EYxwCKgx8vlwURCB8A+kyfJJyThlYimjlGgOcOe2Bhw9VxxnnHC91OC9PgHg+nVdgVPTYPkIo62vg==",
"version": "1.1.8",
"resolved": "https://registry.npmjs.org/@maplibre/mlt/-/mlt-1.1.8.tgz",
"integrity": "sha512-8vtfYGidr1rNkv5IwIoU2lfe3Oy+Wa8HluzQYcQi9cveU9K3pweAal/poQj4GJ0K/EW4bTQp2wVAs09g2yDRZg==",
"license": "(MIT OR Apache-2.0)",
"dependencies": {
"@mapbox/point-geometry": "^1.1.0"
@@ -8098,14 +8097,18 @@
"license": "ISC"
},
"node_modules/@maplibre/vt-pbf": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/@maplibre/vt-pbf/-/vt-pbf-4.3.2.tgz",
"integrity": "sha512-j6p0AdjvAR19Z3XaCysle7A4ZSo08tYOzxD0Y9NQylwPAkwJJeYub5b2eVucdeDh7erhv69DahoLOevDRERRUw==",
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@maplibre/vt-pbf/-/vt-pbf-4.3.0.tgz",
"integrity": "sha512-jIvp8F5hQCcreqOOpEt42TJMUlsrEcpf/kI1T2v85YrQRV6PPXUcEXUg5karKtH6oh47XJZ4kHu56pUkOuqA7w==",
"license": "MIT",
"dependencies": {
"@mapbox/point-geometry": "^1.1.0",
"@mapbox/vector-tile": "^2.0.4",
"@maplibre/geojson-vt": "^5.0.4",
"@types/geojson": "^7946.0.16",
"pbf": "^5.1.0"
"@types/supercluster": "^7.1.3",
"pbf": "^4.0.1",
"supercluster": "^8.0.1"
}
},
"node_modules/@maplibre/vt-pbf/node_modules/@mapbox/point-geometry": {
@@ -8114,10 +8117,21 @@
"integrity": "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==",
"license": "ISC"
},
"node_modules/@maplibre/vt-pbf/node_modules/@mapbox/vector-tile": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-2.0.5.tgz",
"integrity": "sha512-pXj8m7KTsqZt+1jsE0xIpGvqTSbblfkuEJL/NJmNePMtEwxO8V3XMDo9WMSfDeqHvCtBI9Lmt4mGcGR10zecmw==",
"license": "BSD-3-Clause",
"dependencies": {
"@mapbox/point-geometry": "~1.1.0",
"@types/geojson": "^7946.0.16",
"pbf": "^4.0.2"
}
},
"node_modules/@maplibre/vt-pbf/node_modules/pbf": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/pbf/-/pbf-5.1.2.tgz",
"integrity": "sha512-mnvGdvOrIvJOBGUEdGkrVXjN8E/VkIJCkf2eS1DH2yv82ORUlLttmDt0rWY38yYZmVwciZwBUvHM20qxBZf40w==",
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/pbf/-/pbf-4.0.2.tgz",
"integrity": "sha512-J0ajxARhZfpUEebxYs1vhMGMuLSXtBe1e+fFPDrf2uA2hgo+UshKfNUWOz92HJNz6/NFEXseQPddnHkTreWRqg==",
"license": "BSD-3-Clause",
"dependencies": {
"resolve-protobuf-schema": "^2.1.0"
@@ -28767,9 +28781,9 @@
}
},
"node_modules/jest-process-manager/node_modules/joi": {
"version": "17.13.7",
"resolved": "https://registry.npmjs.org/joi/-/joi-17.13.7.tgz",
"integrity": "sha512-MF80Dm5Y2veNy8QWVx9Bj3ui4mo7+VPSPsR1M+oaHXV0Gx6zGX9a2F+OZG3Blby9tOlzU9Rs5FUimlEhbKtfnQ==",
"version": "17.13.4",
"resolved": "https://registry.npmjs.org/joi/-/joi-17.13.4.tgz",
"integrity": "sha512-1RuuER6kmt8K8I3nIWvPZKi5RQCb568ZPyY4Pwjlua+yo+63ZTmIwxLZH0heBmiKN4uxjvCiarDrjaeH84xicQ==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
@@ -30769,9 +30783,9 @@
}
},
"node_modules/joi": {
"version": "18.2.8",
"resolved": "https://registry.npmjs.org/joi/-/joi-18.2.8.tgz",
"integrity": "sha512-G2TX62h58ZHuwqetJgP2F4ualakqAmZtBYe3jWen7gxQRw5xApX6crnFtuB91WC0c3ESBnva+kGSnb3+6pIQDQ==",
"version": "18.2.3",
"resolved": "https://registry.npmjs.org/joi/-/joi-18.2.3.tgz",
"integrity": "sha512-N5A3KTWQpPWT4ExxxPlUx7WmykGXRzhNidWhV41d6Abu9YfI2NyWCJuxdPnslJCPWtbRpSVOWSnSS6GakLM/Rg==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
@@ -32274,8 +32288,7 @@
"version": "4.4.2",
"resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz",
"integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/lodash.isempty": {
"version": "4.4.0",
@@ -32287,8 +32300,7 @@
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz",
"integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/lodash.isplainobject": {
"version": "4.0.6",
@@ -32563,25 +32575,27 @@
]
},
"node_modules/maplibre-gl": {
"version": "6.8.0",
"resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-6.8.0.tgz",
"integrity": "sha512-+ZkjKTodsVLY0ewQThvXRxXQsclcsSgOm5LlnrBM3G8AloMJKt6Haw8mY4xrOl8T0WMqH6DTYjktZ9zGQXZd4w==",
"version": "5.24.0",
"resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.24.0.tgz",
"integrity": "sha512-ALyFxgtd5R+65UqZ/++lOqwWcC0SNho9c27fYSyLmG7AfnAul2o46F05aDJGPbFU57wos9dgcIySHs0Xe6ia3A==",
"license": "BSD-3-Clause",
"dependencies": {
"@mapbox/jsonlint-lines-primitives": "^2.0.2",
"@mapbox/point-geometry": "^1.1.0",
"@mapbox/tiny-sdf": "^2.2.0",
"@mapbox/unitbezier": "^1.0.0",
"@mapbox/vector-tile": "^3.0.0",
"@maplibre/geojson-vt": "^6.1.1",
"@maplibre/maplibre-gl-style-spec": "^26.4.1",
"@maplibre/mlt": "^1.2.1",
"@maplibre/vt-pbf": "^4.3.2",
"@mapbox/tiny-sdf": "^2.1.0",
"@mapbox/unitbezier": "^0.0.1",
"@mapbox/vector-tile": "^2.0.4",
"@mapbox/whoots-js": "^3.1.0",
"@maplibre/geojson-vt": "^6.1.0",
"@maplibre/maplibre-gl-style-spec": "^24.8.1",
"@maplibre/mlt": "^1.1.8",
"@maplibre/vt-pbf": "^4.3.0",
"@types/geojson": "^7946.0.16",
"earcut": "^3.2.3",
"earcut": "^3.0.2",
"gl-matrix": "^3.4.4",
"kdbush": "^4.1.0",
"kdbush": "^4.0.2",
"murmurhash-js": "^1.0.0",
"pbf": "^5.1.2",
"pbf": "^4.0.1",
"potpack": "^2.1.0",
"quickselect": "^3.0.0",
"tinyqueue": "^3.0.0"
@@ -32600,33 +32614,36 @@
"integrity": "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==",
"license": "ISC"
},
"node_modules/maplibre-gl/node_modules/@mapbox/unitbezier": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-1.0.0.tgz",
"integrity": "sha512-fqd515fjBmANKGGsQ286E2Wvj/XvDFpGzwJxq4CI6jMQue6Oy04uCKp+JWKF00xRTmk6cEu1jPJ9p3xqH8YWqQ==",
"license": "BSD-2-Clause"
},
"node_modules/maplibre-gl/node_modules/@mapbox/vector-tile": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-3.0.0.tgz",
"integrity": "sha512-Qf10S1uIHMk20ri/IVBnpS+esUEkVaR5Hftmz88jTInrpmWgPGJfPe3LVjjlE77trLx8tH6qjTG7uWH9hIq/0Q==",
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-2.0.5.tgz",
"integrity": "sha512-pXj8m7KTsqZt+1jsE0xIpGvqTSbblfkuEJL/NJmNePMtEwxO8V3XMDo9WMSfDeqHvCtBI9Lmt4mGcGR10zecmw==",
"license": "BSD-3-Clause",
"dependencies": {
"@mapbox/point-geometry": "~1.1.0",
"@types/geojson": "^7946.0.16",
"pbf": "^5.0.0"
"pbf": "^4.0.2"
}
},
"node_modules/maplibre-gl/node_modules/@maplibre/geojson-vt": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-6.1.0.tgz",
"integrity": "sha512-2eIY4gZxeKIVOZVNkAMb+5NgXhgsMQpOveTQAvnp53LYqHGJZDidk7Ew0Tged9PThidpbS+NFTh0g4zivhPDzQ==",
"license": "ISC",
"dependencies": {
"kdbush": "^4.0.2"
}
},
"node_modules/maplibre-gl/node_modules/earcut": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/earcut/-/earcut-3.2.3.tgz",
"integrity": "sha512-vnS4AVwp1KHAF13i1vp1/2D5evWy3k5u/iW/B81QVsUZtV8cv2tU0b2VNFlqvh4kYwrFMDdjPCfAmfyJW9y14Q==",
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.2.tgz",
"integrity": "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==",
"license": "ISC"
},
"node_modules/maplibre-gl/node_modules/pbf": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/pbf/-/pbf-5.1.2.tgz",
"integrity": "sha512-mnvGdvOrIvJOBGUEdGkrVXjN8E/VkIJCkf2eS1DH2yv82ORUlLttmDt0rWY38yYZmVwciZwBUvHM20qxBZf40w==",
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/pbf/-/pbf-4.0.2.tgz",
"integrity": "sha512-J0ajxARhZfpUEebxYs1vhMGMuLSXtBe1e+fFPDrf2uA2hgo+UshKfNUWOz92HJNz6/NFEXseQPddnHkTreWRqg==",
"license": "BSD-3-Clause",
"dependencies": {
"resolve-protobuf-schema": "^2.1.0"
@@ -42597,6 +42614,15 @@
"integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==",
"license": "MIT"
},
"node_modules/supercluster": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/supercluster/-/supercluster-8.0.1.tgz",
"integrity": "sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==",
"license": "ISC",
"dependencies": {
"kdbush": "^4.0.2"
}
},
"node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
@@ -42689,9 +42715,9 @@
"dev": true
},
"node_modules/svgo": {
"version": "3.3.5",
"resolved": "https://registry.npmjs.org/svgo/-/svgo-3.3.5.tgz",
"integrity": "sha512-8SQMzdrvWaD8deUmrnYB+ASyxBVgWUOilg+A75nE/76WdLpj6LopCwiAVvkzkcqy/9b7t2Mg7faFLjg0ZRcZ3w==",
"version": "3.3.4",
"resolved": "https://registry.npmjs.org/svgo/-/svgo-3.3.4.tgz",
"integrity": "sha512-GsNRis4e8jxn2Y9ENz/8lbJ93CstG8svtMnuRaHbiF2LTJ5tK0/q3t/URPq9Zc7zVWBJnNnJMIp6bevK7bSmNg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -47076,7 +47102,7 @@
"math-expression-evaluator": "^2.0.7",
"parse-ms": "^4.0.0",
"re-resizable": "^6.11.2",
"react-ace": "^15.0.0",
"react-ace": "^14.0.1",
"react-draggable": "^4.7.1",
"react-error-boundary": "^6.1.4",
"react-js-cron": "^6.0.2",
@@ -47177,14 +47203,15 @@
}
},
"packages/superset-ui-core/node_modules/react-ace": {
"version": "15.0.0",
"resolved": "https://registry.npmjs.org/react-ace/-/react-ace-15.0.0.tgz",
"integrity": "sha512-gdmS5ftF0hsbkcrAjZQqYbXwFg5JrHuxjki8mP6Bn9kfa6lHKfZk9sU9EfS0ifQ1CpGCmRxF/VC7GRvlJMBuZw==",
"version": "14.0.1",
"resolved": "https://registry.npmjs.org/react-ace/-/react-ace-14.0.1.tgz",
"integrity": "sha512-z6YAZ20PNf/FqmYEic//G/UK6uw0rn21g58ASgHJHl9rfE4nITQLqthr9rHMVQK4ezwohJbp2dGrZpkq979PYQ==",
"license": "MIT",
"dependencies": {
"ace-builds": "^1.36.3",
"diff-match-patch": "^1.0.5",
"fast-equals": "^5.3.3",
"lodash.get": "^4.4.2",
"lodash.isequal": "^4.5.0",
"prop-types": "^15.8.1"
},
"peerDependencies": {
@@ -47566,10 +47593,8 @@
"license": "Apache-2.0",
"dependencies": {
"@math.gl/web-mercator": "^4.1.0",
"@types/geojson": "^7946.0.16",
"@types/supercluster": "^7.1.3",
"mapbox-gl": "^3.29.0",
"maplibre-gl": "^6.8.0",
"maplibre-gl": "^5.24.0",
"react-map-gl": "^8.1.2",
"supercluster": "^9.0.0"
},
@@ -47712,7 +47737,7 @@
"handlebars": "^4.7.9",
"lodash": "^4.18.1",
"lodash-es": "^4.18.1",
"maplibre-gl": "^6.8.0",
"maplibre-gl": "^5.24.0",
"mousetrap": "^1.6.5",
"ngeohash": "^0.6.4",
"prop-types": "^15.8.1",
@@ -78,7 +78,7 @@
"math-expression-evaluator": "^2.0.7",
"parse-ms": "^4.0.0",
"re-resizable": "^6.11.2",
"react-ace": "^15.0.0",
"react-ace": "^14.0.1",
"react-draggable": "^4.7.1",
"react-error-boundary": "^6.1.4",
"react-js-cron": "^6.0.2",
@@ -566,104 +566,6 @@ test('should NOT refetch data when other string-based renderTrigger controls cha
});
});
test('should NOT refetch data when echart_options (string-based renderTrigger control) changes', async () => {
// Matches how the Timeseries/MixedTimeseries control panels reference this
// shared control: a bare string, e.g. ['echart_options'].
const controlPanelConfig = {
controlPanelSections: [
{
controlSetRows: [['echart_options']],
},
],
};
jest.mocked(getChartControlPanelRegistry).mockReturnValue({
get: jest.fn().mockReturnValue(controlPanelConfig),
} as unknown as ReturnType<typeof getChartControlPanelRegistry>);
const formDataWithEchartOptions = {
...mockFormData,
echart_options: '{}',
};
const { rerender, getByTestId } = render(
<StatefulChart
formData={formDataWithEchartOptions}
chartType="test_chart"
/>,
);
await waitFor(() => {
expect(mockChartClient.client.post).toHaveBeenCalledTimes(1);
});
// Edit the ECharts Options field (e.g. from the Customize tab while the
// chart is part of a Matrixify grid cell).
const updatedFormData = {
...formDataWithEchartOptions,
echart_options: '{"title": {"text": "My Chart"}}',
};
rerender(<StatefulChart formData={updatedFormData} chartType="test_chart" />);
await waitFor(() => {
// Should NOT refetch data - echart_options is a renderTrigger control
expect(mockChartClient.client.post).toHaveBeenCalledTimes(1);
// But should re-render with the new formData
expect(getByTestId('super-chart')).toHaveTextContent(
JSON.stringify(updatedFormData),
);
});
});
test('should refetch when a chart overrides a shared renderTrigger control to renderTrigger: false', async () => {
// Matches Country Map's controlPanel.controlOverrides, which sets
// linear_color_scheme to renderTrigger: false because it drives the
// choropleth data query rather than just styling.
const controlPanelConfig = {
controlPanelSections: [
{
controlSetRows: [['linear_color_scheme']],
},
],
controlOverrides: {
linear_color_scheme: {
renderTrigger: false,
},
},
};
jest.mocked(getChartControlPanelRegistry).mockReturnValue({
get: jest.fn().mockReturnValue(controlPanelConfig),
} as unknown as ReturnType<typeof getChartControlPanelRegistry>);
const formDataWithColorScheme = {
...mockFormData,
linear_color_scheme: 'schemeA',
};
const { rerender } = render(
<StatefulChart formData={formDataWithColorScheme} chartType="test_chart" />,
);
await waitFor(() => {
expect(mockChartClient.client.post).toHaveBeenCalledTimes(1);
});
const updatedFormData = {
...formDataWithColorScheme,
linear_color_scheme: 'schemeB',
};
rerender(<StatefulChart formData={updatedFormData} chartType="test_chart" />);
await waitFor(() => {
// Should refetch because this chart's controlOverrides mark the control
// as data-affecting, overriding the shared-control fallback.
expect(mockChartClient.client.post).toHaveBeenCalledTimes(2);
});
});
test('should refetch when string control is NOT in RENDER_TRIGGER_SHARED_CONTROLS', async () => {
// Control panel with a string control that is NOT in the renderTrigger set
const controlPanelConfig = {
@@ -50,9 +50,6 @@ type LoadingState = 'uninitialized' | 'loading' | 'loaded' | 'error';
* This list is needed because string-based control references (e.g., ['zoomable'])
* cannot be introspected for their renderTrigger property without importing
* sharedControls, which would create a circular dependency.
*
* Keep this list in sync with the `renderTrigger: true` entries in
* @superset-ui/chart-controls's sharedControls.tsx.
*/
const RENDER_TRIGGER_SHARED_CONTROLS = new Set([
'zoomable',
@@ -60,11 +57,6 @@ const RENDER_TRIGGER_SHARED_CONTROLS = new Set([
'time_shift_color',
'y_axis_format',
'currency_format',
'color_picker',
'linear_color_scheme',
'x_axis_time_format',
'x_axis_number_format',
'echart_options',
]);
/**
@@ -119,28 +111,6 @@ function shouldRefetchData(
}
});
// Individual chart types can override a shared control's renderTrigger
// behavior (e.g., Country Map sets `linear_color_scheme` to
// renderTrigger: false because it drives the choropleth query, not just
// styling). Apply those overrides on top of the shared-control fallback
// so such controls still trigger a refetch for that chart type.
const { controlOverrides } = controlPanel;
if (controlOverrides) {
Object.entries(controlOverrides).forEach(([controlName, override]) => {
if (
override &&
typeof override === 'object' &&
'renderTrigger' in override
) {
if ((override as { renderTrigger?: boolean }).renderTrigger) {
renderTriggerControls.add(controlName);
} else {
renderTriggerControls.delete(controlName);
}
}
});
}
// Check which fields changed
const changedFields = Object.keys(nextFormData).filter(
key =>
@@ -604,61 +604,6 @@ test('cleans up event listeners on unmount', async () => {
offSpy.mockRestore();
});
test('re-applies annotations only when their content actually changes across renders (react-ace 15 fast-equals regression guard)', async () => {
// react-ace's componentDidUpdate decides whether to call
// session.setAnnotations() by deep-comparing the new/old `annotations`
// prop (lib/ace.js, using an internal deep-equality helper -- lodash's
// isEqual through react-ace 14.x, fast-equals's deepEqual from 15.0.0
// onward). Superset's own AceEditorProvider/EditorWrapper always pass a
// freshly `.map()`-derived annotations array on every render, so this
// guards the actual behavior Superset relies on: a same-content-but-
// different-reference array must NOT re-trigger setAnnotations (or the
// editor would thrash on every keystroke-driven re-render), while a
// genuinely different array must still update the editor.
const ref = createRef<AceEditor>();
const annotationsV1 = [{ row: 0, column: 0, type: 'error', text: 'oops' }];
const { rerender, container } = render(
<SQLEditor ref={ref as React.Ref<never>} annotations={annotationsV1} />,
);
await waitFor(() => {
expect(container.querySelector(selector)).toBeInTheDocument();
});
const session = ref.current?.editor?.getSession();
expect(session).toBeDefined();
if (!session) return;
// The initial mount already applies annotations via componentDidMount,
// not componentDidUpdate, so start observing only from the first update.
const setAnnotationsSpy = jest.spyOn(session, 'setAnnotations');
// Same content, new array/object references -- must be a no-op.
const annotationsV1SameContent = [
{ row: 0, column: 0, type: 'error', text: 'oops' },
];
rerender(
<SQLEditor
ref={ref as React.Ref<never>}
annotations={annotationsV1SameContent}
/>,
);
expect(setAnnotationsSpy).not.toHaveBeenCalled();
// Genuinely different content -- must update, with the new value.
const annotationsV2 = [
{ row: 1, column: 2, type: 'warning', text: 'different' },
];
rerender(
<SQLEditor ref={ref as React.Ref<never>} annotations={annotationsV2} />,
);
expect(setAnnotationsSpy).toHaveBeenCalledTimes(1);
expect(setAnnotationsSpy).toHaveBeenCalledWith(annotationsV2);
setAnnotationsSpy.mockRestore();
});
test('does not move autocomplete popup if target container is document.body', async () => {
const ref = createRef<AceEditor>();
const { container } = render(<SQLEditor ref={ref as React.Ref<never>} />);
@@ -1,102 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { Icons } from '../Icons';
import { Button } from '../Button';
import MetadataBar, { MetadataType } from '../MetadataBar';
import { Menu } from '../Menu';
import { PageHeaderWithActions, PageHeaderWithActionsProps } from '.';
export default {
title: 'Design System/Components/PageHeaderWithActions',
component: PageHeaderWithActions,
parameters: {
layout: 'fullscreen',
docs: {
description: {
component:
'Header used on entity pages (e.g. the dashboard page) combining an editable title with badges, a metadata bar, and page-level actions.',
},
},
},
};
// Mirrors src/dashboard/components/Header's real composition: an editable
// title, the certified badge, and a titlePanelAdditionalItems cluster of a
// refresh button, an auto-refresh indicator, a published-status toggle, and
// a MetadataBar (Last Modified + Editor) -- the same items the real
// dashboard header packs into that space -- so this story reproduces the
// header's real narrow-viewport layout behavior, not just the isolated
// MetadataBar. The real RefreshButton/AutoRefreshIndicator/PublishedStatus
// components live in src/dashboard and depend on this package, so they
// can't be imported here without inverting that dependency; these are
// same-sized stand-ins built from core components instead.
export const DashboardHeader = (args: PageHeaderWithActionsProps) => (
<PageHeaderWithActions {...args} />
);
DashboardHeader.args = {
editableTitleProps: {
title: 'Q3 Executive Revenue and Growth Overview Dashboard',
placeholder: 'Add the name of the dashboard',
onSave: () => {},
canEdit: true,
label: 'Dashboard title',
},
showTitlePanelItems: true,
certificatiedBadgeProps: {
certifiedBy: 'Jane Doe',
details: 'Certified by the BI team',
},
showFaveStar: true,
faveStarProps: { itemId: 1, saveFaveStar: () => {}, isStarred: false },
titlePanelAdditionalItems: [
<Button key="refresh-button" buttonStyle="link" tooltip="Refresh dashboard">
<Icons.ReloadOutlined iconSize="l" />
</Button>,
<Icons.SyncOutlined key="auto-refresh-indicator" iconSize="l" />,
<Button key="published-status" buttonStyle="link">
Published
</Button>,
<MetadataBar
key="metadata-bar"
tooltipPlacement="bottom"
items={[
{
type: MetadataType.LastModified,
value: '2 hours ago',
modifiedBy: 'Jane Doe',
},
{
type: MetadataType.Editor,
createdBy: 'Jane Doe',
editors: ['Jane Doe', 'John Smith'],
createdOn: 'a week ago',
},
]}
/>,
],
rightPanelAdditionalItems: <button type="button">Edit dashboard</button>,
additionalActionsMenu: (
<Menu
items={[{ label: 'Edit properties', key: '1' }]}
data-test="additional-actions-menu"
/>
),
menuDropdownProps: {},
};
@@ -18,12 +18,7 @@
*/
import { render, screen, userEvent } from '@superset-ui/core/spec';
import { supersetTheme } from '@apache-superset/core/theme';
import {
buttonsStyles,
PageHeaderWithActions,
PageHeaderWithActionsProps,
} from './index';
import { PageHeaderWithActions, PageHeaderWithActionsProps } from './index';
import { Menu } from '../Menu';
const defaultProps: PageHeaderWithActionsProps = {
@@ -59,15 +54,3 @@ test('Renders', async () => {
await userEvent.click(screen.getByLabelText('Menu actions trigger'));
expect(defaultProps.menuDropdownProps.onOpenChange).toHaveBeenCalled();
});
test('clips the title panel buttons/metadata cluster instead of letting it overflow into the actions menu', () => {
// jsdom doesn't compute real flexbox layout, so it can't verify the
// overlap itself is fixed; this guards the underlying CSS from
// regressing instead. Without `overflow: hidden`, this wrapper's
// automatic flex minimum size is based on its content rather than 0, so
// it refuses to shrink -- forcing the title to absorb all the space
// pressure until the cluster's content renders outside its box and
// overlaps the actions menu once the title has fully collapsed.
const { styles } = buttonsStyles(supersetTheme);
expect(styles).toMatch(/overflow:\s*hidden/);
});
@@ -99,13 +99,9 @@ const headerStyles = (theme: SupersetTheme) => css`
}
`;
// Exported only so PageHeaderWithActions.test.tsx can assert on the
// `overflow: hidden` declaration directly; not part of the component's
// public API.
export const buttonsStyles = (theme: SupersetTheme) => css`
const buttonsStyles = (theme: SupersetTheme) => css`
display: flex;
align-items: center;
overflow: hidden;
padding-left: ${theme.sizeUnit * 2}px;
& .anticon-star {
@@ -19,7 +19,6 @@
export { default as TimeFormats, LOCAL_PREFIX } from './TimeFormats';
export { default as TimeFormatter, PREVIEW_TIME } from './TimeFormatter';
export { default as DateWithFormatter } from './DateWithFormatter';
export { DEFAULT_D3_TIME_FORMAT } from './D3FormatConfig';
export {
@@ -28,17 +28,8 @@ export default function stringifyTimeInput(
let time: Date;
if (typeof value === 'string') {
const trimmed = value.trim();
// A bare four-digit string is the ISO 8601 year-only form ("2017"), which
// every engine parses as January 1st of that year. Any other integer
// string is an epoch timestamp in milliseconds that was stringified on
// its way here, e.g. by the pivot table, and is not a valid Date input.
const isYear = /^\d{4}$/.test(trimmed);
const isIntegerString = /^-?\d+$/.test(trimmed);
if (isYear) {
time = new Date(trimmed);
} else {
time = new Date(isIntegerString ? Number(trimmed) : value);
}
time = new Date(isIntegerString ? Number(trimmed) : value);
} else {
time = value instanceof Date ? value : new Date(value);
}
@@ -46,9 +37,7 @@ export default function stringifyTimeInput(
// An input that does not resolve to a valid date - a duration such as
// "00:01:54", for instance - would otherwise be formatted from an Invalid
// Date and render as "NaN:NaN:NaN". Fall back to its own representation,
// as is already done for null and undefined above. For a `DateWithFormatter`
// this calls its `toString()`, which returns the original input rather than
// re-entering the formatter; that guard is what keeps the fallback finite.
// as is already done for null and undefined above.
if (Number.isNaN(time.getTime())) {
return `${value}`;
}
@@ -16,7 +16,6 @@
* specific language governing permissions and limitations
* under the License.
*/
import { DateWithFormatter, getTimeFormatter } from '@superset-ui/core';
import stringifyTimeInput from '../../../src/time-format/utils/stringifyTimeInput';
const format = (time: Date) => time.toISOString();
@@ -57,25 +56,3 @@ test('returns unparseable strings unchanged instead of formatting an Invalid Dat
test('returns the representation of a Date that could not be resolved', () => {
expect(stringifyTimeInput(new Date('00:01:54'), format)).toBe('Invalid Date');
});
test('treats a four-digit integer string as a year, not as milliseconds', () => {
// "2017" is the ISO 8601 year-only form. Reading it as an epoch offset
// would silently turn it into two seconds past 1970.
expect(stringifyTimeInput('2017', format)).toBe('2017-01-01T00:00:00.000Z');
expect(stringifyTimeInput(' 1987 ', format)).toBe('1987-01-01T00:00:00.000Z');
// Longer digit strings stay epoch milliseconds.
expect(stringifyTimeInput('1704067200000', format)).toBe(
'2024-01-01T00:00:00.000Z',
);
});
test('returns the original input of an unparseable DateWithFormatter without re-entering the formatter', () => {
// The `${value}` fallback calls `DateWithFormatter.toString()`, which must
// return the input rather than call the formatter again, or the two would
// recurse until the stack overflows.
const formatter = getTimeFormatter('%H:%M:%S');
const value = new DateWithFormatter('00:01:54', { formatter });
expect(stringifyTimeInput(value, time => formatter(time))).toBe('00:01:54');
expect(formatter(value)).toBe('00:01:54');
});
@@ -36,7 +36,6 @@ import {
TimeFormatter,
AgGridChartState,
AgGridFilterModel,
DateWithFormatter,
} from '@superset-ui/core';
import { GenericDataType } from '@apache-superset/core/common';
import { isEmpty, isEqual, merge } from 'lodash-es';
@@ -46,6 +45,7 @@ import {
ColorSchemeEnum,
} from '@superset-ui/chart-controls';
import isEqualColumns from './utils/isEqualColumns';
import DateWithFormatter from './utils/DateWithFormatter';
import { BASIC_COLOR_FORMATTERS_ROW_KEY } from './consts';
import {
DataColumnMeta,
@@ -16,18 +16,15 @@
* specific language governing permissions and limitations
* under the License.
*/
import type { DataRecordValue } from '../query/types/QueryResponse';
import type { TimeFormatFunction } from './types';
import normalizeTimestamp from './utils/normalizeTimestamp';
import {
DataRecordValue,
normalizeTimestamp,
TimeFormatFunction,
} from '@superset-ui/core';
/**
* Extended Date object with a custom formatter, and retains the original input
* when the formatter is simple `String(..)`.
*
* `toString()` never formats an Invalid Date: it returns the original input
* instead. `stringifyTimeInput` relies on that when it falls back to
* `${value}` for an unparseable input, otherwise the two would call each other
* forever.
*/
export default class DateWithFormatter extends Date {
formatter: TimeFormatFunction;
@@ -22,7 +22,6 @@ import {
isDefined,
isProbablyHTML,
sanitizeHtml,
DateWithFormatter,
} from '@superset-ui/core';
import { GenericDataType } from '@apache-superset/core/common';
import {
@@ -30,6 +29,7 @@ import {
ValueGetterParams,
} from '@superset-ui/core/components/ThemedAgGridReact';
import { DataColumnMeta, InputColumn } from '../types';
import DateWithFormatter from './DateWithFormatter';
/**
* Format text for cell value.
@@ -25,11 +25,7 @@ import {
CellClassParams,
} from '@superset-ui/core/components/ThemedAgGridReact';
import { useCallback, useMemo } from 'react';
import {
DataRecordValue,
DateWithFormatter,
JsonObject,
} from '@superset-ui/core';
import { DataRecordValue, JsonObject } from '@superset-ui/core';
import { GenericDataType } from '@apache-superset/core/common';
import { useTheme } from '@apache-superset/core/theme';
import { ColorFormatters } from '@superset-ui/chart-controls';
@@ -46,6 +42,7 @@ import htmlTextFilterValueGetter, {
htmlTextComparator,
} from './htmlTextFilterValueGetter';
import dateFilterComparator from './dateFilterComparator';
import DateWithFormatter from './DateWithFormatter';
import { getAggFunc } from './getAggFunc';
import { TextCellRenderer } from '../renderers/TextCellRenderer';
import { NumericCellRenderer } from '../renderers/NumericCellRenderer';
@@ -1,4 +1,4 @@
/**
/*
* 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
@@ -16,7 +16,11 @@
* specific language governing permissions and limitations
* under the License.
*/
import { DateWithFormatter, getTimeFormatter } from '@superset-ui/core';
import { getTimeFormatter } from '@superset-ui/core';
import { GenericDataType } from '@apache-superset/core/common';
import DateWithFormatter from '../../src/utils/DateWithFormatter';
import { formatColumnValue } from '../../src/utils/formatValue';
import { DataColumnMeta } from '../../src/types';
const formatter = getTimeFormatter('%H:%M:%S');
@@ -39,3 +43,18 @@ test('retains the original input when the formatter is String', () => {
const value = new DateWithFormatter('00:01:54');
expect(String(value)).toBe('00:01:54');
});
test('renders a duration cell through the column formatter without producing NaN', () => {
// The cell text is produced by formatColumnValue, which hands the wrapped
// value straight to the formatter rather than going through toString().
const column: DataColumnMeta = {
key: 'call_period',
label: 'call_period',
dataType: GenericDataType.Temporal,
formatter,
isNumeric: false,
};
const value = new DateWithFormatter('00:01:54', { formatter });
expect(formatColumnValue(column, value)).toEqual([false, '00:01:54']);
});
@@ -1,39 +0,0 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { DateWithFormatter, getTimeFormatter } from '@superset-ui/core';
import { GenericDataType } from '@apache-superset/core/common';
import { formatColumnValue } from '../../src/utils/formatValue';
import { DataColumnMeta } from '../../src/types';
const formatter = getTimeFormatter('%H:%M:%S');
test('renders a duration cell through the column formatter without producing NaN', () => {
// The cell text is produced by formatColumnValue, which hands the wrapped
// value straight to the formatter rather than going through toString().
const column: DataColumnMeta = {
key: 'call_period',
label: 'call_period',
dataType: GenericDataType.Temporal,
formatter,
isNumeric: false,
};
const value = new DateWithFormatter('00:01:54', { formatter });
expect(formatColumnValue(column, value)).toEqual([false, '00:01:54']);
});
@@ -44,23 +44,6 @@ import {
import { PivotData, flatKey } from './utilities';
import { Styles } from './Styles';
/**
* Pivot keys are stringified on their way through `PivotData`, so a temporal
* header holding an epoch timestamp arrives as e.g. "1700000000000". Coerce
* such numeric strings back to numbers so temporal formatters (which expect
* an epoch) render correctly. A bare four-digit string is the ISO 8601
* year-only form ("2017"), which the shared `stringifyTimeInput` in core
* reads as that calendar year; coercing it would turn the year into two
* seconds past 1970, so it is passed through untouched.
*/
const toDateFormatterInput = (value: unknown): unknown =>
typeof value === 'string' &&
value.trim() !== '' &&
!/^\d{4}$/.test(value.trim()) &&
Number.isFinite(Number(value))
? Number(value)
: value;
type ClickCallback = (
e: MouseEvent,
value: unknown,
@@ -1006,9 +989,15 @@ export function TableRenderer(props: TableRendererProps) {
/>
);
};
// Coerce numeric timestamp strings to numbers so temporal formatters
// (which typically expect an epoch) render correctly.
const rawHeaderCellValue = colKey[attrIdx];
const headerCellFormatterValue =
toDateFormatterInput(rawHeaderCellValue);
typeof rawHeaderCellValue === 'string' &&
rawHeaderCellValue.trim() !== '' &&
Number.isFinite(Number(rawHeaderCellValue))
? Number(rawHeaderCellValue)
: rawHeaderCellValue;
const headerCellFormattedValue =
dateFormatters?.[attrName]?.(headerCellFormatterValue) ??
rawHeaderCellValue;
@@ -1274,7 +1263,14 @@ export function TableRenderer(props: TableRendererProps) {
? toggleRowKey(flatRowKeySlice)
: null;
const headerFormatterValue = toDateFormatterInput(r);
// Coerce numeric timestamp strings to numbers so temporal formatters
// (which typically expect an epoch) render correctly.
const headerFormatterValue =
typeof r === 'string' &&
r.trim() !== '' &&
Number.isFinite(Number(r))
? Number(r)
: r;
const headerCellFormattedValue =
dateFormatters?.[settingsRowAttrs[i]]?.(headerFormatterValue) ?? r;
const isActiveHeader = valueCellClassName.includes('active');
@@ -21,7 +21,6 @@ import type { ReactElement } from 'react';
import '@testing-library/jest-dom';
import { fireEvent, render, screen } from '@testing-library/react';
import { supersetTheme, ThemeProvider } from '@apache-superset/core/theme';
import { getTimeFormatter } from '@superset-ui/core';
import { TableRenderer } from '../../src/react-pivottable/TableRenderers';
import {
aggregatorTemplates,
@@ -690,44 +689,6 @@ test('TableRenderer coerces numeric timestamp strings to numbers for row header
expect(screen.getByText('row:red')).toBeInTheDocument();
});
test('TableRenderer passes four-digit year strings through to column header date formatters uncoerced', () => {
const data = [
{ shape: '2017', color: 'blue', value: 1 },
{ shape: '1700000000000', color: 'blue', value: 2 },
];
const props = buildDefaultProps({
data,
rows: ['color'],
cols: ['shape'],
tableOptions: { dateFormatters: { shape: getTimeFormatter('%Y') } },
});
renderWithTheme(<TableRenderer {...props} />);
// "2017" is the ISO year-only form and must render as that year, not as
// 2.017 seconds past the epoch; a stringified epoch still coerces.
expect(screen.getByText('2017')).toBeInTheDocument();
expect(screen.getByText('2023')).toBeInTheDocument();
expect(screen.queryByText('1970')).not.toBeInTheDocument();
});
test('TableRenderer passes four-digit year strings through to row header date formatters uncoerced', () => {
const data = [
{ color: '2017', shape: 'circle', value: 1 },
{ color: '1700000000000', shape: 'circle', value: 2 },
];
const props = buildDefaultProps({
data,
rows: ['color'],
cols: ['shape'],
tableOptions: { dateFormatters: { color: getTimeFormatter('%Y') } },
});
renderWithTheme(<TableRenderer {...props} />);
expect(screen.getByText('2017')).toBeInTheDocument();
expect(screen.getByText('2023')).toBeInTheDocument();
expect(screen.queryByText('1970')).not.toBeInTheDocument();
});
test('TableRenderer applies cellColorFormatters background and contrast color to column headers', () => {
const cellColorFormatters = {
shape: [
@@ -30,10 +30,8 @@
},
"dependencies": {
"@math.gl/web-mercator": "^4.1.0",
"@types/geojson": "^7946.0.16",
"@types/supercluster": "^7.1.3",
"mapbox-gl": "^3.29.0",
"maplibre-gl": "^6.8.0",
"maplibre-gl": "^5.24.0",
"react-map-gl": "^8.1.2",
"supercluster": "^9.0.0"
},
@@ -103,42 +103,6 @@ const sortTypes = {
alphanumeric: sortAlphanumericCaseInsensitive,
};
// Prefer a stable identifier from original row data; otherwise use a deterministic
// concatenation of visible values (keys sorted so the result does not depend on
// column order).
function stableRowKey<D extends object>(r: Row<D>): string {
const orig = r.original as Record<string, unknown> | undefined;
if (orig) {
const idLike = orig.id ?? orig.ID ?? orig.key ?? orig.uuid;
if (idLike != null) return String(idLike);
}
// Fallback: derive from row.values, sorting the keys so that reordering the
// columns does not change the key.
const v = r.values as Record<string, unknown>;
const keys = Object.keys(v).sort();
return keys.map(k => String(v[k] ?? '')).join('|');
}
// Very small, fast hash for strings (no crypto dependency).
function hashString(s: string): string {
let h = 0;
for (let i = 0; i < s.length; i += 1) {
// oxlint-disable-next-line unicorn/prefer-math-trunc -- | 0 is intentional for 32-bit integer wrapping in hash
h = (h * 31 + s.charCodeAt(i)) | 0;
}
return String(h);
}
function signatureOfRows<D extends object>(rs: Row<D>[]): string {
const keys = rs.map(stableRowKey);
const len = keys.length;
const first = keys[0] ?? '';
const last = keys[len - 1] ?? '';
const digest = hashString(keys.join('\u0001')); // non-printable separator to avoid collisions
return `${len}|${first}|${last}|${digest}`;
}
// Be sure to pass our updateMyData and the skipReset option
export default typedMemo(function DataTable<D extends object>({
tableClassName,
@@ -325,46 +289,6 @@ export default typedMemo(function DataTable<D extends object>({
onFilteredDataChange(rowsRef.current, searchText);
}, [filterValue, onFilteredDataChange, rowSignature]);
// Emit filtered rows to parent in client-side mode (debounced via RAF)
const isMountedRef = useRef(true);
useEffect(() => {
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
};
}, []);
const rafRef = useRef<number | null>(null);
const lastSigRef = useRef<string>('');
useEffect(() => {
if (serverPagination || typeof onFilteredRowsChange !== 'function') {
return;
}
const sig = signatureOfRows(rows);
if (sig !== lastSigRef.current) {
lastSigRef.current = sig;
if (rafRef.current != null) {
cancelAnimationFrame(rafRef.current);
}
rafRef.current = requestAnimationFrame(() => {
if (isMountedRef.current) {
// Only emit originals when the signature truly changed
onFilteredRowsChange(rows.map(r => r.original as D));
}
});
}
return () => {
if (rafRef.current != null) {
cancelAnimationFrame(rafRef.current);
rafRef.current = null;
}
};
}, [rows, serverPagination, onFilteredRowsChange]);
const handleSearchChange = useCallback(
(query: string) => {
if (manualSearch && onSearchChange) {
@@ -548,6 +472,84 @@ export default typedMemo(function DataTable<D extends object>({
onServerPaginationChange(pageNumber, serverPageSize);
}
// Emit filtered rows to parent in client-side mode (debounced via RAF)
const isMountedRef = useRef(true);
useEffect(() => {
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
};
}, []);
const rafRef = useRef<number | null>(null);
const lastSigRef = useRef<string>('');
// Prefer a stable identifier from original row data; otherwise use a deterministic
// concatenation of visible values (keys sorted so column order changes are detected).
function stableRowKey<D extends object>(r: Row<D>): string {
const orig = r.original as Record<string, unknown> | undefined;
if (orig) {
const idLike =
(orig as any).id ??
(orig as any).ID ??
(orig as any).key ??
(orig as any).uuid;
if (idLike != null) return String(idLike);
}
// Fallback: derive from row.values, but make it stable against column order changes.
const v = r.values as Record<string, unknown>;
const keys = Object.keys(v).sort(); // detect column order changes
return keys.map(k => String(v[k] ?? '')).join('|');
}
// Very small, fast hash for strings (no crypto dependency).
function hashString(s: string): string {
let h = 0;
for (let i = 0; i < s.length; i += 1) {
// oxlint-disable-next-line unicorn/prefer-math-trunc -- | 0 is intentional for 32-bit integer wrapping in hash
h = (h * 31 + s.charCodeAt(i)) | 0;
}
return String(h);
}
function signatureOfRows<D extends object>(rs: Row<D>[]): string {
const keys = rs.map(stableRowKey);
const len = keys.length;
const first = keys[0] ?? '';
const last = keys[len - 1] ?? '';
const digest = hashString(keys.join('\u0001')); // non-printable separator to avoid collisions
return `${len}|${first}|${last}|${digest}`;
}
useEffect(() => {
if (serverPagination || typeof onFilteredRowsChange !== 'function') {
return;
}
const sig = signatureOfRows(rows);
if (sig !== lastSigRef.current) {
lastSigRef.current = sig;
if (rafRef.current != null) {
cancelAnimationFrame(rafRef.current);
}
rafRef.current = requestAnimationFrame(() => {
if (isMountedRef.current) {
// Only emit originals when the signature truly changed
onFilteredRowsChange(rows.map(r => r.original as D));
}
});
}
return () => {
if (rafRef.current != null) {
cancelAnimationFrame(rafRef.current);
rafRef.current = null;
}
};
}, [rows, serverPagination, onFilteredRowsChange]);
return (
<div
ref={wrapperRef}
@@ -47,7 +47,6 @@ import {
extractTextFromHTML,
TimeGranularity,
forceHexAlpha,
DateWithFormatter,
} from '@superset-ui/core';
import {
styled,
@@ -99,6 +98,7 @@ import { formatColumnValue } from './utils/formatValue';
import { PAGE_SIZE_OPTIONS, SERVER_PAGE_SIZE_OPTIONS } from './consts';
import { updateTableOwnState } from './DataTable/utils/externalAPIs';
import getScrollBarSize from './DataTable/utils/getScrollBarSize';
import DateWithFormatter from './utils/DateWithFormatter';
type ValueRange = [number, number];
@@ -36,7 +36,6 @@ import {
SMART_DATE_ID,
TimeFormats,
TimeFormatter,
DateWithFormatter,
} from '@superset-ui/core';
import { GenericDataType } from '@apache-superset/core/common';
import {
@@ -48,6 +47,7 @@ import {
import { isEmpty, merge } from 'lodash-es';
import isEqualColumns from './utils/isEqualColumns';
import DateWithFormatter from './utils/DateWithFormatter';
import {
BasicColorFormatterType,
DataColumnMeta,
@@ -0,0 +1,62 @@
/**
* 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 {
DataRecordValue,
normalizeTimestamp,
TimeFormatFunction,
} from '@superset-ui/core';
/**
* Extended Date object with a custom formatter, and retains the original input
* when the formatter is simple `String(..)`.
*/
export default class DateWithFormatter extends Date {
formatter: TimeFormatFunction;
input: DataRecordValue;
constructor(
input: DataRecordValue,
{ formatter = String }: { formatter?: TimeFormatFunction } = {},
) {
let value = input;
// assuming timestamps without a timezone is in UTC time
if (typeof value === 'string') {
value = normalizeTimestamp(value);
}
super(value as string);
this.input = input;
this.formatter = formatter;
this.toString = (): string => {
if (this.formatter === String) {
return String(this.input);
}
// Values that are not parseable timestamps - durations such as
// "00:01:54" or "0 days 00:01:54", for instance - produce an Invalid
// Date, and formatting one renders as "NaN:NaN:NaN". Fall back to the
// original value instead.
if (Number.isNaN(this.getTime())) {
return String(this.input);
}
return this.formatter ? this.formatter(this) : Date.toString.call(this);
};
}
}
@@ -22,10 +22,10 @@ import {
getSmallNumberFormatter,
isProbablyHTML,
sanitizeHtml,
DateWithFormatter,
} from '@superset-ui/core';
import { GenericDataType } from '@apache-superset/core/common';
import { DataColumnMeta } from '../types';
import DateWithFormatter from './DateWithFormatter';
/**
* Format text for cell value.
@@ -1,129 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import '@testing-library/jest-dom';
import { Component, type ReactNode } from 'react';
import { render, screen } from '@superset-ui/core/spec';
import { CellProps, Column, HeaderProps } from 'react-table';
import DataTable from '../../src/DataTable/DataTable';
import { ProviderWrapper } from '../testHelpers';
type DataRow = {
city: string;
firstName: string;
};
interface RenderErrorBoundaryProps {
children: ReactNode;
}
interface RenderErrorBoundaryState {
hasError: boolean;
}
const columns: Column<DataRow>[] = [
{
Header: ({ column }: HeaderProps<DataRow>) => (
<th data-column-name={column.id}>First name</th>
),
Cell: ({ value }: CellProps<DataRow>) => <td>{value}</td>,
id: 'firstName',
accessor: 'firstName' as never,
},
{
Header: ({ column }: HeaderProps<DataRow>) => (
<th data-column-name={column.id}>City</th>
),
Cell: ({ value }: CellProps<DataRow>) => <td>{value}</td>,
id: 'city',
accessor: 'city' as never,
},
];
const data: DataRow[] = [
{ firstName: 'Michael', city: 'Paris' },
{ firstName: 'Jordan', city: 'London' },
];
// Turns a render-phase throw, such as a Rules of Hooks violation, into a
// readable assertion instead of an unhandled error.
class RenderErrorBoundary extends Component<
RenderErrorBoundaryProps,
RenderErrorBoundaryState
> {
state: RenderErrorBoundaryState = {
hasError: false,
};
static getDerivedStateFromError() {
return { hasError: true };
}
render() {
if (this.state.hasError) {
// data-test is the configured testIdAttribute (see spec/helpers/setup.ts),
// so *ByTestId('render-error') resolves this node.
return <div data-test="render-error">Render error</div>;
}
return this.props.children;
}
}
const renderDataTable = (tableColumns: Column<DataRow>[]) => (
<ProviderWrapper>
<RenderErrorBoundary>
<DataTable<DataRow>
columns={tableColumns}
data={data}
rowCount={data.length}
serverPagination={false}
serverPaginationData={{}}
onServerPaginationChange={jest.fn()}
handleSortByChange={jest.fn()}
sortByFromParent={[]}
onSearchColChange={jest.fn()}
searchOptions={[]}
onFilteredRowsChange={jest.fn()}
sticky={false}
/>
</RenderErrorBoundary>
</ProviderWrapper>
);
test('keeps the hook order stable when the columns disappear', () => {
const { rerender } = render(renderDataTable(columns));
expect(screen.getByText('Michael')).toBeInTheDocument();
rerender(renderDataTable([]));
expect(screen.queryByTestId('render-error')).not.toBeInTheDocument();
expect(screen.getByText('No data found')).toBeInTheDocument();
});
test('keeps the hook order stable when the columns appear', () => {
const { rerender } = render(renderDataTable([]));
expect(screen.getByText('No data found')).toBeInTheDocument();
rerender(renderDataTable(columns));
expect(screen.queryByTestId('render-error')).not.toBeInTheDocument();
expect(screen.getByText('Michael')).toBeInTheDocument();
});
@@ -40,13 +40,13 @@ import {
TimeGranularity,
SMART_DATE_ID,
getTimeFormatterForGranularity,
DateWithFormatter,
} from '@superset-ui/core';
import { CellProps, Column, HeaderProps } from 'react-table';
import DataTable from '../src/DataTable/DataTable';
import TableChart, { sanitizeHeaderId } from '../src/TableChart';
import { GenericDataType } from '@apache-superset/core/common';
import transformProps from '../src/transformProps';
import DateWithFormatter from '../src/utils/DateWithFormatter';
import testData from './testData';
import { ProviderWrapper } from './testHelpers';
@@ -16,13 +16,34 @@
* specific language governing permissions and limitations
* under the License.
*/
import { DateWithFormatter, getTimeFormatter } from '@superset-ui/core';
import { getTimeFormatter } from '@superset-ui/core';
import { GenericDataType } from '@apache-superset/core/common';
import DateWithFormatter from '../../src/utils/DateWithFormatter';
import { formatColumnValue } from '../../src/utils/formatValue';
import { DataColumnMeta } from '../../src/types';
const formatter = getTimeFormatter('%H:%M:%S');
test('formats a parseable timestamp with the configured formatter', () => {
const value = new DateWithFormatter('2017-02-14T11:22:33Z', { formatter });
expect(String(value)).toBe('11:22:33');
});
test('renders the original value when it is not a parseable timestamp', () => {
// Duration columns hold values like these. They produce an Invalid Date,
// which used to be formatted and rendered as "NaN:NaN:NaN".
['00:01:54', '0 days 00:01:54'].forEach(input => {
const value = new DateWithFormatter(input, { formatter });
expect(Number.isNaN(value.getTime())).toBe(true);
expect(String(value)).toBe(input);
});
});
test('retains the original input when the formatter is String', () => {
const value = new DateWithFormatter('00:01:54');
expect(String(value)).toBe('00:01:54');
});
test('renders a duration cell through the column formatter without producing NaN', () => {
// The cell text is produced by formatColumnValue, which hands the wrapped
// value straight to the formatter rather than going through toString().
@@ -49,7 +49,7 @@
"handlebars": "^4.7.9",
"lodash": "^4.18.1",
"lodash-es": "^4.18.1",
"maplibre-gl": "^6.8.0",
"maplibre-gl": "^5.24.0",
"mousetrap": "^1.6.5",
"ngeohash": "^0.6.4",
"prop-types": "^15.8.1",
@@ -1,101 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { TextEncoder } from 'util';
import {
measureGuestToken,
guestAuthenticationMessage,
} from './guestTokenDiagnostics';
beforeAll(() => {
Object.assign(global, { TextEncoder });
});
test.each([
[20, true],
[21, false],
[22, false],
[null, false],
[0, false],
[-1, false],
])('header budget %s: exceeded=%s', (budget, exceeded) => {
expect(measureGuestToken('é'.repeat(3), 'X-Custom-É', budget)).toEqual({
tokenBytes: 6,
headerBytes: 21,
headerBudgetBytes: budget && budget > 0 ? budget : null,
headerBudgetExceeded: exceeded,
});
});
test('default header accounting and safe metadata only', () => {
const size = measureGuestToken('secret-token', undefined, 1);
expect(size.headerBytes).toBe(28);
expect(JSON.stringify(size)).not.toContain('secret-token');
expect(guestAuthenticationMessage(size)).toContain('may exceed');
});
test('no size evidence uses generic authentication message', () => {
expect(guestAuthenticationMessage()).not.toContain('may exceed');
expect(guestAuthenticationMessage(measureGuestToken('t'))).not.toContain(
'may exceed',
);
});
test.each([
['16384', null],
['invalid', null],
[true, null],
[false, null],
[[], null],
[{}, null],
[20.5, null],
[NaN, null],
[Infinity, null],
[-Infinity, null],
[2 ** 53, null],
[Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER],
[16384.0, 16384],
])('normalizes configured budget %p to %p', (configured, expected) => {
const size = measureGuestToken('t', undefined, configured);
expect(size.headerBudgetBytes).toBe(expected);
expect(size.headerBudgetExceeded).toBe(false);
});
test.each([undefined, 400, 431, 494])(
'uses header-size evidence for status %p',
status => {
expect(
guestAuthenticationMessage(measureGuestToken('t', undefined, 1), {
status,
}),
).toContain('may exceed');
expect(
guestAuthenticationMessage(measureGuestToken('t', undefined, 100), {
status,
}),
).not.toContain('may exceed');
},
);
test.each([401, 403, 413, 500])('keeps status %p generic', status => {
expect(
guestAuthenticationMessage(measureGuestToken('t', undefined, 1), {
status,
}),
).not.toContain('may exceed');
});
@@ -1,71 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { t } from '@apache-superset/core/translation';
export type GuestTokenSize = {
tokenBytes: number;
headerBytes: number;
headerBudgetBytes: number | null;
headerBudgetExceeded: boolean;
};
/** Measure the encoded token without decoding or retaining credentials. */
export function measureGuestToken(
token: string,
headerName = 'X-GuestToken',
budget?: unknown,
): GuestTokenSize {
const encoder = new TextEncoder();
const tokenBytes = encoder.encode(token).length;
// HTTP/1-style accounting: name + ": " + value + CRLF, not wire compression.
const headerBytes = tokenBytes + encoder.encode(headerName).length + 4;
const headerBudgetBytes =
typeof budget === 'number' && Number.isSafeInteger(budget) && budget > 0
? budget
: null;
return {
tokenBytes,
headerBytes,
headerBudgetBytes,
headerBudgetExceeded:
headerBudgetBytes !== null && headerBytes > headerBudgetBytes,
};
}
/** Diagnose from size evidence only; never inspect or log proxy response bodies. */
export function guestAuthenticationMessage(
size?: GuestTokenSize,
error?: unknown,
): string {
const status =
typeof error === 'object' && error !== null && 'status' in error
? error.status
: undefined;
// Explicit auth/server failures have other causes. Some non-JSON proxy
// failures lose their status during parsing, so size remains the evidence.
const possibleHeaderFailure =
status === undefined || status === 400 || status === 431 || status === 494;
return size?.headerBudgetExceeded && possibleHeaderFailure
? t(
'Embedded authentication failed. The guest token may exceed the request-header size limit. Reduce the token payload; large inline RLS lists can be replaced with an entitlements-table lookup.',
)
: t(
'Something went wrong with embedded authentication. Check the dev console for details.',
);
}
+47 -150
View File
@@ -18,14 +18,7 @@
*/
// Mark this file as a module so its top-level declarations stay file-scoped
// (the file has no imports; modules are loaded via require() inside tests).
import { TextEncoder } from 'util';
Object.assign(global, { TextEncoder });
const mockConfig = {
GUEST_TOKEN_HEADER_NAME: 'X-Custom-Guest',
GUEST_TOKEN_HEADER_MAX_BYTES: 100,
};
export {};
// Stable mock references so they survive jest.resetModules() between tests
// (a factory-created jest.fn() would otherwise be replaced on each reset,
@@ -66,14 +59,13 @@ jest.mock('src/components/UiConfigContext', () => ({
// Capture the guestToken handler that start() is wired to, so tests can
// re-trigger the handshake and assert the retry behavior.
const mockSwitchboardInit = jest.fn();
const mockSwitchboard = {
handler: undefined as ((arg: { guestToken: string }) => void) | undefined,
};
jest.mock('@superset-ui/switchboard', () => ({
__esModule: true,
default: {
init: mockSwitchboardInit,
init: jest.fn(),
start: jest.fn(),
defineMethod: (name: string, fn: (arg: { guestToken: string }) => void) => {
if (name === 'guestToken') {
@@ -84,8 +76,7 @@ jest.mock('@superset-ui/switchboard', () => ({
},
}));
const mockSetupClient = jest.fn();
jest.mock('src/setup/setupClient', () => mockSetupClient, { virtual: true });
jest.mock('src/setup/setupClient', () => jest.fn(), { virtual: true });
jest.mock('src/views/store', () => ({
store: {
@@ -122,7 +113,6 @@ jest.mock('react-dom/client', () => ({
jest.mock('src/utils/getBootstrapData', () => ({
__esModule: true,
default: () => ({
config: mockConfig,
embedded: { dashboard_id: '123', allowed_domains: [] },
common: {
application_root: '/',
@@ -157,148 +147,55 @@ function sendHandshake() {
);
}
beforeEach(() => {
jest.resetModules();
mockSwitchboard.handler = undefined;
mockSetupPlugins.mockReset();
mockSetupAGGridModules.mockReset();
mockLogging.error.mockClear();
mockGetMeWithRole.mockReset();
mockGetMeWithRole.mockResolvedValue({ result: { roles: {} } });
document.body.innerHTML = '<div id="app"></div>';
});
describe('embedded/index.tsx', () => {
beforeEach(() => {
jest.resetModules();
mockSwitchboard.handler = undefined;
mockSetupPlugins.mockReset();
mockSetupAGGridModules.mockReset();
mockLogging.error.mockClear();
mockGetMeWithRole.mockReset();
mockGetMeWithRole.mockResolvedValue({ result: { roles: {} } });
document.body.innerHTML = '<div id="app"></div>';
});
test('initializes AG Grid modules on bootstrap', async () => {
mockSetupPlugins.mockImplementation(() => undefined);
require('./index');
await flush();
expect(mockSetupAGGridModules).toHaveBeenCalled();
});
test('retries plugin setup after setupPlugins rejects, then bootstraps the user', async () => {
// First plugin setup throws; the second attempt (after a re-handshake) succeeds.
mockSetupPlugins
.mockImplementationOnce(() => {
throw new Error('setupPlugins failed');
})
.mockImplementation(() => undefined);
require('./index');
await flush();
sendHandshake();
expect(mockSwitchboard.handler).toBeDefined();
// First guest token: plugin setup rejects, start() resets the guard and
// recreates pluginsReady so a retry can re-run setup.
mockSwitchboard.handler!({ guestToken: 'token-1' });
await flush();
expect(mockLogging.error).toHaveBeenCalled();
expect(mockGetMeWithRole).not.toHaveBeenCalled();
// The user gets a visible failure message rather than a blank #app.
expect(document.getElementById('app')!.innerHTML).toContain(
'Something went wrong loading the dashboard',
);
// Second guest token retries: plugin setup now succeeds and the user loads.
mockSwitchboard.handler!({ guestToken: 'token-2' });
await flush();
expect(mockSetupPlugins).toHaveBeenCalledTimes(2);
expect(mockGetMeWithRole).toHaveBeenCalled();
});
test.each([
['short', { status: 400, text: '<html>proxy error</html>' }, false],
['short', { status: 401 }, false],
['x'.repeat(100), { status: 401 }, false],
['x'.repeat(100), { status: 500 }, false],
['x'.repeat(100), new SyntaxError('private response body'), true],
])(
'authentication failure uses size evidence, not response content',
async (token, error, targeted) => {
mockGetMeWithRole.mockRejectedValue(error);
test('initializes AG Grid modules on bootstrap', async () => {
mockSetupPlugins.mockImplementation(() => undefined);
require('./index');
await flush();
expect(mockSetupAGGridModules).toHaveBeenCalled();
});
test('retries plugin setup after setupPlugins rejects, then bootstraps the user', async () => {
// First plugin setup throws; the second attempt (after a re-handshake) succeeds.
mockSetupPlugins
.mockImplementationOnce(() => {
throw new Error('setupPlugins failed');
})
.mockImplementation(() => undefined);
require('./index');
await flush();
sendHandshake();
mockSwitchboard.handler!({ guestToken: token });
expect(mockSwitchboard.handler).toBeDefined();
// First guest token: plugin setup rejects, start() resets the guard and
// recreates pluginsReady so a retry can re-run setup.
mockSwitchboard.handler!({ guestToken: 'token-1' });
await flush();
expect(
document.getElementById('app')!.textContent?.includes('may exceed'),
).toBe(targeted);
expect(JSON.stringify(mockLogging.error.mock.calls)).not.toContain(
'private response body',
expect(mockLogging.error).toHaveBeenCalled();
expect(mockGetMeWithRole).not.toHaveBeenCalled();
// The user gets a visible failure message rather than a blank #app.
expect(document.getElementById('app')!.innerHTML).toContain(
'Something went wrong loading the dashboard',
);
expect(JSON.stringify(mockLogging.error.mock.calls)).not.toContain(
'<html>',
);
// Failed authentication still permits the existing retry.
mockGetMeWithRole.mockResolvedValue({ result: { roles: {} } });
mockSwitchboard.handler!({ guestToken: 'replacement' });
// Second guest token retries: plugin setup now succeeds and the user loads.
mockSwitchboard.handler!({ guestToken: 'token-2' });
await flush();
expect(mockGetMeWithRole).toHaveBeenCalledTimes(2);
},
);
test('oversized refresh updates diagnostics without restarting successful auth', async () => {
mockLogging.warn.mockClear();
require('./index');
await flush();
sendHandshake();
mockSwitchboard.handler!({ guestToken: 'short' });
await flush();
mockSwitchboard.handler!({ guestToken: 'x'.repeat(100) });
await flush();
expect(mockGetMeWithRole).toHaveBeenCalledTimes(1);
expect(mockLogging.warn).toHaveBeenLastCalledWith(
'Guest token exceeds configured request-header budget',
{
tokenBytes: 100,
headerBytes: 118,
headerBudgetBytes: 100,
headerBudgetExceeded: true,
},
);
expect(mockSetupClient).toHaveBeenLastCalledWith(
expect.objectContaining({
guestToken: 'x'.repeat(100),
guestTokenHeaderName: 'X-Custom-Guest',
}),
);
});
test('refresh during pending authentication does not misattribute size evidence', async () => {
let rejectRequest: (error: unknown) => void = () => {};
mockGetMeWithRole.mockReturnValue(
new Promise((_resolve, reject) => {
rejectRequest = reject;
}),
);
require('./index');
await flush();
sendHandshake();
mockSwitchboard.handler!({ guestToken: 'x'.repeat(100) });
await flush();
mockSwitchboard.handler!({ guestToken: 'short' });
rejectRequest({ status: 400 });
await flush();
expect(document.getElementById('app')!.textContent).not.toContain(
'may exceed',
);
expect(mockGetMeWithRole).toHaveBeenCalledTimes(1);
// Clearing the guard after failure lets a subsequent token retry authentication.
mockGetMeWithRole.mockResolvedValue({ result: { roles: {} } });
mockSwitchboard.handler!({ guestToken: 'retry' });
await flush();
expect(mockGetMeWithRole).toHaveBeenCalledTimes(2);
});
test('Switchboard does not log credential-bearing message bodies', async () => {
require('./index');
await flush();
sendHandshake();
expect(mockSwitchboardInit).toHaveBeenLastCalledWith(
expect.objectContaining({ debug: false }),
);
expect(mockSetupPlugins).toHaveBeenCalledTimes(2);
expect(mockGetMeWithRole).toHaveBeenCalled();
});
});
+13 -33
View File
@@ -49,11 +49,6 @@ import {
import { embeddedApi } from './api';
import { getDataMaskChangeTrigger } from './utils';
import { validateMessageEvent } from './originValidation';
import {
measureGuestToken,
guestAuthenticationMessage,
GuestTokenSize,
} from './guestTokenDiagnostics';
// Defer plugin setup until after the language pack loads to prevent t() calls in
// plugin control panel configs from being cached in English before translations are ready.
@@ -182,7 +177,6 @@ if (!window.parent || window.parent === window) {
let displayedUnauthorizedToast = false;
let root: Root | null = null;
let started = false;
let guestTokenSize: GuestTokenSize | undefined;
/**
* If there is a problem with the guest token, we will start getting
@@ -215,10 +209,8 @@ function start() {
endpoint: '/api/v1/me/roles/',
});
return pluginsReady.then(
() => {
// Snapshot at dispatch, not at handshake: plugin loading can overlap refresh.
const requestTokenSize = guestTokenSize;
return getMeWithRole().then(
() =>
getMeWithRole().then(
({ result }) => {
// fill in some missing bootstrap data
// (because at pageload, we don't have any auth yet)
@@ -233,18 +225,18 @@ function start() {
}
root.render(<EmbeddedApp />);
},
(error: unknown) => {
err => {
// something is most likely wrong with the guest token; reset the guard
// so a rehandshake with a valid token can retry.
// A refresh while the request is in flight makes attribution ambiguous.
const size =
requestTokenSize === guestTokenSize ? requestTokenSize : undefined;
logging.error('Embedded authentication failed', size);
showFailureMessage(guestAuthenticationMessage(size, error));
logging.error(err);
showFailureMessage(
t(
'Something went wrong with embedded authentication. Check the dev console for details.',
),
);
started = false;
},
);
},
),
err => {
// setupPlugins() or setupCodeOverrides() threw while preparing plugins;
// reset the guard and recreate pluginsReady so a retry actually re-runs
@@ -266,17 +258,6 @@ function start() {
* Configures SupersetClient with the correct settings for the embedded dashboard page.
*/
function setupGuestClient(guestToken: string) {
guestTokenSize = measureGuestToken(
guestToken,
bootstrapData.config?.GUEST_TOKEN_HEADER_NAME,
bootstrapData.config?.GUEST_TOKEN_HEADER_MAX_BYTES,
);
if (guestTokenSize.headerBudgetExceeded) {
logging.warn(
'Guest token exceeds configured request-header budget',
guestTokenSize,
);
}
setupClient({
appRoot: applicationRoot(),
guestToken,
@@ -287,19 +268,18 @@ function setupGuestClient(guestToken: string) {
window.addEventListener('message', function embeddedPageInitializer(event) {
if (!validateMessageEvent(event, bootstrapData.embedded?.allowed_domains)) {
log('ignoring message unrelated to embedded comms');
log('ignoring message unrelated to embedded comms', event);
return;
}
const port = event.ports?.[0];
if (event.data.handshake === 'port transfer' && port) {
log('message port received');
log('message port received', event);
Switchboard.init({
port,
name: 'superset',
// Switchboard debug logs message bodies, including guest-token credentials.
debug: false,
debug: debugMode,
});
Switchboard.defineMethod(
@@ -16,13 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import {
ChartLabel,
ChartMetadata,
ChartPlugin,
Preset,
VizType,
} from '@superset-ui/core';
import { Preset, VizType } from '@superset-ui/core';
import {
render,
cleanup,
@@ -53,31 +47,12 @@ jest.mock('scroll-into-view-if-needed', () => jest.fn());
jest.useFakeTimers({ advanceTimers: true });
// A minimal plugin carrying a "Featured" label, so tests can assert on the
// badge that VizTypeGallery overlays on its thumbnail.
class FeaturedTestChartPlugin extends ChartPlugin {
constructor() {
super({
metadata: new ChartMetadata({
name: 'Featured Test Chart',
thumbnail: '',
label: ChartLabel.Featured,
tags: ['Featured'],
}),
Chart: () => null,
});
}
}
class MainPreset extends Preset {
constructor() {
super({
name: 'Legacy charts',
plugins: [
new TableChartPlugin().configure({ key: VizType.Table }),
new FeaturedTestChartPlugin().configure({
key: 'featured_test_chart',
}),
new BigNumberTotalChartPlugin().configure({
key: VizType.BigNumberTotal,
}),
@@ -303,25 +278,6 @@ describe('VizTypeControl', () => {
).not.toBeInTheDocument();
});
test('anchors the Featured badge to the bottom-right of the thumbnail image', async () => {
// The badge is positioned relative to the thumbnail image only (not the
// whole tile), so it must hang off the image's bottom-right corner
// rather than its top edge.
await waitForRenderWrapper();
userEvent.click(screen.getByRole('tab', { name: 'All charts' }));
const visualizations = screen.getByTestId(getTestId('viz-row'));
const image = await within(visualizations).findByAltText(
'Featured Test Chart',
);
const badgeWrapper = image.nextElementSibling as HTMLElement;
expect(badgeWrapper).toHaveStyleRule('bottom', '4px');
expect(badgeWrapper).toHaveStyleRule('right', '4px');
expect(badgeWrapper).not.toHaveStyleRule('top', expect.anything());
expect(within(badgeWrapper).getByText('FEATURED')).toBeInTheDocument();
});
test('Thumbnail labels expose the full chart name via a title tooltip', async () => {
// Labels are clamped to a fixed two-line block so every tile is the same
// height; the full (possibly truncated) name must stay discoverable through
@@ -344,7 +344,7 @@ const ThumbnailImageWrapper = styled.div`
const ThumbnailLabelWrapper = styled.div`
position: absolute;
right: ${({ theme }) => theme.sizeUnit}px;
bottom: ${({ theme }) => theme.sizeUnit}px;
top: ${({ theme }) => theme.sizeUnit}px;
`;
const TitleLabelWrapper = styled.div`
@@ -153,20 +153,6 @@ test('renders a select and a VizTypeGallery', async () => {
expect(screen.getByText(/choose chart type/i)).toBeInTheDocument();
});
test('does not double up the vertical Steps icon-to-content gap', async () => {
// antd 6 added its own icon->content gap on `.ant-steps-item-wrapper`
// (column-gap), stacking on top of the pre-existing `margin-right` on
// `.ant-steps-item-icon` and shifting every step's content to the right.
const { container } = await renderComponent();
const styledContainer = container.firstChild;
expect(styledContainer).toHaveStyleRule('column-gap', '0', {
target: '.ant-steps-item-wrapper',
});
expect(styledContainer).toHaveStyleRule('margin-right', '8px', {
target: '.ant-steps-item-icon',
});
});
test('renders dataset help text when user lacks dataset write permissions', async () => {
await renderComponent();
expect(screen.queryByText('Add a dataset')).not.toBeInTheDocument();
@@ -141,13 +141,6 @@ const StyledContainer = styled.div`
display: none;
}
/* antd 6 added its own icon->content gap on this flex wrapper
(column-gap), on top of the .ant-steps-item-icon margin-right below,
doubling the gap. Zero it out so the icon's margin is the only gap. */
&&&& .ant-steps-item-wrapper {
column-gap: 0;
}
&&&& .ant-steps-item-icon {
margin-right: ${theme.marginXS}px;
width: ${theme.sizeUnit * 5}px;
@@ -181,10 +181,7 @@ export interface CommonBootstrapData {
export interface BootstrapData {
user?: BootstrapUser;
common: CommonBootstrapData;
config?: {
GUEST_TOKEN_HEADER_NAME?: string;
GUEST_TOKEN_HEADER_MAX_BYTES?: number | null;
};
config?: any;
embedded?: {
dashboard_id: string;
// Domains allowed to embed this dashboard. An empty/undefined list means
@@ -18,7 +18,6 @@
*/
import type { WorkBook } from 'xlsx';
import { getNumberFormatterRegistry } from '@superset-ui/core';
import { logging } from '@apache-superset/core/utils';
import exportPivotExcel from './downloadAsPivotExcel';
const mockWriteFile = jest.fn();
@@ -31,14 +30,6 @@ jest.mock('xlsx', () => {
};
});
jest.mock('@apache-superset/core/utils', () => ({
logging: { error: jest.fn() },
}));
afterEach(() => {
jest.restoreAllMocks();
});
// Renders a single-row pivot table with the given cell values, runs the
// export, and returns the resulting sheet so each test only has to state
// its input cells and assertions.
@@ -123,13 +114,3 @@ test('leaves date-shaped strings as text rather than reinterpreting them as date
expect(sheet.B1).toMatchObject({ t: 's', v: '2024-01-01 13:45:30' });
expect(sheet.C1).toMatchObject({ t: 's', v: 'not-a-date' });
});
test('should log an error and return early when table element is not found', () => {
jest.spyOn(document, 'querySelector').mockReturnValue(null);
exportPivotExcel('.non-existent-selector', 'test-file');
expect(logging.error as jest.Mock).toHaveBeenCalledWith(
'[exportPivotExcel] No element found for selector: ".non-existent-selector"',
);
});
@@ -17,7 +17,6 @@
* under the License.
*/
import { getNumberFormatterRegistry } from '@superset-ui/core';
import { logging } from '@apache-superset/core/utils';
import { utils, writeFile } from 'xlsx';
import type { WorkSheet } from 'xlsx';
@@ -66,12 +65,6 @@ export default function exportPivotExcel(
fileName: string,
) {
const table = document.querySelector(tableSelector);
if (!table) {
logging.error(
`[exportPivotExcel] No element found for selector: "${tableSelector}"`,
);
return;
}
// `raw: true` keeps every cell as the literal text rendered in the DOM.
// Without it, SheetJS tries to infer numbers/dates from the displayed
// string, which mangles values that were formatted using a non-US
+7 -6
View File
@@ -184,6 +184,9 @@ class SupersetApp(Flask):
app startup.
"""
try:
# Import here to avoid circular import issues
from superset.extensions import feature_flag_manager
# Check if database is up-to-date with migrations
if not self._is_database_up_to_date():
logger.info("Pending database migrations: run 'superset db upgrade'")
@@ -191,13 +194,11 @@ class SupersetApp(Flask):
logger.info("Syncing configuration to database...")
# Register SQLA event listeners for the tagging system. The
# listeners that create tags check TAGGING_SYSTEM when they fire,
# and the cleanup listeners must run regardless of the flag so a
# deleted object never leaves orphaned `tagged_object` rows behind.
from superset.tags.core import register_sqla_event_listeners
# Register SQLA event listeners for tagging system
if feature_flag_manager.is_feature_enabled("TAGGING_SYSTEM"):
from superset.tags.core import register_sqla_event_listeners
register_sqla_event_listeners()
register_sqla_event_listeners()
# Seed system themes from configuration
from superset.commands.theme.seed import SeedSystemThemesCommand
-29
View File
@@ -44,26 +44,6 @@ class DatabaseExistsValidationError(ValidationError):
)
class DatabaseUpdateUnsafeRebindError(ValidationError):
"""
Marshmallow validation error for an update that would change a
database's effective connection destination while leaving the stored
password/encrypted_extra/SSH tunnel credential masked.
"""
def __init__(self, field_name: str = "sqlalchemy_uri") -> None:
super().__init__(
_(
"This update would change the connection's effective "
"destination (host/port, engine parameters, or SSH tunnel "
"endpoint) while reusing the stored credential. Provide "
"the real password (or SSH tunnel credential) to confirm "
"a connection move."
),
field_name=field_name,
)
class DatabaseRequiredFieldValidationError(ValidationError):
def __init__(self, field_name: str) -> None:
super().__init__(
@@ -194,15 +174,6 @@ class DatabaseSecurityUnsafeError(CommandInvalidError):
message = _("Stopped an unsafe database connection")
class DatabaseTestConnectionUnsafeRebindError(CommandInvalidError):
message = _(
"Testing this connection would change its effective destination "
"(engine parameters or SSH tunnel endpoint) while reusing the stored "
"password. Provide the real password to test a connection whose "
"destination has changed."
)
class DatabaseTestConnectionDriverError(CommandInvalidError):
message = _("Could not load database driver")
@@ -22,12 +22,7 @@ from flask import current_app as app
from superset import db, security_manager
from superset.commands.database.exceptions import DatabaseInvalidError
from superset.commands.database.utils import (
add_permissions,
engine_params_changed,
ssh_tunnel_rebind_unsafe,
uri_identity_changed,
)
from superset.commands.database.utils import add_permissions
from superset.commands.exceptions import ImportFailedError
from superset.constants import PASSWORD_MASK
from superset.databases.ssh_tunnel.models import SSHTunnel
@@ -46,19 +41,14 @@ logger = logging.getLogger(__name__)
def _connection_identity_changed(existing: Database, config: dict[str, Any]) -> bool:
"""Whether the import points the database at a different endpoint."""
if uri_identity_changed(existing.sqlalchemy_uri, config.get("sqlalchemy_uri")):
try:
stored = make_url_safe(existing.sqlalchemy_uri)._replace(password=None)
incoming = make_url_safe(config["sqlalchemy_uri"])._replace(password=None)
except DatabaseInvalidError:
# An unparseable URI cannot be compared: treat it as a change so
# stored secrets never survive onto it.
return True
# The URI's host/port aren't the whole story: `extra.engine_params`
# (e.g. `connect_args.host`/`port`) is merged into the actual DBAPI
# connect kwargs and can override them. An import that opens a live
# connection (`add_permissions` -> `get_all_catalog_names`) with a
# rehydrated stored password must not do so against a destination this
# field silently redirected.
submitted_extra = config.get("extra")
if isinstance(submitted_extra, dict):
submitted_extra = json.dumps(submitted_extra)
return engine_params_changed(existing.extra, submitted_extra)
return stored != incoming
def _refuse_stored_secret_reuse(existing: Database, config: dict[str, Any]) -> None:
@@ -87,13 +77,33 @@ def _refuse_stored_secret_reuse(existing: Database, config: dict[str, Any]) -> N
"connection to confirm the change."
)
if ssh_tunnel_rebind_unsafe(existing.ssh_tunnel, config.get("ssh_tunnel")):
raise ImportFailedError(
f"Import would change the SSH tunnel endpoint of database "
f"'{existing.database_name}' without providing new tunnel "
"credentials. Re-enter the SSH tunnel credentials to "
"confirm the change."
)
if ssh_tunnel := config.get("ssh_tunnel"):
existing_tunnel = existing.ssh_tunnel
if existing_tunnel and (
ssh_tunnel.get("server_address") != existing_tunnel.server_address
or ssh_tunnel.get("server_port") != existing_tunnel.server_port
):
has_fresh_credential = any(
ssh_tunnel.get(field) not in (None, PASSWORD_MASK)
for field in ("password", "private_key")
)
# A passphrase-protected private key's stored passphrase is a
# secret in its own right: if the existing tunnel had one, a
# repoint that supplies a fresh private_key but leaves
# private_key_password masked/absent would keep the old
# passphrase attached to the new key rather than requiring the
# importer to confirm it too.
stale_private_key_password = (
existing_tunnel.private_key_password is not None
and ssh_tunnel.get("private_key_password") in (None, PASSWORD_MASK)
)
if not has_fresh_credential or stale_private_key_password:
raise ImportFailedError(
f"Import would change the SSH tunnel endpoint of database "
f"'{existing.database_name}' without providing new tunnel "
"credentials. Re-enter the SSH tunnel credentials to "
"confirm the change."
)
def import_database( # noqa: C901
+5 -36
View File
@@ -26,18 +26,13 @@ from superset.commands.database.exceptions import (
DatabaseSecurityUnsafeError,
DatabaseTestConnectionDriverError,
DatabaseTestConnectionUnexpectedError,
DatabaseTestConnectionUnsafeRebindError,
)
from superset.commands.database.ssh_tunnel.exceptions import (
SSHTunnelDatabasePortError,
SSHTunnelHostKeyVerificationError,
SSHTunnelingNotEnabledError,
)
from superset.commands.database.utils import (
engine_params_changed,
ping,
ssh_tunnel_endpoint_changed,
)
from superset.commands.database.utils import ping
from superset.daos.database import DatabaseDAO
from superset.databases.utils import make_url_safe
from superset.errors import ErrorLevel, SupersetErrorType
@@ -70,8 +65,6 @@ class TestConnectionDatabaseCommand(BaseCommand):
_model: Optional[Database] = None
_context: dict[str, Any]
_uri: str
_identity_changed: bool
_ssh_tunnel_endpoint_changed: bool
def __init__(self, data: dict[str, Any]):
self._properties = data.copy()
@@ -80,27 +73,8 @@ class TestConnectionDatabaseCommand(BaseCommand):
self._model = DatabaseDAO.get_database_by_name(database_name)
uri = self._properties.get("sqlalchemy_uri", "")
self._identity_changed = False
self._ssh_tunnel_endpoint_changed = False
if (model := self._model) is not None:
# A stored password (and, below, encrypted_extra / SSH tunnel
# credentials) must never be rehydrated onto a connection whose
# final effective destination the requester can change. The
# visible `sqlalchemy_uri` is only one part of that destination:
# `extra.engine_params` (merged into the DBAPI connect kwargs,
# e.g. `connect_args.host`/`port`) and the SSH tunnel endpoint
# can both override it after this decision is made.
self._ssh_tunnel_endpoint_changed = ssh_tunnel_endpoint_changed(
model.ssh_tunnel, self._properties.get("ssh_tunnel")
)
self._identity_changed = (
engine_params_changed(model.extra, self._properties.get("extra", "{}"))
or self._ssh_tunnel_endpoint_changed
)
if uri == model.safe_sqlalchemy_uri():
if self._identity_changed:
raise DatabaseTestConnectionUnsafeRebindError()
uri = model.sqlalchemy_uri_decrypted
if self._model and uri == self._model.safe_sqlalchemy_uri():
uri = self._model.sqlalchemy_uri_decrypted
url = make_url_safe(uri)
@@ -128,7 +102,7 @@ class TestConnectionDatabaseCommand(BaseCommand):
"masked_encrypted_extra",
"{}",
)
if self._model and not self._identity_changed:
if self._model:
serialized_encrypted_extra = (
self._model.db_engine_spec.unmask_encrypted_extra(
self._model.encrypted_extra,
@@ -138,12 +112,7 @@ class TestConnectionDatabaseCommand(BaseCommand):
# collect SSH tunnel info
ssh_tunnel_properties = self._properties.get("ssh_tunnel")
if (
ssh_tunnel_properties
and self._model
and self._model.ssh_tunnel
and not self._ssh_tunnel_endpoint_changed
):
if ssh_tunnel_properties and self._model and self._model.ssh_tunnel:
# unmask password while allowing for updated values
ssh_tunnel_properties = unmask_password_info(
ssh_tunnel_properties,
-87
View File
@@ -30,18 +30,10 @@ from superset.commands.database.exceptions import (
DatabaseInvalidError,
DatabaseNotFoundError,
DatabaseUpdateFailedError,
DatabaseUpdateUnsafeRebindError,
MissingOAuth2TokenError,
)
from superset.commands.database.sync_permissions import SyncPermissionsCommand
from superset.commands.database.utils import (
engine_params_changed,
ssh_tunnel_rebind_unsafe,
uri_identity_changed,
)
from superset.constants import PASSWORD_MASK
from superset.daos.database import DatabaseDAO
from superset.databases.utils import make_url_safe
from superset.exceptions import OAuth2RedirectError
from superset.models.core import Database
from superset.utils import json
@@ -188,82 +180,3 @@ class UpdateDatabaseCommand(BaseCommand):
database_name,
):
raise DatabaseInvalidError(exceptions=[DatabaseExistsValidationError()])
if self._model:
self._check_no_unsafe_secret_rebind()
def _check_no_unsafe_secret_rebind(self) -> None:
"""
Refuse an update that changes the connection's effective destination
(URI host/port, `extra.engine_params`, or the SSH tunnel endpoint)
while leaving the corresponding stored secret masked.
Without this, an editor could silently redirect the real stored
password/encrypted_extra/SSH tunnel credential to a different
destination -- and since an update persists, every subsequent use of
the database (by any user) would send the real secret there, not
just the editor's own request.
"""
model = self._model
assert model is not None
connection_identity_changed = False
submitted_password: str | None = None
if "sqlalchemy_uri" in self._properties:
submitted_uri = self._properties["sqlalchemy_uri"] or ""
connection_identity_changed = uri_identity_changed(
model.sqlalchemy_uri, submitted_uri
)
try:
submitted_password = make_url_safe(submitted_uri).password
except DatabaseInvalidError:
submitted_password = None
if "extra" in self._properties and engine_params_changed(
model.extra, self._properties["extra"]
):
connection_identity_changed = True
if connection_identity_changed:
# The URI password is only one of the secrets that can silently
# carry over onto a changed destination. `encrypted_extra` (e.g.
# a service-account key or OAuth2 client secret) is reattached
# unconditionally in `run()` via `unmask_encrypted_extra` unless
# we catch it here -- gating on the URI password alone would
# both miss that reuse when a fresh URI password is supplied,
# and wrongly block engines that keep credentials entirely in
# `encrypted_extra` and carry no URI password at all (BigQuery,
# GSheets), since those never have a "fresh" URI password to
# give.
uri_password_reused = model.password is not None and submitted_password in (
None,
PASSWORD_MASK,
)
# encrypted_extra is a blob with per-field masks, so "reused"
# means unmasking the submission against the stored value
# changes nothing -- including not submitting it at all, which
# leaves the old (real) value attached unchanged.
encrypted_extra_reused = model.encrypted_extra not in (
None,
"",
"{}",
) and (
"masked_encrypted_extra" not in self._properties
or model.db_engine_spec.unmask_encrypted_extra(
model.encrypted_extra,
self._properties["masked_encrypted_extra"],
)
== model.encrypted_extra
)
if uri_password_reused or encrypted_extra_reused:
raise DatabaseInvalidError(
exceptions=[DatabaseUpdateUnsafeRebindError()]
)
if "ssh_tunnel" in self._properties and ssh_tunnel_rebind_unsafe(
model.ssh_tunnel, self._properties["ssh_tunnel"]
):
raise DatabaseInvalidError(
exceptions=[DatabaseUpdateUnsafeRebindError(field_name="ssh_tunnel")]
)
-96
View File
@@ -19,7 +19,6 @@ from __future__ import annotations
import logging
import sqlite3
from contextlib import closing
from typing import Any
from flask import current_app as app
from flask_appbuilder.security.sqla.models import (
@@ -31,109 +30,14 @@ from sqlalchemy.engine import Engine
from sqlalchemy.orm import Session
from superset import security_manager
from superset.commands.database.exceptions import DatabaseInvalidError
from superset.constants import PASSWORD_MASK
from superset.databases.ssh_tunnel.models import SSHTunnel
from superset.databases.utils import make_url_safe
from superset.db_engine_specs.base import GenericDBException
from superset.models.core import Database
from superset.security.manager import SupersetSecurityManager
from superset.utils import json
from superset.utils.core import timeout
logger = logging.getLogger(__name__)
def uri_identity_changed(existing_uri: str | None, submitted_uri: str | None) -> bool:
"""
Whether two SQLAlchemy URIs differ once their password is stripped --
i.e. whether the effective connection destination (driver, host, port,
database, username, query params) changed.
"""
try:
stored = make_url_safe(existing_uri or "")._replace(password=None)
incoming = make_url_safe(submitted_uri or "")._replace(password=None)
except DatabaseInvalidError:
# An unparseable URI cannot be compared: treat it as a change so a
# stored secret never survives onto it.
return True
return stored != incoming
def engine_params_changed(
existing_extra: str | None, submitted_extra: str | None
) -> bool:
"""
Whether ``submitted_extra`` carries different ``engine_params`` than
``existing_extra``.
``engine_params`` (in particular ``engine_params.connect_args``) is
merged into the actual DBAPI connect kwargs, so it can override the
host/port/etc. carried in the SQLAlchemy URI itself. Any caller that
conditionally reattaches a stored secret (password, encrypted_extra, SSH
tunnel credentials) based on the URI being unchanged must also check
this, or the destination can be silently redirected while the real
secret rides along.
"""
def _engine_params(serialized_extra: str | None) -> dict[str, Any]:
try:
return json.loads(serialized_extra or "{}").get("engine_params", {})
except (json.JSONDecodeError, AttributeError):
# Unparseable/non-dict `extra` cannot be compared: treat it as a
# change so a stored secret never rides along with input that
# can't be verified to leave the connection identity untouched.
return {"__unparseable__": True}
return _engine_params(submitted_extra) != _engine_params(existing_extra)
def ssh_tunnel_endpoint_changed(
existing_tunnel: SSHTunnel | None, submitted_tunnel: dict[str, Any] | None
) -> bool:
"""
Whether a submitted SSH tunnel config points at a different endpoint
than the stored tunnel it would otherwise inherit credentials from.
"""
if not submitted_tunnel or not existing_tunnel:
return False
return bool(
submitted_tunnel.get("server_address") != existing_tunnel.server_address
or submitted_tunnel.get("server_port") != existing_tunnel.server_port
)
def ssh_tunnel_rebind_unsafe(
existing_tunnel: SSHTunnel | None, submitted_tunnel: dict[str, Any] | None
) -> bool:
"""
Whether a submitted SSH tunnel config repoints the tunnel at a
different endpoint without supplying credentials fresh enough to
justify it -- i.e. whether carrying the stored tunnel secrets over
onto this submission would be unsafe.
"""
if not ssh_tunnel_endpoint_changed(existing_tunnel, submitted_tunnel):
return False
assert submitted_tunnel is not None
assert existing_tunnel is not None
has_fresh_credential = any(
submitted_tunnel.get(field) not in (None, PASSWORD_MASK)
for field in ("password", "private_key")
)
# A passphrase-protected private key's stored passphrase is a secret in
# its own right: if the existing tunnel had one, a repoint that
# supplies a fresh private_key but leaves private_key_password
# masked/absent would keep the old passphrase attached to the new key
# rather than requiring the caller to confirm it too.
stale_private_key_password = (
existing_tunnel.private_key_password is not None
and submitted_tunnel.get("private_key_password") in (None, PASSWORD_MASK)
)
return not has_fresh_credential or stale_private_key_password
def ping(engine: Engine) -> bool:
try:
time_delta = app.config["TEST_DATABASE_CONNECTION_TIMEOUT"]
+2 -45
View File
@@ -27,10 +27,6 @@ from superset.commands.database.exceptions import (
InvalidEngineError,
InvalidParametersError,
)
from superset.commands.database.utils import (
engine_params_changed,
ssh_tunnel_endpoint_changed,
)
from superset.daos.database import DatabaseDAO
from superset.databases.utils import make_url_safe
from superset.db_engine_specs import get_engine_spec
@@ -94,28 +90,11 @@ class ValidateDatabaseParametersCommand(BaseCommand):
event_logger.log_with_context(action="validation_error", engine=engine)
raise InvalidParametersError(errors)
# A stored password/encrypted_extra/SSH tunnel credential must never
# be rehydrated onto a connection whose final effective destination
# the caller can change. `parameters` only covers what feeds into
# `sqlalchemy_uri` here -- `extra.engine_params` (merged into the
# actual DBAPI connect kwargs, e.g. `connect_args.host`/`port`) and
# the SSH tunnel endpoint can both override it independently.
identity_changed = False
ssh_tunnel_changed = False
if (model := self._model) is not None:
ssh_tunnel_changed = ssh_tunnel_endpoint_changed(
model.ssh_tunnel, self._properties.get("ssh_tunnel")
)
identity_changed = (
engine_params_changed(model.extra, self._properties.get("extra", "{}"))
or ssh_tunnel_changed
)
serialized_encrypted_extra = self._properties.get(
"masked_encrypted_extra",
"{}",
)
if self._model and not identity_changed:
if self._model:
serialized_encrypted_extra = engine_spec.unmask_encrypted_extra(
self._model.encrypted_extra,
serialized_encrypted_extra,
@@ -131,35 +110,13 @@ class ValidateDatabaseParametersCommand(BaseCommand):
encrypted_extra,
)
if self._model and sqlalchemy_uri == self._model.safe_sqlalchemy_uri():
if identity_changed:
raise InvalidParametersError(
[
SupersetError(
message=__(
"Testing this connection would change its "
"effective destination (engine parameters "
"or SSH tunnel endpoint) while reusing the "
"stored password. Provide the real "
"password to test a connection whose "
"destination has changed."
),
error_type=SupersetErrorType.GENERIC_DB_ENGINE_ERROR,
level=ErrorLevel.ERROR,
)
]
)
sqlalchemy_uri = self._model.sqlalchemy_uri_decrypted
# Forward the SSH tunnel into the connection test so that
# tunnel-only databases are reached through the tunnel rather
# than directly, mirroring the existing test_connection flow.
ssh_tunnel_properties = self._properties.get("ssh_tunnel")
if (
ssh_tunnel_properties
and self._model
and self._model.ssh_tunnel
and not ssh_tunnel_changed
):
if ssh_tunnel_properties and self._model and self._model.ssh_tunnel:
ssh_tunnel_properties = unmask_password_info(
ssh_tunnel_properties,
self._model.ssh_tunnel,
+12 -22
View File
@@ -555,29 +555,19 @@ def import_dataset( # noqa: C901
except SupersetSecurityException as ex:
raise DatasetAccessDeniedError() from ex
# `has_table` opens a live connection to the target database to run a
# schema-introspection query. Its result is only ever consulted below to
# decide whether to call `load_data`, which itself is a no-op unless
# `data_uri` is set - so for imports that don't carry inline data (the
# common case when bulk-importing dataset *metadata*, e.g. hundreds of
# datasets at once), this was an unconditional, unnecessary round trip
# to every target database on every single dataset, and a major
# contributor to bulk imports timing out.
if data_uri:
try:
table_exists = dataset.database.has_table(
Table(dataset.table_name, dataset.schema, dataset.catalog),
)
except Exception: # pylint: disable=broad-except
# MySQL doesn't play nice with GSheets table names
logger.warning(
"Couldn't check if table %s exists, assuming it does",
dataset.table_name,
)
table_exists = True
try:
table_exists = dataset.database.has_table(
Table(dataset.table_name, dataset.schema, dataset.catalog),
)
except Exception: # pylint: disable=broad-except
# MySQL doesn't play nice with GSheets table names
logger.warning(
"Couldn't check if table %s exists, assuming it does", dataset.table_name
)
table_exists = True
if not table_exists or force_data:
load_data(data_uri, dataset, dataset.database)
if data_uri and (not table_exists or force_data):
load_data(data_uri, dataset, dataset.database)
if user:
from superset.subjects.utils import get_user_subject
+10 -102
View File
@@ -70,22 +70,14 @@ class GetCombinedDatasourceListCommand(BaseCommand):
type_filter,
database_id,
semantic_layer_uuid,
schema_filter,
) = self._parse_filters(filters)
source_type = self._resolve_connection_source_type(
source_type,
database_id,
semantic_layer_uuid,
schema_filter,
)
# A connection filter can already resolve to "empty" (e.g. a semantic-layer
# connection combined with a dataset-only schema filter); don't let the
# content-filter resolution override that terminal decision.
if source_type != "empty":
source_type = self._resolve_source_type(
source_type, sql_filter, type_filter, schema_filter
)
source_type = self._resolve_source_type(source_type, sql_filter, type_filter)
if source_type == "empty":
return {"count": 0, "result": []}
@@ -96,7 +88,6 @@ class GetCombinedDatasourceListCommand(BaseCommand):
sql_filter,
database_id,
semantic_layer_uuid,
schema_filter,
)
total_count, rows = DatasourceDAO.paginate_combined_query(
combined, order_column, order_direction, page, page_size
@@ -111,7 +102,6 @@ class GetCombinedDatasourceListCommand(BaseCommand):
source_type: str,
database_id: int | None,
semantic_layer_uuid: str | None,
schema_filter: str | None = None,
) -> str:
# A connection filter implicitly narrows the source type: selecting a
# database ID means "show only datasets", and selecting a semantic layer
@@ -121,14 +111,6 @@ class GetCombinedDatasourceListCommand(BaseCommand):
if database_id is not None:
return "database"
elif semantic_layer_uuid is not None:
# A semantic-layer connection selects only that layer's
# (schema-less) views, so a dataset-only schema filter matches
# nothing: the honest result is empty. Unlike an explicit
# Source="Semantic layer" selection (handled in
# _resolve_source_type), the user never picked a source type
# here, so the "explicit selection wins" rule does not apply.
if schema_filter is not None:
return "empty"
return "semantic_layer"
return source_type
@@ -140,11 +122,8 @@ class GetCombinedDatasourceListCommand(BaseCommand):
sql_filter: bool | None,
database_id: int | None,
semantic_layer_uuid: str | None,
schema_filter: str | None = None,
) -> Any:
ds_q = DatasourceDAO.build_dataset_query(
name_filter, sql_filter, database_id, schema_filter
)
ds_q = DatasourceDAO.build_dataset_query(name_filter, sql_filter, database_id)
sv_q = DatasourceDAO.build_semantic_view_query(name_filter, semantic_layer_uuid)
if source_type == "database":
@@ -205,40 +184,12 @@ class GetCombinedDatasourceListCommand(BaseCommand):
source_type: str,
sql_filter: bool | None,
type_filter: str | None,
schema_filter: str | None = None,
) -> str:
"""Narrow source_type based on access flags, sql filter, and type filter.
Returns one of: "database", "semantic_layer", "all", or "empty".
"empty" signals that the caller should short-circuit and return no results
(used when the user explicitly requests semantic views but lacks access).
Resolution follows a single precedence order (highest to lowest). This
is what makes a dataset-only filter (schema/sql) combined with a
semantic-view result behave consistently across entry points, with one
deliberate exception noted below:
1. Access a principal never sees a source type it cannot read; a
dataset-only filter applied by a user without dataset access yields
"empty" (nothing to match).
2. Explicit ``Source`` selection an explicit ``source_type`` of
"database"/"semantic_layer" is authoritative and suppresses
otherwise-contradictory cross-type filters (a leftover Schema chip
becomes a no-op rather than a contradiction). This is the one place a
dataset-only filter is intentionally dropped instead of yielding
"empty".
3. Implicit narrowing and content filters honest AND: a filter that
cannot match the resulting rows returns "empty" rather than being
silently dropped. This covers Type="Semantic View" + schema and the
semantic-layer-*connection* + schema route (see
``_resolve_connection_source_type``).
Consequence: "views + schema=X" resolves to "empty" via the Type filter
and via a semantic-layer connection, but an explicit ``Source``="Semantic
layer" selection shows all views with the schema ignored (rule 2). A
views-only user hits rule 1 first, so the same explicit selection yields
"empty" for them access restrictions outrank the explicit-selection
escape hatch. All intended.
"""
if not self._can_read_semantic_views:
# If the user explicitly asked for semantic views but cannot read them,
@@ -248,61 +199,24 @@ class GetCombinedDatasourceListCommand(BaseCommand):
return "empty"
return "database"
if not self._can_read_datasets:
# schema and sql_filter are both dataset-only, so a semantic-views-only
# user matches nothing under AND semantics; return "empty" rather than
# showing views with the filter dropped (mirrors the
# not-can_read_semantic_views branch above and the
# schema/Type="Semantic View" case below).
if schema_filter is not None or sql_filter is not None:
return "empty"
return "semantic_layer"
# An explicit source_type selection ("database" or "semantic_layer") always
# wins. This prevents e.g. Type="Semantic View" from overriding an explicit
# Source="Database" filter and showing inconsistent results.
if source_type in ("database", "semantic_layer"):
return source_type
# sql_filter (physical/virtual toggle) and schema both only apply to
# datasets (semantic views have no schema), so either narrows to datasets.
if sql_filter is not None or schema_filter is not None:
# A schema filter combined with an explicit Type="Semantic View" is
# contradictory: no semantic view has a schema, so under AND semantics
# the honest result is zero rows rather than silently dropping either
# filter. This pair is reachable because the Schema control is not part
# of the frontend cascade. (Via the UI, sql_filter and type_filter come
# from one control and cannot collide; a direct API payload could set
# both, in which case sql_filter wins — see _apply_sql_null_filter.)
if schema_filter is not None and type_filter == "semantic_view":
return "empty"
# sql_filter (physical/virtual toggle) only applies to datasets
if sql_filter is not None:
return "database"
# Explicit semantic-view type filter (only reached when source_type="all")
if type_filter == "semantic_view":
return "semantic_layer"
return source_type
@staticmethod
def _apply_sql_null_filter(
value: Any,
type_filter: str | None,
sql_filter: bool | None,
) -> tuple[str | None, bool | None]:
"""Interpret a ``sql``/``dataset_is_null_or_empty`` filter value.
``"semantic_view"`` selects semantic views; a boolean toggles the
physical/virtual dataset split. Unrecognized values leave both inputs
unchanged, so the caller can pass its current values straight through.
"""
if value == "semantic_view":
return "semantic_view", sql_filter
if isinstance(value, bool):
return type_filter, value
return type_filter, sql_filter
@staticmethod
def _parse_filters(
filters: list[dict[str, Any]],
) -> tuple[
str, str | None, bool | None, str | None, int | None, str | None, str | None
]:
) -> tuple[str, str | None, bool | None, str | None, int | None, str | None]:
"""
Translate raw rison filter dicts into typed query parameters.
@@ -314,7 +228,6 @@ class GetCombinedDatasourceListCommand(BaseCommand):
semantic views
database_id: filter datasets to a specific database ID
semantic_layer_uuid: filter semantic views to a specific semantic layer UUID
schema_filter: filter datasets to a specific schema name
"""
source_type = "all"
name_filter: str | None = None
@@ -322,7 +235,6 @@ class GetCombinedDatasourceListCommand(BaseCommand):
type_filter: str | None = None
database_id: int | None = None
semantic_layer_uuid: str | None = None
schema_filter: str | None = None
for f in filters:
col = f.get("col")
@@ -333,12 +245,11 @@ class GetCombinedDatasourceListCommand(BaseCommand):
source_type = value or "all"
elif col == "table_name" and f.get("opr") == "ct":
name_filter = value
elif col == "sql" and opr == "dataset_is_null_or_empty":
type_filter, sql_filter = (
GetCombinedDatasourceListCommand._apply_sql_null_filter(
value, type_filter, sql_filter
)
)
elif col == "sql":
if opr == "dataset_is_null_or_empty" and value == "semantic_view":
type_filter = "semantic_view"
elif opr == "dataset_is_null_or_empty" and isinstance(value, bool):
sql_filter = value
elif col == "database" and value is not None:
try:
database_id = int(value)
@@ -346,8 +257,6 @@ class GetCombinedDatasourceListCommand(BaseCommand):
pass
elif col == "semantic_layer_uuid" and value is not None:
semantic_layer_uuid = str(value)
elif col == "schema" and opr == "eq" and value is not None:
schema_filter = str(value)
return (
source_type,
@@ -356,5 +265,4 @@ class GetCombinedDatasourceListCommand(BaseCommand):
type_filter,
database_id,
semantic_layer_uuid,
schema_filter,
)
-194
View File
@@ -1,194 +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.
"""Bounded, privacy-preserving transport handling for report attachments."""
import errno
import logging
import socket
import time
from collections.abc import Callable
from http.client import HTTPException, IncompleteRead
from urllib.error import HTTPError, URLError
from superset.errors import SupersetErrorType
from superset.utils import json
logger = logging.getLogger(__name__)
_ERROR_BODY_LIMIT = 4096
_BACKOFF_SECONDS = 0.5
_RETRYABLE_STATUS = {429, 500, 502, 503, 504}
_TRANSIENT_ERRNOS = {
errno.ECONNREFUSED,
errno.ECONNRESET,
errno.ECONNABORTED,
errno.ETIMEDOUT,
errno.EHOSTUNREACH,
errno.ENETUNREACH,
errno.EPIPE,
}
class ChartDataRequestError(Exception):
"""A transport error whose message contains no remote-controlled text."""
def __init__(self, category: str, status: int | None = None) -> None:
"""Store the category used to select the normalized report exception."""
self.category = category
super().__init__(
f"Chart data request failed: category={category} status={status}"
)
def _error_body(error: HTTPError) -> str:
"""Project a bounded JSON body onto known error types, never free-form text.
Messages, SQL, validation values and arbitrary extra fields may contain
credentials or query data. Redaction by keyword cannot safely retain them.
"""
try:
body = error.read(_ERROR_BODY_LIMIT + 1)
if len(body) > _ERROR_BODY_LIMIT:
return "[omitted: oversized body]"
payload = json.loads(body)
except (ValueError, OSError, HTTPException, RecursionError):
return "[omitted: unreadable or non-JSON body]"
if not isinstance(payload, dict) or not isinstance(payload.get("errors"), list):
return "[redacted]"
known_types = {member.value for member in SupersetErrorType}
errors = []
for item in payload["errors"][:4]:
error_type = item.get("error_type") if isinstance(item, dict) else None
errors.append(
{
"error_type": error_type
if isinstance(error_type, str) and error_type in known_types
else "[redacted]",
"message": "[redacted]",
}
)
return json.dumps({"errors": errors})
def _retry_delay(error: HTTPError) -> float | None:
"""Respect numeric Retry-After; defer date-based delays to the scheduler."""
retry_after = error.headers.get("Retry-After") if error.headers else None
if retry_after is None:
return _BACKOFF_SECONDS
try:
seconds = float(retry_after)
except ValueError:
return None
return max(_BACKOFF_SECONDS, seconds) if 0 <= seconds <= 2 else None
def request_chart_data(
fetch: Callable[[float | None], bytes | None],
get_timeout: Callable[[], float | None],
*,
retry: bool,
endpoint: str,
log_context: str,
) -> bytes | None:
"""Optionally retry once, sharing the initial timeout and execution budget.
The caller supplies a fixed endpoint path, never a URL or query payload.
The deadline callback also preserves the report's delivery/cleanup reserves.
Socket timeouts are not wall-clock cancellation; the report task's existing
execution limits remain responsible for interrupting in-flight work.
"""
started = time.monotonic()
timeout = get_timeout()
deadline = started + timeout if timeout is not None else None
for attempt in (1, 2):
if attempt == 2:
phase_timeout = get_timeout()
remaining = deadline - time.monotonic() if deadline is not None else 0
if remaining <= 0:
raise ChartDataRequestError("timeout")
timeout = (
min(remaining, phase_timeout)
if phase_timeout is not None
else remaining
)
status = None
body = "[not applicable]"
delay = _BACKOFF_SECONDS
try:
content = fetch(timeout)
logger.info(
"Chart data request completed %s endpoint=%s "
"elapsed_seconds=%.2f attempt=%s timeout_seconds=%s",
log_context,
endpoint,
time.monotonic() - started,
attempt,
timeout,
)
return content
except HTTPError as error:
status = error.code
category = "http"
transient = status in _RETRYABLE_STATUS
# A server requesting a longer or date-based delay should be left
# to the scheduler, rather than retried earlier than requested.
retry_delay = _retry_delay(error)
if retry_delay is None:
transient = False
else:
delay = retry_delay
try:
body = _error_body(error)
finally:
error.close()
except (OSError, HTTPException) as error:
reason = error.reason if isinstance(error, URLError) else error
is_timeout = isinstance(reason, TimeoutError) or (
isinstance(reason, OSError) and reason.errno == errno.ETIMEDOUT
)
category = "timeout" if is_timeout else "network"
transient = (
is_timeout
or isinstance(reason, IncompleteRead)
or (isinstance(reason, OSError) and reason.errno in _TRANSIENT_ERRNOS)
or (
isinstance(reason, socket.gaierror)
and reason.errno == socket.EAI_AGAIN
)
)
logger.warning(
"Chart data request failed %s endpoint=%s category=%s status=%s "
"elapsed_seconds=%.2f attempt=%s timeout_seconds=%s body=%s",
log_context,
endpoint,
category,
status,
time.monotonic() - started,
attempt,
timeout,
body,
)
# Never retry an unbounded request, or renew the original timeout.
if not retry or not transient or attempt == 2 or deadline is None:
raise ChartDataRequestError(category, status) from None
phase_timeout = get_timeout()
remaining = deadline - time.monotonic()
if phase_timeout is not None:
remaining = min(remaining, phase_timeout)
if remaining <= delay:
raise ChartDataRequestError(category, status) from None
time.sleep(delay)
return None # pragma: no cover
+38 -53
View File
@@ -35,10 +35,6 @@ from superset.commands.base import BaseCommand
from superset.commands.dashboard.permalink.create import CreateDashboardPermalinkCommand
from superset.commands.exceptions import CommandException, UpdateFailedError
from superset.commands.report.alert import AlertCommand
from superset.commands.report.chart_data import (
ChartDataRequestError,
request_chart_data,
)
from superset.commands.report.exceptions import (
ReportScheduleAlertGracePeriodError,
ReportScheduleClientErrorsException,
@@ -1001,7 +997,7 @@ class BaseReportState:
raise URLError(response.getcode())
return content or None
def _get_data(self, result_format: ChartDataResultFormat) -> bytes: # noqa: C901
def _get_data(self, result_format: ChartDataResultFormat) -> bytes:
"""
Fetch tabular chart data (CSV or Excel) as raw bytes.
@@ -1039,56 +1035,50 @@ class BaseReportState:
self._update_query_context(failed_error)
db.session.refresh(self._report_schedule.chart)
def get_timeout() -> float | None:
"""Cap every request by the available data-generation budget."""
return self._phase_timeout(
"data_generation",
requested_seconds=app.config["ALERT_REPORTS_CSV_REQUEST_TIMEOUT"],
reserve_seconds=(
self._report_execution_context.post_capture_reserve_seconds
if self._report_execution_context
else 0.0
),
)
try:
if self._report_schedule.chart.query_context is None:
url = self._get_url(result_format=result_format)
endpoint = "/api/v1/chart/{id}/data/"
def fetch(timeout: float | None) -> bytes | None:
"""Fetch the legacy export without exposing its URL in logs."""
return get_chart_csv_data(
chart_url=url, auth_cookies=auth_cookies, timeout=timeout
)
data = get_chart_csv_data(
chart_url=url,
auth_cookies=auth_cookies,
timeout=self._phase_timeout(
"data_generation",
requested_seconds=app.config[
"ALERT_REPORTS_CSV_REQUEST_TIMEOUT"
],
reserve_seconds=(
self._report_execution_context.post_capture_reserve_seconds
if self._report_execution_context
else 0.0
),
),
)
else:
request_payload = self._get_chart_data_request_payload(result_format)
url = get_url_path("ChartDataRestApi.data")
endpoint = "/api/v1/chart/data"
def fetch(timeout: float | None) -> bytes | None:
"""Use the saved query context's existing POST export path."""
return self._post_chart_data(
chart_url=url,
auth_cookies=auth_cookies,
request_payload=request_payload,
timeout=timeout,
)
data = request_chart_data(
fetch,
get_timeout,
retry=app.config["ALERT_REPORTS_CSV_REQUEST_RETRY"],
endpoint=endpoint,
log_context=self._log_context,
)
data = self._post_chart_data(
chart_url=url,
auth_cookies=auth_cookies,
request_payload=request_payload,
timeout=self._phase_timeout(
"data_generation",
requested_seconds=app.config[
"ALERT_REPORTS_CSV_REQUEST_TIMEOUT"
],
reserve_seconds=(
self._report_execution_context.post_capture_reserve_seconds
if self._report_execution_context
else 0.0
),
),
)
elapsed_seconds: float = (
datetime.now(timezone.utc).replace(tzinfo=None) - start_time
).total_seconds()
logger.info(
"%s data generation from %s as user %s took %.2fs - execution_id: %s",
label,
endpoint,
url,
username,
elapsed_seconds,
self._execution_id,
@@ -1106,10 +1096,6 @@ class BaseReportState:
if self._report_schedule.type == ReportScheduleType.REPORT:
raise
raise timeout_error() from ex
except ChartDataRequestError as ex:
if ex.category == "timeout":
raise timeout_error() from ex
raise failed_error(str(ex)) from ex
except ReportExecutionBudgetExceededError:
raise
except Exception as ex:
@@ -1859,9 +1845,9 @@ class ReportNotTriggeredErrorState(BaseReportState):
second_error_message = str(second_ex)
finally:
try:
# Notification bookkeeping is not another execution outcome.
self.create_log(
second_error_message,
self.update_report_schedule_and_log(
ReportState.ERROR,
error_message=second_error_message,
include_execution_warnings=False,
)
except ReportScheduleUnexpectedError:
@@ -2064,9 +2050,8 @@ class ReportSuccessState(BaseReportState):
second_error_message = str(second_ex)
finally:
try:
# Preserve the grace-period marker without another terminal log.
self.create_log(
second_error_message, include_execution_warnings=False
self.update_report_schedule_and_log(
ReportState.ERROR, error_message=second_error_message
)
except ReportScheduleUnexpectedError:
# Logging failed again; log it but don't hide first_ex
+15 -63
View File
@@ -78,41 +78,6 @@ def _get_timegrains(
return {"data": grains}
def _filter_status(
datasource: Explorable,
query_obj: QueryObject,
applied_filter_columns: list[Any],
rejected_filter_columns: list[Any],
) -> dict[str, Any]:
"""Describe which of the query's filters reached the generated SQL.
Used by both SQL-only and data-bearing results so the public filter status
and the datasource-only rejection carrier cannot diverge.
"""
applied_time_columns, rejected_time_columns = get_time_filter_status(
datasource, query_obj.applied_time_extras
)
return {
"applied_filters": [
{"column": get_column_name(col)} for col in applied_filter_columns
]
+ applied_time_columns,
"rejected_filters": [
{
"reason": ExtraFiltersReasonType.COL_NOT_IN_DATASOURCE,
"column": get_column_name(col),
}
for col in rejected_filter_columns
]
+ rejected_time_columns,
# Keep the datasource rejection origin available to consumers that
# must distinguish it from temporal pseudo-filter status above.
"rejected_filter_columns": [
get_column_name(col) for col in rejected_filter_columns
],
}
def _get_query(
query_context: QueryContext,
query_obj: QueryObject,
@@ -121,26 +86,7 @@ def _get_query(
datasource = _get_datasource(query_context, query_obj)
result = {"language": datasource.query_language}
try:
# Prefer the extended form so the rejected/applied filter columns the
# datasource computed while building the query are not discarded: a
# filter silently dropped during query construction is otherwise
# invisible to anyone requesting only the SQL. Datasources that do not
# implement it (e.g. semantic layers) keep the plain string form.
if get_query_str_extended := getattr(
datasource, "get_query_str_extended", None
):
extended = get_query_str_extended(query_obj.to_dict())
result["query"] = extended.full_sql
result.update(
_filter_status(
datasource,
query_obj,
extended.applied_filter_columns,
extended.rejected_filter_columns,
)
)
else:
result["query"] = datasource.get_query_str(query_obj.to_dict())
result["query"] = datasource.get_query_str(query_obj.to_dict())
except QueryObjectValidationError as err:
# Validation errors (missing required fields, invalid config)
# No SQL was generated
@@ -239,18 +185,24 @@ def _materialize_full_payload(
)
del payload["df"]
applied_time_columns, rejected_time_columns = get_time_filter_status(
datasource, query_obj.applied_time_extras
)
applied_filter_columns = payload.get("applied_filter_columns", [])
rejected_filter_columns = payload.get("rejected_filter_columns", [])
del payload["applied_filter_columns"]
del payload["rejected_filter_columns"]
payload.update(
_filter_status(
datasource,
query_obj,
applied_filter_columns,
rejected_filter_columns,
)
)
payload["applied_filters"] = [
{"column": get_column_name(col)} for col in applied_filter_columns
] + applied_time_columns
payload["rejected_filters"] = [
{
"reason": ExtraFiltersReasonType.COL_NOT_IN_DATASOURCE,
"column": get_column_name(col),
}
for col in rejected_filter_columns
] + rejected_time_columns
if result_type == ChartDataResultType.RESULTS and status != QueryStatus.FAILED:
return {
+3 -9
View File
@@ -740,6 +740,9 @@ DEFAULT_FEATURE_FLAGS: dict[str, bool] = {
# Enable Table V2 time comparison feature
# @lifecycle: development
"TABLE_V2_TIME_COMPARISON_ENABLED": False,
# Enables the tagging system for organizing assets
# @lifecycle: development
"TAGGING_SYSTEM": False,
# Enables the version history panel on Explore and Dashboard pages.
# History only accrues while ``ENABLE_VERSIONING_CAPTURE`` is also on;
# with capture off the panel renders empty or stale history, so the two
@@ -823,9 +826,6 @@ DEFAULT_FEATURE_FLAGS: dict[str, bool] = {
# @lifecycle: testing
# @docs: https://superset.apache.org/docs/configuration/setup-ssh-tunneling
"SSH_TUNNELING": False,
# Enables the tagging system for organizing assets
# @lifecycle: testing
"TAGGING_SYSTEM": True,
# Enable AWS IAM authentication for database connections (Aurora, Redshift).
# Allows cross-account role assumption via STS AssumeRole.
# Security note: When enabled, ensure Superset's IAM role has restricted
@@ -2545,9 +2545,6 @@ ALERT_REPORTS_QUERY_EXECUTION_MAX_TRIES = 1
# which leaves the report schedule stuck in the WORKING state. Set to None to
# disable (not recommended).
ALERT_REPORTS_CSV_REQUEST_TIMEOUT = 60
# Opt in to at most one transient CSV/Excel transport retry within the original
# request timeout and report execution budget. Does not retry unbounded requests.
ALERT_REPORTS_CSV_REQUEST_RETRY = False
# Custom width for screenshots
ALERT_REPORTS_MIN_CUSTOM_SCREENSHOT_WIDTH = 600
ALERT_REPORTS_MAX_CUSTOM_SCREENSHOT_WIDTH = 2400
@@ -3032,9 +3029,6 @@ GUEST_ROLE_NAME = "Public"
GUEST_TOKEN_JWT_SECRET = CHANGE_ME_GUEST_TOKEN_JWT_SECRET
GUEST_TOKEN_JWT_ALGO = "HS256" # noqa: S105
GUEST_TOKEN_HEADER_NAME = "X-GuestToken" # noqa: S105
# Diagnostic budget for UTF-8 bytes of "header-name: encoded-token\r\n".
# None disables size warnings, not issuance or authentication. Deployment-specific.
GUEST_TOKEN_HEADER_MAX_BYTES: int | None = None
GUEST_TOKEN_JWT_EXP_SECONDS = 300 # 5 minutes
# Audience for the Superset guest token used in embedded mode.
# Can be a string or a callable. Defaults to WEBDRIVER_BASEURL.
+4 -13
View File
@@ -179,20 +179,11 @@ class DatabaseDAO(BaseDAO[Database]):
@staticmethod
def get_database_by_name(database_name: str) -> Database | None:
"""
Look up a database by name, scoped to the requesting user's object-level
visibility (the same ``DatabaseFilter`` boundary ``find_by_id``/
``get_connection`` already apply). An unfiltered lookup would let any
principal with class-level ``can_write`` reference an arbitrary
existing database by name -- including one they have no catalog,
schema, datasource, or database access to -- and ride along with
whatever secret-rehydration behavior callers apply to the result.
"""
query = db.session.query(Database).filter(
Database.database_name == database_name
return (
db.session.query(Database)
.filter(Database.database_name == database_name)
.one_or_none()
)
query = DatabaseDAO._apply_base_filter(query)
return query.one_or_none()
@staticmethod
def build_db_for_connection_test(
-4
View File
@@ -96,7 +96,6 @@ class DatasourceDAO(BaseDAO[Datasource]):
name_filter: str | None,
sql_filter: bool | None,
database_id: int | None = None,
schema_filter: str | None = None,
) -> Select:
"""Build a SELECT for datasets, applying access and content filters."""
ds_table = SqlaTable.__table__
@@ -142,9 +141,6 @@ class DatasourceDAO(BaseDAO[Datasource]):
if database_id is not None:
ds_q = ds_q.where(SqlaTable.database_id == database_id)
if schema_filter is not None:
ds_q = ds_q.where(SqlaTable.schema == schema_filter)
return ds_q
@staticmethod
+1 -6
View File
@@ -97,12 +97,7 @@ class EmbeddedView(BaseSupersetView):
bootstrap_data = {
"config": {
"GUEST_TOKEN_HEADER_NAME": current_app.config[
"GUEST_TOKEN_HEADER_NAME"
],
"GUEST_TOKEN_HEADER_MAX_BYTES": current_app.config[
"GUEST_TOKEN_HEADER_MAX_BYTES"
],
"GUEST_TOKEN_HEADER_NAME": current_app.config["GUEST_TOKEN_HEADER_NAME"]
},
"common": common_bootstrap_payload(),
"embedded": {
+1 -2
View File
@@ -44,9 +44,8 @@ class SupersetMetastoreCache(BaseCache):
namespace: UUID,
codec: KeyValueCodec,
default_timeout: int = 300,
ignore_delete_many_errors: bool = False,
) -> None:
super().__init__(default_timeout, ignore_delete_many_errors)
super().__init__(default_timeout)
self.namespace = namespace
self.codec = codec
@@ -30,7 +30,6 @@ from typing import Any, TYPE_CHECKING
from urllib.parse import parse_qs, urlparse
from superset.constants import EXTRA_FORM_DATA_OVERRIDE_REGULAR_MAPPINGS
from superset.utils.core import ExtraFiltersReasonType
if TYPE_CHECKING:
from superset.mcp_service.chart.schemas import AppliedDashboardFilter
@@ -63,75 +62,6 @@ class ChartNotOnDashboardError(ValueError):
"""Raised when a chart is not part of the given dashboard's slices."""
def requested_filter_columns(extra_form_data: dict[str, Any] | None) -> set[str]:
"""Return simple column names explicitly requested through extra form data."""
if not extra_form_data:
return set()
columns: set[str] = set()
for filter_ in extra_form_data.get("filters") or []:
if isinstance(filter_, dict) and isinstance(column := filter_.get("col"), str):
columns.add(column)
for filter_ in extra_form_data.get("adhoc_filters") or []:
if (
isinstance(filter_, dict)
and filter_.get("expressionType") == "SIMPLE"
and isinstance(column := filter_.get("subject"), str)
):
columns.add(column)
return columns
def rejected_columns_in_query(query: Any) -> set[str]:
"""Return the rejected filter column names reported by one query payload.
Query construction reports dropped filters as ``rejected_filters`` entries
(``{"reason": ..., "column": ...}``), the shape every consumer of a
chart-data or query payload sees. The raw ``rejected_filter_columns`` list
is still accepted for payloads captured before that conversion.
"""
if not isinstance(query, dict):
return set()
# QUERY results retain the datasource-only list so temporal pseudo-filter
# rejections cannot be mistaken for ordinary filters with the same name.
# Prefer it whenever present, including when it is empty.
if "rejected_filter_columns" in query:
return {
column
for column in query.get("rejected_filter_columns") or []
if isinstance(column, str)
}
columns = {
column
for entry in query.get("rejected_filters") or []
if isinstance(entry, dict)
and entry.get("reason") != ExtraFiltersReasonType.NO_TEMPORAL_COLUMN
and isinstance(column := entry.get("column"), str)
}
return columns
def rejected_requested_filter_columns(
result: Any, extra_form_data: dict[str, Any] | None
) -> list[str]:
"""Find request filters rejected by datasource query construction.
Only columns the caller asked for are reported, so a stale filter stored in
an older chart configuration cannot fail the request.
"""
if not isinstance(result, dict):
return []
requested = requested_filter_columns(extra_form_data)
rejected = {
column
for query in result.get("queries", [])
for column in rejected_columns_in_query(query)
}
return sorted(requested & rejected)
def find_chart_by_identifier(
identifier: int | str,
query_options: list[Any] | None = None,
@@ -43,7 +43,6 @@ from superset.mcp_service.chart.chart_helpers import (
find_chart_by_identifier,
get_cached_form_data,
merge_extra_form_data_filters_into_query,
rejected_requested_filter_columns,
)
from superset.mcp_service.chart.chart_utils import validate_chart_dataset
from superset.mcp_service.chart.schemas import (
@@ -64,6 +63,65 @@ from superset.utils.core import GenericDataType
logger = logging.getLogger(__name__)
def _requested_filter_columns(extra_form_data: dict[str, Any] | None) -> set[str]:
"""Return simple column names explicitly requested through extra form data."""
if not extra_form_data:
return set()
columns: set[str] = set()
for filter_ in extra_form_data.get("filters", []):
if isinstance(filter_, dict) and isinstance(column := filter_.get("col"), str):
columns.add(column)
for filter_ in extra_form_data.get("adhoc_filters", []):
if (
isinstance(filter_, dict)
and filter_.get("expressionType") == "SIMPLE"
and isinstance(column := filter_.get("subject"), str)
):
columns.add(column)
return columns
def _rejected_columns_in_query(query: Any) -> set[str]:
"""Return the rejected filter column names reported by one query payload.
``_materialize_full_payload`` converts the datasource's raw
``rejected_filter_columns`` list into the ``rejected_filters`` entries
(``{"reason": ..., "column": ...}``) that every consumer of a chart-data
payload sees, so that is the primary shape to read. The raw key is still
accepted for payloads captured before that conversion.
"""
if not isinstance(query, dict):
return set()
columns = {
column
for entry in query.get("rejected_filters", [])
if isinstance(entry, dict) and isinstance(column := entry.get("column"), str)
}
columns.update(
column
for column in query.get("rejected_filter_columns", [])
if isinstance(column, str)
)
return columns
def _rejected_requested_filter_columns(
result: Any, extra_form_data: dict[str, Any] | None
) -> list[str]:
"""Find request filters rejected by datasource query construction."""
if not isinstance(result, dict):
return []
requested = _requested_filter_columns(extra_form_data)
rejected = {
column
for query in result.get("queries", [])
for column in _rejected_columns_in_query(query)
}
return sorted(requested & rejected)
_GENERIC_TYPE_MAP: dict[int, str] = {
GenericDataType.NUMERIC: "numeric",
GenericDataType.STRING: "string",
@@ -689,7 +747,7 @@ async def get_chart_data( # noqa: C901
command.validate()
result = command.run()
if rejected := rejected_requested_filter_columns(
if rejected := _rejected_requested_filter_columns(
result, request.extra_form_data
):
rejected_columns = ", ".join(rejected)
@@ -1051,7 +1109,7 @@ async def _query_from_form_data( # noqa: C901
command.validate()
result = command.run()
if rejected := rejected_requested_filter_columns(
if rejected := _rejected_requested_filter_columns(
result, request.extra_form_data
):
rejected_columns = ", ".join(rejected)
@@ -38,7 +38,6 @@ from superset.mcp_service.chart.chart_helpers import (
build_query_context_from_form_data,
extract_x_axis_col,
merge_extra_form_data_filters_into_query,
rejected_requested_filter_columns,
resolve_form_data_datasource,
resolve_groupby,
resolve_metrics,
@@ -239,11 +238,7 @@ def _sql_from_saved_query_context(
result = command.run()
return _extract_sql_from_result(
result,
chart.id,
chart.slice_name,
chart.datasource_name,
extra_form_data=extra_form_data,
result, chart.id, chart.slice_name, chart.datasource_name
)
except SupersetSecurityException:
raise # Let access denials propagate for consistent error handling
@@ -331,7 +326,6 @@ def _sql_from_form_data(
chart_id=getattr(chart, "id", None),
chart_name=getattr(chart, "slice_name", None),
datasource_name=_resolve_datasource_name(form_data, chart),
extra_form_data=extra_form_data,
)
@@ -340,7 +334,6 @@ def _extract_sql_from_result(
chart_id: int | None,
chart_name: str | None,
datasource_name: str | None,
extra_form_data: dict[str, Any] | None = None,
) -> ChartSql | ChartError:
"""Extract SQL query string(s) from the ChartDataCommand result.
@@ -357,16 +350,6 @@ def _extract_sql_from_result(
error_type="EmptyQuery",
)
# A filter naming a column the dataset does not have is dropped during query
# construction. Returning the resulting unfiltered SQL as a success would
# misrepresent it as the SQL for the filters that were asked for.
if rejected := rejected_requested_filter_columns(result, extra_form_data):
rejected_columns = ", ".join(rejected)
return ChartError(
error=f"Unknown dataset column(s) in filters: {rejected_columns}",
error_type="ValidationError",
)
sql_parts: list[str] = []
errors: list[str] = []
language = "sql"
+8 -35
View File
@@ -80,6 +80,7 @@ from superset import db, is_feature_enabled
from superset.advanced_data_type.types import AdvancedDataTypeResponse
from superset.common.db_query_status import QueryStatus
from superset.common.grouping_sets import (
grouping_id_column,
grouping_marker_label,
grouping_sets_clause,
)
@@ -1744,11 +1745,6 @@ class QueryStringExtended(NamedTuple):
sql: str
sql_shifted_temporal_labels: set[str]
@property
def full_sql(self) -> str:
"""The prequeries and the main query as one displayable statement."""
return ";\n\n".join([*self.prequeries, self.sql]) + ";"
class SqlaQuery(NamedTuple):
applied_template_filters: list[str]
@@ -3644,10 +3640,6 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
from_sql = parsed_script.format()
except Exception as ex: # pylint: disable=broad-except
# A caught DB error can leave db.session in "pending rollback"
# state, which would poison unrelated queries later in this request.
db.session.rollback() # pylint: disable=consider-using-transaction
# RLS injection failures fail closed: only continue when it is
# positively confirmed that no RLS predicates apply to the
# referenced tables; any other outcome aborts the query.
@@ -3868,7 +3860,9 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
return values
def get_query_str(self, query_obj: QueryObjectDict) -> str:
return self.get_query_str_extended(query_obj).full_sql
query_str_ext = self.get_query_str_extended(query_obj)
all_queries = query_str_ext.prequeries + [query_str_ext.sql]
return ";\n\n".join(all_queries) + ";"
def _get_series_orderby(
self,
@@ -4866,31 +4860,10 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
and groupby_all_columns
and db_engine_spec.supports_grouping_sets
)
# Both the GROUPING() marker labels and the `grouping_sets` level
# definitions sent by the frontend (see buildQuery.ts) are expressed in
# terms of the column's logical/requested label (``.key``), not the
# engine-mutated SQL alias (``.name``). BigQuery, for example, mangles
# labels containing spaces (e.g. a Custom SQL column named "Test Row")
# into something like "Test_Row_a1b2c3" for `.name`, while `.key` keeps
# the original "Test Row". Keying by `.name` here would silently drop
# such columns from every rollup level (the `col in ...` guard below),
# producing an invalid ``GROUP BY GROUPING SETS`` clause that omits a
# selected, non-aggregated column.
groupby_columns_by_label = {
gby_expr.key: gby_expr for gby_expr in groupby_all_columns.values()
}
if use_grouping_sets:
# Route the marker through `make_sqla_column_compatible` like every
# other selected column: the SQL-level alias is engine-mutated if
# required (e.g. BigQuery rejects aliases with spaces), while
# `.key` keeps the unmutated marker label so it lines up with the
# `groupby_columns_by_label` keys above and with what the frontend
# looks for when splitting the combined result back per level.
select_exprs = select_exprs + [
self.make_sqla_column_compatible(
sa.func.grouping(gby_expr), grouping_marker_label(label)
)
for label, gby_expr in groupby_columns_by_label.items()
grouping_id_column(gby_expr, grouping_marker_label(name))
for name, gby_expr in groupby_all_columns.items()
]
# Expected output columns
@@ -4907,9 +4880,9 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
if use_grouping_sets:
gs_levels = [
[
groupby_columns_by_label[col]
groupby_all_columns[col]
for col in level
if col in groupby_columns_by_label
if col in groupby_all_columns
]
for level in grouping_sets or []
]
+8 -16
View File
@@ -245,23 +245,15 @@ class SecurityRestApi(BaseSupersetApi):
body["rls"],
**({"datasets": body["datasets"]} if "datasets" in body else {}),
)
audit_payload = build_guest_token_audit_payload(
issuer_user_id=get_user_id(),
source_ip=request.remote_addr,
body=body,
token=token,
header_name=current_app.config["GUEST_TOKEN_HEADER_NAME"],
header_budget_bytes=current_app.config["GUEST_TOKEN_HEADER_MAX_BYTES"],
logger.info(
"Guest token issued: %s",
build_guest_token_audit_payload(
issuer_user_id=get_user_id(),
source_ip=request.remote_addr,
body=body,
token=token,
),
)
logger.info("Guest token issued: %s", audit_payload)
if audit_payload["header_budget_exceeded"]:
logger.warning(
"Guest token exceeds configured request-header budget: "
"token_bytes=%s header_bytes=%s header_budget_bytes=%s",
audit_payload["token_bytes"],
audit_payload["header_bytes"],
audit_payload["header_budget_bytes"],
)
return self.response(200, token=token)
except EmbeddedDashboardNotFoundError as error:
return self.response_400(message=error.message)
-19
View File
@@ -31,8 +31,6 @@ def build_guest_token_audit_payload(
source_ip: Optional[str],
body: dict[str, Any],
token: str,
header_name: str = "X-GuestToken",
header_budget_bytes: object = None,
) -> dict[str, Any]:
"""Build security-relevant metadata for a guest-token issuance event.
@@ -42,24 +40,7 @@ def build_guest_token_audit_payload(
"""
resources = body.get("resources") or []
rls = body.get("rls") or []
token_bytes = len(token.encode("utf-8"))
# HTTP/1-style accounting: name + colon-space + value + CRLF.
header_bytes = token_bytes + len(header_name.encode("utf-8")) + 4
# Match JavaScript's positive safe-integer budget, without coercing settings.
# Invalid deployment values must not turn successful issuance into an error.
budget = (
int(header_budget_bytes)
if isinstance(header_budget_bytes, (int, float))
and not isinstance(header_budget_bytes, bool)
and 0 < header_budget_bytes <= 2**53 - 1
and int(header_budget_bytes) == header_budget_bytes
else None
)
return {
"token_bytes": token_bytes,
"header_bytes": header_bytes,
"header_budget_bytes": budget,
"header_budget_exceeded": budget is not None and header_bytes > budget,
"issuer_user_id": issuer_user_id,
"source_ip": source_ip,
"resources": [
-31
View File
@@ -170,28 +170,6 @@ def get_object_type(class_name: str) -> ObjectType:
) from ex
def tagging_enabled() -> bool:
"""
Whether the tagging system is enabled.
The SQLA event listeners below are attached unconditionally at app startup
(see ``superset.app.SupersetApp.sync_config_to_db``). Listeners that
*create* tags check the flag when they fire, so the flag, including a
runtime override, is honored on the write path the same way it is on the
UI, export and import paths. The ``after_delete`` listeners deliberately
skip this check: they only remove ``tagged_object`` rows, and skipping that
cleanup would orphan rows pointing at a deleted object whose id may later
be reused.
"""
# Resolved through the manager on every call rather than bound at import
# time, so that patching the manager (as the tests do) takes effect.
from superset.extensions import ( # pylint: disable=import-outside-toplevel
feature_flag_manager,
)
return feature_flag_manager.is_feature_enabled("TAGGING_SYSTEM")
class ObjectUpdater:
object_type: str = "default"
@@ -257,9 +235,6 @@ class ObjectUpdater:
connection: Connection,
target: Dashboard | FavStar | Slice | Query | SqlaTable,
) -> None:
if not tagging_enabled():
return
with Session(bind=connection) as session: # pylint: disable=disallowed-name
# add `editor:` tags
cls._add_editors(session, target)
@@ -278,9 +253,6 @@ class ObjectUpdater:
connection: Connection,
target: Dashboard | FavStar | Slice | Query | SqlaTable,
) -> None:
if not tagging_enabled():
return
with Session(bind=connection) as session: # pylint: disable=disallowed-name
# Fetch current editor tags
existing_tags = (
@@ -373,9 +345,6 @@ class FavStarUpdater:
def after_insert(
cls, _mapper: Mapper, connection: Connection, target: FavStar
) -> None:
if not tagging_enabled():
return
with Session(bind=connection) as session: # pylint: disable=disallowed-name
name = f"favorited_by:{target.user_id}"
tag = get_tag(name, session, TagType.favorited_by)
+1 -15
View File
@@ -31,11 +31,7 @@ from superset_core.tasks.types import TaskStatus
from superset import is_feature_enabled
from superset.commands.exceptions import CommandException
from superset.commands.logs.prune import LogPruneCommand
from superset.commands.report.exceptions import (
ReportScheduleCsvTimeout,
ReportScheduleUnexpectedError,
ReportScheduleXlsxTimeout,
)
from superset.commands.report.exceptions import ReportScheduleUnexpectedError
from superset.commands.report.execute import AsyncExecuteReportScheduleCommand
from superset.commands.report.log_prune import AsyncPruneReportScheduleLogCommand
from superset.commands.sql_lab.query import QueryPruneCommand
@@ -182,16 +178,6 @@ def execute(
"An unexpected error occurred while executing the report: %s", task_id
)
self.update_state(state="FAILURE")
except (ReportScheduleCsvTimeout, ReportScheduleXlsxTimeout):
# Attachment generation timeouts are failed executions, despite their
# HTTP 408 status. Keep them visible to error-level task monitoring.
logger.exception(
"Report attachment generation timed out; execution_id=%s "
"report_schedule_id=%s",
task_id,
report_schedule_id,
)
self.update_state(state="FAILURE")
except CommandException as ex:
logger_func, level = get_logger_from_status(ex.status)
logger_func(
+1 -1
View File
@@ -325,7 +325,7 @@ def etag_cache( # noqa: C901
wrapper.uncached = f # type: ignore
wrapper.cache_timeout = timeout # type: ignore
wrapper.make_cache_key = cache._memoize_make_cache_key( # type: ignore # pylint: disable=protected-access
make_name=None, hash_method=configurable_hash_method
make_name=None, timeout=timeout, hash_method=configurable_hash_method
)
return wrapper
+4
View File
@@ -147,12 +147,16 @@ class SupersetCache(Cache):
def _memoize_make_cache_key(
self,
make_name: Callable[..., Any] | None = None,
timeout: Callable[..., Any] | None = None,
forced_update: bool = False,
hash_method: Callable[..., Any] = configurable_hash_method,
source_check: bool | None = False,
args_to_ignore: Any | None = None,
) -> Callable[..., Any]:
return super()._memoize_make_cache_key(
make_name=make_name,
timeout=timeout,
forced_update=forced_update,
hash_method=hash_method,
source_check=source_check,
args_to_ignore=args_to_ignore,
+4 -5
View File
@@ -16,7 +16,6 @@
# under the License.
import logging
import urllib.request
from contextlib import closing
from typing import Any, Optional, Union
from urllib.error import URLError
@@ -120,10 +119,10 @@ def get_chart_csv_data(
opener.addheaders.append(("Cookie", cookie_str))
# A missing timeout means the socket blocks forever when the Superset
# webserver is unreachable, wedging the report schedule in WORKING.
with closing(opener.open(chart_url, timeout=timeout)) as response:
content = response.read()
if response.getcode() != 200:
raise URLError(response.getcode())
response = opener.open(chart_url, timeout=timeout)
content = response.read()
if response.getcode() != 200:
raise URLError(response.getcode())
if content:
return content
return None
-97
View File
@@ -35,10 +35,6 @@ logger = logging.getLogger(__name__)
# Time to wait after scrolling for content to settle and load (in milliseconds)
SCROLL_SETTLE_TIMEOUT_MS = 1000
# Ceiling for un-clipping scrollable chart content (ag-Grid stabilization
# polling) before a screenshot, absent a report deadline to bound it against.
EXPAND_SCROLLABLE_CONTENT_MAX_WAIT_SECONDS = 5.0
# Chromium can occasionally return a valid but uniformly blank PNG for an
# off-screen clip. Retry after forcing a compositor frame, but keep each CDP
# capture bounded so a wedged compositor cannot consume the report deadline.
@@ -441,99 +437,6 @@ CHART_CONTAINER_STATE_JS = f"""
}}
"""
CHART_CONTAINER_SELECTOR = ".chart-container"
# `.slice_container` (superset-frontend/src/components/Chart/Chart.tsx) is
# the one ancestor every chart type shares, directly inside `.chart-container`,
# with an explicit pixel height matching the dashboard tile. A locator-bounded
# capture (`element.screenshot()`, used for single-chart exports) clips to
# `.chart-container`'s own bounding box, which only has a `min-height` --
# so it stays exactly `.slice_container`'s fixed height unless that fixed
# height is lifted too. Un-clipping a scrollable *descendant* (the ag-Grid
# host, a table's own scroll body) is not enough on its own: the descendant
# can grow, but its ancestor's box does not, and the extra content just
# overflows the ancestor unseen by a bounding-box screenshot (#38090).
SLICE_CONTAINER_SELECTOR_FOR_EXPANSION = ".slice_container"
# Legacy/other chart-table implementations that scroll via an inline style
# rather than a stable class name (e.g. plugin-chart-table's sticky body,
# `superset-frontend/plugins/plugin-chart-table/src/DataTable/hooks/useSticky.tsx`)
# aren't reachable by a fixed class-selector list, so this catches any
# descendant of a chart that is *actually* clipping its own content
# (scrollHeight > clientHeight) rather than guessing at class names that may
# not exist in every plugin version. `.ant-table-body` is kept alongside it
# for a real Ant Design `<Table>` if one ever renders inside a chart.
GENERIC_SCROLLABLE_DESCENDANT_SELECTOR = (
f'{CHART_CONTAINER_SELECTOR} [style*="overflow"], '
f"{CHART_CONTAINER_SELECTOR} .ant-table-body"
)
# ag-Grid virtualizes rows for performance, so a plain height/overflow reset
# would still leave off-screen rows unrendered. `domLayout: "print"` is
# ag-Grid's own "render every row into the DOM" mode -- the same mode the
# client-side "download as image" export switches to via the GridApi that
# ThemedAgGridReact (superset-ui-core) stashes on the grid's host element
# specifically so screenshot/export code can reach it. The grid's own host
# element and its immediate parent (the ag-Grid table plugin's container,
# which sets an explicit pixel height via inline style -- see
# `plugin-chart-ag-grid-table/src/AgGridTable/index.tsx`) are reset for the
# same ancestor-box reason as `.slice_container` above.
#
# `page.screenshot(full_page=True)` already expands the outer dashboard
# scroll to include every below-the-fold chart (#31158); it has no effect on
# a chart's own internal scroll container, which is what this JS unrolls
# in-place before the page is captured.
EXPAND_SCROLLABLE_CONTENT_JS = f"""
async (maxWaitMs) => {{
const agGrids = Array.from(
document.querySelectorAll('{AG_GRID_HOST_SELECTOR}')
);
await Promise.all(agGrids.map(async (grid) => {{
const api = grid._agGridApi;
if (!api) {{ return; }}
api.setGridOption('domLayout', 'print');
if (api.resetRowHeights) {{ api.resetRowHeights(); }}
grid.style.height = 'auto';
if (grid.parentElement) {{ grid.parentElement.style.height = 'auto'; }}
// ag-Grid's autoHeight rows batch-measure asynchronously, so this
// polls for a stable scrollHeight instead of a fixed sleep. Five
// consecutive unchanged 100ms polls is a deliberate match for the
// client-side export's own
// waitForStableScrollHeight(agRootWrapper, 5000, 5) (downloadAsImage.tsx):
// always paid in full even when nothing is still settling, so both
// paths trust the measurement after the same wait rather than
// racing a batch that hasn't finished yet.
let lastHeight = grid.scrollHeight;
let stableCount = 0;
const deadline = Date.now() + maxWaitMs;
while (Date.now() < deadline && stableCount < 5) {{
await new Promise((resolve) => setTimeout(resolve, 100));
const height = grid.scrollHeight;
if (height === lastHeight) {{
stableCount += 1;
}} else {{
stableCount = 0;
lastHeight = height;
}}
}}
}}));
document.querySelectorAll('{SLICE_CONTAINER_SELECTOR_FOR_EXPANSION}').forEach(
(el) => {{ el.style.height = 'auto'; }}
);
document.querySelectorAll('{GENERIC_SCROLLABLE_DESCENDANT_SELECTOR}').forEach(
(el) => {{
if (el.scrollHeight > el.clientHeight) {{
el.style.overflow = 'visible';
el.style.height = 'auto';
el.style.maxHeight = 'none';
}}
}}
);
}}
"""
def combine_screenshot_tiles(
screenshot_tiles: list[bytes],
-68
View File
@@ -34,8 +34,6 @@ from superset.utils.screenshot_utils import (
CHART_CONTAINER_READY_JS,
CHART_CONTAINER_STATE_JS,
CHART_HOLDERS_READY_JS,
EXPAND_SCROLLABLE_CONTENT_JS,
EXPAND_SCROLLABLE_CONTENT_MAX_WAIT_SECONDS,
FIND_ALL_UNREADY_CHART_HOLDERS_JS,
FIND_CHART_HOLDER_STATES_JS,
FORCE_ALL_CHART_HOLDERS_IN_VIEW_JS,
@@ -240,43 +238,6 @@ class WebDriverPlaywright(WebDriverProxy):
else:
return element.screenshot(**timeout_kwargs)
@staticmethod
def _expand_scrollable_content(
page: Page,
log_context: str | None = None,
report_execution_context: ReportExecutionContext | None = None,
) -> None:
"""
Un-clip chart content that is fully present in the DOM but visually
cropped by a fixed height + internal scrollbar (e.g. a table taller
than the space its dashboard tile gives it) before the page is
captured.
The ag-Grid branch of this step polls for a stable row count, so it
is bounded by the report's own deadline the same way every other
wait in this method is, rather than an unconditional fixed sleep.
Best-effort: a failure here should not abort the screenshot, since a
clipped-but-present capture beats none at all.
"""
max_wait_seconds = (
report_execution_context.deadline.timeout_seconds(
"scrollable_content_expansion",
requested_seconds=EXPAND_SCROLLABLE_CONTENT_MAX_WAIT_SECONDS,
reserve_seconds=report_execution_context.readiness_reserve_seconds,
)
if report_execution_context
else EXPAND_SCROLLABLE_CONTENT_MAX_WAIT_SECONDS
)
try:
page.evaluate(EXPAND_SCROLLABLE_CONTENT_JS, max_wait_seconds * 1000)
except PlaywrightError:
logger.warning(
"Failed to expand scrollable chart content before screenshot%s",
f" [{log_context}]" if log_context else "",
exc_info=True,
)
@staticmethod
def _wait_for_charts_ready( # noqa: C901
page: Page,
@@ -751,19 +712,6 @@ class WebDriverPlaywright(WebDriverProxy):
unexpected_errors,
context_suffix,
)
# Un-clip scrollable/virtualized chart content (dense tables
# taller than their dashboard tile) before measuring height,
# so the tiling decision below sees the full content when
# possible. A chart whose ag-Grid hasn't fired GridReady yet
# at this point is re-expanded below, after readiness --
# `.chart-container` elements attaching (waited on above) is
# not the same as ag-Grid finishing its own internal init.
WebDriverPlaywright._expand_scrollable_content(
page,
log_context=log_context,
report_execution_context=report_execution_context,
)
# Detect large dashboards and use tiled screenshots if enabled
tiled_enabled = app.config.get("SCREENSHOT_TILED_ENABLED", False)
@@ -917,14 +865,6 @@ class WebDriverPlaywright(WebDriverProxy):
screenshot_started_at=screenshot_started_at,
report_execution_context=report_execution_context,
)
# Re-run now that readiness has confirmed every chart
# actually rendered: a grid whose GridReady hadn't
# fired yet at the earlier call above is expanded here.
WebDriverPlaywright._expand_scrollable_content(
page,
log_context=log_context,
report_execution_context=report_execution_context,
)
if selenium_animation_wait > 0:
if report_execution_context:
selenium_animation_wait = min(
@@ -989,14 +929,6 @@ class WebDriverPlaywright(WebDriverProxy):
screenshot_started_at=screenshot_started_at,
report_execution_context=report_execution_context,
)
# Re-run now that readiness has confirmed every chart
# actually rendered: a grid whose GridReady hadn't fired
# yet at the earlier call above is expanded here.
WebDriverPlaywright._expand_scrollable_content(
page,
log_context=log_context,
report_execution_context=report_execution_context,
)
if selenium_animation_wait > 0:
if report_execution_context:
selenium_animation_wait = min(
+55 -101
View File
@@ -24,11 +24,9 @@ request:
* :func:`collect_impact_pairs` pulls the distinct
``(dataset_id, transaction_id)`` pairs that need counts.
* :func:`batch_chart_counts` counts the matching charts without a
join: dashboard membership comes from ``charts_attached_to_dashboard``'s
attach/detach windows over ``dashboard_slices_version``, and a
member-scoped ``slices_version`` scan supplies the chartdataset window;
the two are combined per pair by :func:`_count_attached_charts_at`.
* :func:`batch_chart_counts` one SQL query joining
``dashboard_slices_version`` and ``slices_version`` to count
the matching charts validity-strategy-style.
* :func:`impact_for_record` pure projection from the pre-fetched
counts onto each record (returns ``None`` for non-Dashboard paths
or non-SqlaTable kinds, matching the ``impact`` computation).
@@ -40,7 +38,6 @@ inside another (no DB).
from __future__ import annotations
from collections.abc import Mapping, Sequence
from typing import Any
import sqlalchemy as sa
@@ -50,14 +47,7 @@ from superset.versioning.activity.kinds import (
chunked_ids,
ENTITY_ID_CHUNK_SIZE,
TABLE_KIND_TO_API,
Window,
)
from superset.versioning.baseline import OPERATION_DELETE
# Headroom left below SQLite's 999 bind-variable floor for the handful of scalar
# binds in the slice-scan WHERE (datasource_type, operation_type, the two tx
# bounds) once a member-id chunk and the dataset IN are accounted for.
_SCALAR_BIND_HEADROOM = 20
def collect_impact_pairs(
@@ -86,17 +76,12 @@ def batch_chart_counts(
distinct charts that were both on *dashboard_id* and pointing at
*dataset_id* at *target_tx*.
No join: ``charts_attached_to_dashboard`` supplies each member chart's
``[attach, detach)`` windows from the association shadow (Continuum never
closes an M2M shadow's ``end_transaction_id``, so a validity-window filter
on it would count a chart removed before ``target_tx`` sc-119907), and a
member-scoped scan of ``slices_version`` supplies the chartdataset window,
whose ``end_transaction_id`` the validity backfill *does* close, so the
ordinary validity predicate is right there. The Python loop counts a slice
for a pair when both an attachment window and its chartdataset window
contain ``target_tx``. Replaces the previous N+1 shape that fired one COUNT
per related record, and the m2mslices join whose M2M validity window was
the buggy naive filter.
One SELECT against ``dashboard_slices_version`` ``slices_version``,
pulling the (slice, dataset, validity-window) state for every slice
ever on the dashboard whose dataset matches one of the requested
dataset_ids. The Python loop then applies the validity-strategy
predicate per pair. Replaces the previous N+1 shape that fired one
COUNT per related record.
Returns ``{(dataset_id, target_tx): count}``; pairs whose count
would be zero are omitted so the caller's ``.get(key, 0)`` is
@@ -109,104 +94,73 @@ def batch_chart_counts(
from sqlalchemy_continuum import version_class
from superset.models.slice import Slice
from superset.versioning.membership import charts_attached_to_dashboard
metadata = version_class(Slice).__table__.metadata
m2m_tbl = metadata.tables.get("dashboard_slices_version")
slices_tbl = version_class(Slice).__table__
dataset_ids: set[int] = {dataset_id for dataset_id, _ in pairs}
target_txs: set[int] = {target_tx for _, target_tx in pairs}
min_tx, max_tx = min(target_txs), max(target_txs)
# Attachment membership per slice. charts_attached_to_dashboard owns the
# association-shadow read and the attach/detach window pairing — the single
# place that must never filter the M2M shadow by end_transaction_id, which
# Continuum never closes (sc-119907). Reused here so restore.py, the
# activity relationship walk, and this rollup share one implementation.
attach_windows: dict[int, list[Window]] = {}
for slice_id, window in charts_attached_to_dashboard(dashboard_id):
attach_windows.setdefault(slice_id, []).append(window)
if not attach_windows:
if m2m_tbl is None:
return {}
# Chart→dataset validity from the slice parent shadow, whose
# end_transaction_id the validity backfill *does* close, so the ordinary
# half-open validity predicate is correct here. Bounded on the DB side to
# this dashboard's member charts (the attach_windows keys) and the
# transaction range; the member-id IN-clause is chunked to stay under
# SQLite's 999 bind-variable floor.
#
# The requested-dataset prune is applied on the DB side too, but only when
# the dataset set co-binds with a full member chunk under that floor — a
# member chunk (<= ENTITY_ID_CHUNK_SIZE) plus the dataset IN plus the few
# scalar binds must stay < 999. When there are too many requested datasets,
# the DB-side dataset predicate is dropped and the combiner filters datasets
# in Python (it already keys on pairs_by_dataset), so a wide dashboard does
# not overflow the bind limit (sc-119907 review).
filter_datasets_in_sql = (
len(dataset_ids) <= 999 - ENTITY_ID_CHUNK_SIZE - _SCALAR_BIND_HEADROOM
)
slice_rows: list[Any] = []
for chunk in chunked_ids(set(attach_windows), ENTITY_ID_CHUNK_SIZE):
conditions = [
slices_tbl.c.id.in_(chunk),
dataset_ids: set[int] = {dataset_id for dataset_id, _ in pairs}
# Bound both validity windows to the transaction range the page-set
# needs. Both the attachment (m2m) and the chart→dataset (slice) window
# must straddle a requested target_tx, so a row whose window starts
# after the newest target, or closes at/before the oldest one, can
# never contribute a match. Without this the join multiplies every
# attachment row by the full slice version history — an unbounded cross
# product on dashboards with long-lived, frequently-edited charts.
target_txs: set[int] = {target_tx for _, target_tx in pairs}
min_tx, max_tx = min(target_txs), max(target_txs)
# Chunk the datasource_id IN-clause to stay under SQLite's bind-variable
# floor (a dashboard pointing at very many datasets can exceed it).
rows: list[Any] = []
for chunk in chunked_ids(dataset_ids, ENTITY_ID_CHUNK_SIZE):
stmt = sa.select(
m2m_tbl.c.slice_id,
slices_tbl.c.datasource_id,
m2m_tbl.c.transaction_id.label("m2m_start"),
m2m_tbl.c.end_transaction_id.label("m2m_end"),
slices_tbl.c.transaction_id.label("slice_start"),
slices_tbl.c.end_transaction_id.label("slice_end"),
).where(
m2m_tbl.c.dashboard_id == dashboard_id,
m2m_tbl.c.operation_type != 2,
slices_tbl.c.id == m2m_tbl.c.slice_id,
slices_tbl.c.datasource_id.in_(chunk),
slices_tbl.c.datasource_type == "table",
slices_tbl.c.operation_type != OPERATION_DELETE,
slices_tbl.c.operation_type != 2,
m2m_tbl.c.transaction_id <= max_tx,
sa.or_(
m2m_tbl.c.end_transaction_id.is_(None),
m2m_tbl.c.end_transaction_id > min_tx,
),
slices_tbl.c.transaction_id <= max_tx,
sa.or_(
slices_tbl.c.end_transaction_id.is_(None),
slices_tbl.c.end_transaction_id > min_tx,
),
]
if filter_datasets_in_sql:
conditions.append(slices_tbl.c.datasource_id.in_(dataset_ids))
stmt = sa.select(
slices_tbl.c.id.label("slice_id"),
slices_tbl.c.datasource_id,
slices_tbl.c.transaction_id.label("slice_start"),
slices_tbl.c.end_transaction_id.label("slice_end"),
).where(*conditions)
slice_rows.extend(db.session.connection().execute(stmt).mappings().all())
)
rows.extend(db.session.connection().execute(stmt).mappings().all())
# For each pair, collect the slice_ids whose two validity windows
# both straddle target_tx. ``set`` dedupes within a pair.
matches: dict[tuple[int, int], set[int]] = {}
pairs_by_dataset: dict[int, list[int]] = {}
for dataset_id, target_tx in pairs:
pairs_by_dataset.setdefault(dataset_id, []).append(target_tx)
return _count_attached_charts_at(attach_windows, slice_rows, pairs_by_dataset)
def _count_attached_charts_at(
attach_windows: dict[int, list[Window]],
slice_rows: Sequence[Mapping[str, Any]],
pairs_by_dataset: dict[int, list[int]],
) -> dict[tuple[int, int], int]:
"""Pure combiner: for each ``(dataset_id, target_tx)``, count the distinct
charts whose attachment window and chartdataset window both contain
``target_tx``.
*attach_windows* maps ``slice_id`` to its ``[attach, detach)`` episodes
(from the association shadow see
:func:`~superset.versioning.activity.windows.attachment_windows`); a chart
removed before ``target_tx`` has no window containing it and is therefore
not counted. *slice_rows* are the chartdataset parent-shadow rows
(``slice_id``, ``datasource_id``, ``slice_start``, ``slice_end``), whose
``end_transaction_id`` (``slice_end``) the validity backfill does close, so
the half-open validity predicate is correct for them. Split out of
:func:`batch_chart_counts` so this membership logic is unit-testable
without a live shadow-table fixture.
"""
matches: dict[tuple[int, int], set[int]] = {}
for row in slice_rows:
windows = attach_windows.get(row["slice_id"])
if not windows:
continue
for row in rows:
ds_id = row["datasource_id"]
for target_tx in pairs_by_dataset.get(ds_id, ()):
in_attach = any(w.contains(target_tx) for w in windows)
in_m2m = row["m2m_start"] <= target_tx and (
row["m2m_end"] is None or row["m2m_end"] > target_tx
)
in_slice = row["slice_start"] <= target_tx and (
row["slice_end"] is None or row["slice_end"] > target_tx
)
if in_attach and in_slice:
if in_m2m and in_slice:
matches.setdefault((ds_id, target_tx), set()).add(row["slice_id"])
return {pair: len(slice_ids) for pair, slice_ids in matches.items()}
+91 -4
View File
@@ -16,10 +16,8 @@
# under the License.
"""DB-touching helpers for the activity-view read path.
The Phase A relationship walks (``datasets_used_by_chart``,
``batch_datasets_used_by_charts``; the dashboard-membership walk
``charts_attached_to_dashboard`` lives in
:mod:`superset.versioning.membership`),
All Phase A relationship walks (``charts_attached_to_dashboard``,
``datasets_used_by_chart``, ``batch_datasets_used_by_charts``),
the Phase B change-record fetch (``fetch_change_records`` /
``_select_change_rows_for_kinds``), the name-denormalization helpers
(``_resolve_names_for_kind`` / ``apply_entity_name_denormalization``), the
@@ -41,6 +39,7 @@ from __future__ import annotations
import logging
from datetime import datetime
from heapq import heappush, heapreplace
from itertools import groupby
from typing import Any
from uuid import UUID
@@ -120,6 +119,94 @@ def first_tracked_tx(
# ---- Phase A: relationship-traversal queries ------------------------------
# ``operation_type`` values on a Continuum association shadow row
# (sqlalchemy_continuum.operation.Operation): INSERT attaches, DELETE detaches.
# UPDATE never occurs for a pure M2M association (there is nothing to update on
# a (dashboard, slice) pair); if it ever appeared it is ignored — neither
# opening nor closing a window — so an open attachment simply continues.
# These mirror the library enum's numeric values; ``test_m2m_op_constants_match_
# continuum`` pins them so a Continuum renumber fails loudly rather than silently.
_M2M_OP_INSERT = 0
_M2M_OP_DELETE = 2
def _attachment_windows(
rows: list[tuple[int, int, int]],
) -> list[tuple[int, Window]]:
"""Pair INSERT / DELETE association-version rows into ``[attach, detach)``
windows, one per attachment episode.
Each row is ``(slice_id, transaction_id, operation_type)``. Continuum
**never closes** an association shadow row's ``end_transaction_id`` — its
unit-of-work only *inserts* association versions
(``create_association_versions``); the validity backfill that sets
``end_transaction_id`` runs for parent objects, not for M2M links. So the
detach boundary lives on the DELETE row's ``transaction_id``, not on the
attach row's ``end_transaction_id`` (which stays NULL for the association's
whole life). An INSERT opens a window; the next DELETE closes it at its
transaction id; an attachment with no following DELETE stays open (the
chart is still on the dashboard). A DELETE at the same transaction as its
open (add-and-remove in one save) yields no window the chart was never
on a committed dashboard state.
"""
result: list[tuple[int, Window]] = []
# operation_type is part of the sort key so that, within one transaction,
# INSERT (0) sorts before DELETE (2): an add-and-remove in a single save is
# then seen open-before-close and collapses to no window (the DELETE finds
# ``tx == open_tx``, not ``>``). Do not drop it from the key.
rows_sorted = sorted(rows, key=lambda r: (r[0], r[1], r[2]))
for slice_id, group in groupby(rows_sorted, key=lambda r: r[0]):
open_tx: int | None = None
for _slice_id, tx, operation_type in group:
if operation_type == _M2M_OP_DELETE:
if open_tx is not None and tx > open_tx:
result.append((slice_id, Window(open_tx, tx)))
open_tx = None
elif operation_type == _M2M_OP_INSERT and open_tx is None:
open_tx = tx
if open_tx is not None:
result.append((slice_id, Window(open_tx, None)))
return result
def charts_attached_to_dashboard(dashboard_id: int) -> list[tuple[int, Window]]:
"""Return ``(slice_id, window)`` for every chart that has ever been on
*dashboard_id*, with each attachment episode's validity window in
transaction-id space.
Reads from ``dashboard_slices_version`` (Continuum's auto-generated M2M
shadow) and pairs its INSERT/DELETE rows via :func:`_attachment_windows`,
so a chart removed from the dashboard is bounded at the detach transaction
rather than open-ended otherwise the chart's edits made *after* removal
would surface in the dashboard's related history.
"""
# pylint: disable=import-outside-toplevel
from sqlalchemy_continuum import version_class
from superset.models.dashboard import Dashboard
metadata = version_class(Dashboard).__table__.metadata
m2m_tbl = metadata.tables.get("dashboard_slices_version")
if m2m_tbl is None:
return []
rows = (
db.session.connection()
.execute(
sa.select(
m2m_tbl.c.slice_id,
m2m_tbl.c.transaction_id,
m2m_tbl.c.operation_type,
).where(
m2m_tbl.c.dashboard_id == dashboard_id,
m2m_tbl.c.slice_id.is_not(None),
)
)
.all()
)
return _attachment_windows([(row[0], row[1], row[2]) for row in rows])
def datasets_used_by_chart(slice_id: int) -> list[tuple[int, Window]]:
"""Return ``(datasource_id, window)`` for every dataset that *slice_id*
has ever pointed at, with each association's validity window.
+1 -1
View File
@@ -35,13 +35,13 @@ from __future__ import annotations
from superset.versioning.activity.kinds import EntityWindows, Window
from superset.versioning.activity.queries import (
batch_datasets_used_by_charts,
charts_attached_to_dashboard,
datasets_used_by_chart,
)
from superset.versioning.activity.windows import (
intersect_windows,
merge_entity_windows,
)
from superset.versioning.membership import charts_attached_to_dashboard
def resolve_scope(
-66
View File
@@ -29,76 +29,10 @@ means "open-ended (current)" and behaves like positive infinity.
from __future__ import annotations
from itertools import groupby
from typing import Any
from superset.versioning.activity.kinds import EntityWindows, Window
# ``operation_type`` values on a Continuum association shadow row
# (sqlalchemy_continuum.operation.Operation): INSERT attaches, DELETE detaches.
# UPDATE never occurs for a pure M2M association (there is nothing to update on
# a (dashboard, slice) pair); if it ever appeared it is ignored — neither
# opening nor closing a window — so an open attachment simply continues.
# These mirror the library enum's numeric values; ``test_m2m_op_constants_match_
# continuum`` pins them so a Continuum renumber fails loudly rather than silently.
M2M_OP_INSERT = 0
M2M_OP_DELETE = 2
def attachment_windows(
rows: list[tuple[int, int, int]],
) -> list[tuple[int, Window]]:
"""Pair INSERT / DELETE association-version rows into ``[attach, detach)``
windows, one per attachment episode.
Each row is ``(assoc_id, transaction_id, operation_type)``. Continuum
**never closes** an association shadow row's ``end_transaction_id`` — its
unit-of-work only *inserts* association versions
(``create_association_versions``); the validity backfill that sets
``end_transaction_id`` runs for parent objects, not for M2M links. So the
detach boundary lives on the DELETE row's ``transaction_id``, not on the
attach row's ``end_transaction_id`` (which stays NULL for the association's
whole life). An INSERT opens a window; the next DELETE closes it at its
transaction id; an attachment with no following DELETE stays open (the
association is still live). A DELETE at the same transaction as its open
(add-and-remove in one save) yields no window the association was never
on a committed state. That last case is a deliberate divergence from
Continuum's own ``association_subquery`` reverter, which (selecting the
``MAX(tx) <= T`` row and excluding only DELETEs) would treat such a pair as
a member; the never-committed reading is the safer one for restore.
This is the M2M-correct counterpart to
:func:`~superset.versioning.changes.shadow_queries.shadow_rows_valid_at`,
whose ``end_transaction_id`` validity filter is right for parent/child
shadows but silently re-includes a detached association.
"""
result: list[tuple[int, Window]] = []
# operation_type is part of the sort key so that, within one transaction,
# INSERT (0) sorts before DELETE (2): an add-and-remove in a single save is
# then seen open-before-close and collapses to no window (the DELETE finds
# ``tx == open_tx``, not ``>``). Do not drop it from the key.
#
# Corollary / assumption: because INSERT is forced before DELETE within a
# transaction, this cannot represent a *remove-then-re-add* of the same
# association in one transaction (it would read the same as add-then-remove
# → no window). That relies on no write path emitting DELETE-then-INSERT
# for the same association within a single transaction — which holds today
# (a chart is detached or attached in a save, not both), so the case is
# latent, not live. Revisit this pairing if such a write path is added.
rows_sorted = sorted(rows, key=lambda r: (r[0], r[1], r[2]))
for assoc_id, group in groupby(rows_sorted, key=lambda r: r[0]):
open_tx: int | None = None
for _assoc_id, tx, operation_type in group:
if operation_type == M2M_OP_DELETE:
if open_tx is not None and tx > open_tx:
result.append((assoc_id, Window(open_tx, tx)))
open_tx = None
elif operation_type == M2M_OP_INSERT and open_tx is None:
open_tx = tx
if open_tx is not None:
result.append((assoc_id, Window(open_tx, None)))
return result
def intersect_windows(outer: Window, inner: Window) -> Window | None:
"""Intersect two half-open ``[start_tx, end_tx)`` windows.
-86
View File
@@ -1,86 +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.
"""M2M dashboard-membership queries, shared across the versioning surfaces.
``charts_attached_to_dashboard`` reads the ``dashboard_slices_version``
association shadow and pairs its INSERT/DELETE rows into ``[attach, detach)``
windows. It must **never** filter that shadow by ``end_transaction_id``:
Continuum never closes an M2M association's ``end_transaction_id`` (see
:func:`~superset.versioning.activity.windows.attachment_windows`), so a
validity filter would re-include a chart removed before the queried tx.
This lives in a neutral module not the activity read-path module
(``activity/queries.py``) so the restore write path and the impact rollup
depend on it here rather than reaching up into the read path (sc-119907).
"""
from __future__ import annotations
from typing import TYPE_CHECKING
import sqlalchemy as sa
from superset.extensions import db
if TYPE_CHECKING:
from superset.versioning.activity.kinds import Window
def charts_attached_to_dashboard(dashboard_id: int) -> list[tuple[int, Window]]:
"""Return ``(slice_id, window)`` for every chart that has ever been on
*dashboard_id*, with each attachment episode's validity window in
transaction-id space.
Reads from ``dashboard_slices_version`` (Continuum's auto-generated M2M
shadow) and pairs its INSERT/DELETE rows via
:func:`~superset.versioning.activity.windows.attachment_windows`,
so a chart removed from the dashboard is bounded at the detach transaction
rather than open-ended otherwise the chart's edits made *after* removal
would surface in the dashboard's related history.
"""
# pylint: disable=import-outside-toplevel
# attachment_windows is imported lazily (not at module top) so that this
# module stays a leaf: importing it must not pull the activity package,
# whose read-path modules (scope.py) import charts_attached_to_dashboard
# back from here — a module-top import created a circular import that only
# surfaced at runtime, when a restore imported this module for the first
# time (sc-119907).
from sqlalchemy_continuum import version_class
from superset.models.dashboard import Dashboard
from superset.versioning.activity.windows import attachment_windows
metadata = version_class(Dashboard).__table__.metadata
m2m_tbl = metadata.tables.get("dashboard_slices_version")
if m2m_tbl is None:
return []
rows = (
db.session.connection()
.execute(
sa.select(
m2m_tbl.c.slice_id,
m2m_tbl.c.transaction_id,
m2m_tbl.c.operation_type,
).where(
m2m_tbl.c.dashboard_id == dashboard_id,
m2m_tbl.c.slice_id.is_not(None),
)
)
.all()
)
return attachment_windows([(row[0], row[1], row[2]) for row in rows])
+26 -24
View File
@@ -195,40 +195,42 @@ def _restore_dashboard_membership(dashboard: Any, transaction_id: int) -> list[i
"""Reset *dashboard*'s chart membership to what it was at
*transaction_id*, reattaching only charts that still exist.
Membership is derived from the ``dashboard_slices_version`` shadow
(Continuum's auto-generated M2M table) by pairing each slice's
INSERT/DELETE rows into ``[attach, detach)`` windows: a slice was a
member at tx T iff one of its attachment windows contains T. The
``shadow_rows_valid_at`` validity filter must **not** be used here
Continuum never closes an association shadow's ``end_transaction_id``,
so that filter would re-attach a chart that had been removed before T
(attached@1, removed@5, restore to tx10 the chart wrongly returns).
``shadow_rows_valid_at`` stays correct for parent/child shadows, whose
``end_transaction_id`` the validity backfill does close (sc-119907).
Reads the validity-windowed ``dashboard_slices_version`` shadow
(Continuum's auto-generated M2M table): a slice was a member at tx T
iff a non-DELETE row has ``transaction_id <= T`` and an open or
later-closing validity window.
Returns the ids of snapshot members that no longer exist and were
skipped. Live charts' content is never touched — restoring a chart's
content is the chart's own restore endpoint's job.
"""
# pylint: disable=import-outside-toplevel
# Local imports: models.slice transitively imports models.core, which needs
# the initialised app — a module-top import would recreate the bootstrap
# cycle documented in changes/listener.py. charts_attached_to_dashboard is
# imported lazily for the same reason: it pulls the window helpers, whose
# package transitively imports the versioning.changes listener graph, so a
# module-top import here would re-enter that same bootstrap cycle.
# Local imports: models.slice transitively imports models.core, which
# needs the initialised app — module-top import would recreate the
# bootstrap cycle documented in changes/listener.py; shadow_queries is
# imported lazily for the same reason (see queries.get_version).
from superset.models.slice import Slice
from superset.versioning.membership import charts_attached_to_dashboard
from superset.versioning.changes import shadow_rows_valid_at
# charts_attached_to_dashboard owns the association-shadow read and the
# attach/detach window pairing (the single place that must never filter the
# M2M shadow by end_transaction_id — Continuum never closes it). A slice was
# a member at transaction_id iff one of its windows contains it (sc-119907).
ver_cls = version_class(type(dashboard))
m2m_tbl = ver_cls.__table__.metadata.tables.get("dashboard_slices_version")
if m2m_tbl is None: # pragma: no cover — shadow tables always exist here
return []
# shadow_rows_valid_at owns the validity-window semantics (open or
# later-closing window, non-DELETE) — the same predicate the version
# snapshot's column/metric reconstruction uses.
member_ids = sorted(
{
slice_id
for slice_id, window in charts_attached_to_dashboard(dashboard.id)
if window.contains(transaction_id)
row["slice_id"]
for row in shadow_rows_valid_at(
db.session,
m2m_tbl,
"dashboard_id",
dashboard.id,
transaction_id,
)
if row["slice_id"] is not None
}
)
if not member_ids:
-19
View File
@@ -45,7 +45,6 @@ from superset.models.slice import Slice
from superset.sql.parse import CTASMethod
from superset.subjects.models import Subject
from superset.subjects.types import SubjectType
from superset.tags.models import Tag, TaggedObject
from superset.utils import json
from superset.utils.core import get_example_default_schema, shortid
from superset.utils.database import get_example_database
@@ -272,28 +271,10 @@ class SupersetTestCase(TestCase):
db.session.delete(temp_role)
if login:
self.logout()
self._release_tag_references(temp_user.id)
db.session.delete(temp_user)
db.session.commit()
g.user = previous_g_user
@staticmethod
def _release_tag_references(user_id: int) -> None:
"""
Drop `ab_user` references held by rows the tagging system wrote.
Anything a user saves while `TAGGING_SYSTEM` is on stamps the audit
columns of the `tag` and `tagged_object` rows it creates, and those are
foreign keys. Deleting the user without clearing them fails with a
foreign key violation on backends that enforce them.
"""
for model in (Tag, TaggedObject):
for column in ("created_by_fk", "changed_by_fk"):
db.session.query(model).filter(
getattr(model, column) == user_id
).update({column: None}, synchronize_session=False)
db.session.commit()
@staticmethod
def create_user(
username: str,
+1 -7
View File
@@ -1103,6 +1103,7 @@ class TestChartApi(ApiEditorsTestCaseMixin, InsertChartMixin, SupersetTestCase):
"viewers": [],
"params": None,
"slice_name": "title",
"tags": [],
"viz_type": None,
"query_context": None,
"is_managed_externally": False,
@@ -1112,13 +1113,6 @@ class TestChartApi(ApiEditorsTestCaseMixin, InsertChartMixin, SupersetTestCase):
assert "id" in data["result"]
assert "thumbnail_url" in data["result"]
assert "url" in data["result"]
# implicit tags created by the tagging system's SQLA event listeners
tags = data["result"].pop("tags")
assert len(tags) == 2
assert {(tag["name"], tag["type"]) for tag in tags} == {
("type:chart", TagType.type.value),
(f"editor:{admin.id}", TagType.editor.value),
}
for key, value in data["result"].items():
# We can't assert timestamp values or id/urls
if key not in (

Some files were not shown because too many files have changed in this diff Show More