mirror of
https://github.com/apache/superset.git
synced 2026-09-09 08:44:32 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d27500a08f | ||
|
|
83e3dcd932 | ||
|
|
bb60298b88 | ||
|
|
c10f74ba7c | ||
|
|
aaf76687f9 | ||
|
|
7e51e3a0ed | ||
|
|
0b35b9ae98 | ||
|
|
a211aa7b39 | ||
|
|
ec11e17bdb | ||
|
|
1068e253d4 | ||
|
|
38cc25b889 | ||
|
|
8706b68971 | ||
|
|
c917b3d441 | ||
|
|
c033806300 | ||
|
|
744c81fb27 | ||
|
|
3e69650828 | ||
|
|
11cabd7f02 | ||
|
|
e1ef4f06cc | ||
|
|
74a364dc13 | ||
|
|
e4bb1bc825 | ||
|
|
05938977ab | ||
|
|
411f25859e | ||
|
|
8bd06c462c | ||
|
|
fd95e60abd |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Vendored
+6
-6
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+111
-86
@@ -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",
|
||||
|
||||
-98
@@ -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 =>
|
||||
|
||||
-55
@@ -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>} />);
|
||||
|
||||
-102
@@ -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: {},
|
||||
};
|
||||
+1
-18
@@ -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/);
|
||||
});
|
||||
|
||||
+1
-5
@@ -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 {
|
||||
|
||||
+2
-13
@@ -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}`;
|
||||
}
|
||||
|
||||
-23
@@ -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,
|
||||
|
||||
+5
-8
@@ -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';
|
||||
|
||||
+21
-2
@@ -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']);
|
||||
});
|
||||
-39
@@ -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']);
|
||||
});
|
||||
+15
-19
@@ -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');
|
||||
|
||||
-39
@@ -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';
|
||||
|
||||
|
||||
+22
-1
@@ -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.',
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
+1
-45
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")]
|
||||
)
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 []
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 chart→dataset 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 chart→dataset 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 chart→dataset window
|
||||
contain ``target_tx``. Replaces the previous N+1 shape that fired one COUNT
|
||||
per related record, and the m2m⋈slices 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 chart→dataset 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 chart→dataset 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()}
|
||||
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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])
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user