Compare commits

..
Author SHA1 Message Date
rusackasandClaude Opus 4.8 d4df7ca02c fix(charts): don't clobber the datasource_type-required error
Skip the non-table datasource_type guard entirely when datasource_type
is empty, so the existing "Datasource type is required" message isn't
overwritten by "Datasource type is invalid" for the same field key.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-24 23:13:02 -07:00
Evan RusackasandClaude Sonnet 5 f7503213e2 fix(charts): reject non-table datasource_type instead of crashing
Slice.datasource only ever resolves the "table" relationship, so a
chart created (or repointed via update) with datasource_type
"saved_query" or "query" would either crash outright or "succeed" as
a chart that can never actually render:

- "saved_query": SavedQuery has no .name attribute, so validate()
  crashes with an unhandled AttributeError -- surfaced to API clients
  as an opaque 500 "Fatal error" (fixes #29697).
- "query": Query has a synthetic .name property (used for CTAS table
  naming, not a real display name), so this one doesn't crash -- it
  silently creates a permanently broken chart instead.

CreateChartCommand and UpdateChartCommand now reject both up front
with the existing DatasourceTypeInvalidError (422), matching the
pattern already used for this same class of problem in
explore/utils.py and dataset/duplicate.py, rather than adding a new
one-off error type.

Adds unit tests for both commands (TDD: written first against
unfixed code to confirm they reproduce the two distinct failure
modes above, then the fix, then confirmed green) and an integration
test reproducing the original bug report's exact API call shape.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 21:41:33 -07:00
63 changed files with 1192 additions and 1933 deletions
@@ -53,6 +53,12 @@ jobs:
python-version: ${{ github.event_name == 'pull_request' && fromJSON('["current"]') || fromJSON('["current", "next"]') }}
env:
PYTHONPATH: ${{ github.workspace }}
# Promotes the SQLAlchemy 2.0 deprecation warnings already locked in as
# errors via pytest.ini's `filterwarnings` to actually run in CI, so a
# regression on those fails the build instead of relying on a
# contributor remembering to set this locally. See the migration
# battleplan: https://github.com/apache/superset/discussions/40273
SQLALCHEMY_WARN_20: "1"
steps:
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+12 -18
View File
@@ -58,7 +58,6 @@ the old counter to use the outcome-specific replacements.
- [42930](https://github.com/apache/superset/pull/42930): Dataset import data-URI fetches no longer honor an HTTP(S) proxy when `DATASET_IMPORT_ALLOW_INTERNAL_DATA_URLS` is `False` (the default): the connection is now made directly to the destination so the peer-address check validates the real target instead of a proxy's. Deployments that require an egress proxy to reach legitimate external data URLs for dataset import should set `DATASET_IMPORT_ALLOW_INTERNAL_DATA_URLS = True` or otherwise ensure those URLs resolve without one.
- [42935](https://github.com/apache/superset/pull/42935): The MCP service now refuses to start (`MCPAuthConfigError`) when `MCP_JWT_ISSUER` trusts more than one issuer and no `MCP_USER_RESOLVER` is configured, instead of only logging a warning. This was already a documented misconfiguration (the default resolver isn't issuer-scoped, so distinct trusted issuers minting the same username/email would resolve to the same Superset user); deployments trusting multiple issuers must configure an `MCP_USER_RESOLVER` that derives its identity from the token's `iss` claim before upgrading. Single-issuer deployments are unaffected.
- [42429](https://github.com/apache/superset/pull/42429): The Country Map chart's Iran GeoJSON now gives Alborz province its own ISO 3166-2 code, `IR-32`, instead of `IR-30`. `ISO` is the join key used to color/filter provinces on this chart, so any existing dataset keyed on `IR-30` for Alborz will silently stop matching after upgrading; re-key that data to `IR-32`.
- [43388](https://github.com/apache/superset/pull/43388): The MCP service now refuses to start (`MCPAuthConfigError`) if `MCP_DEV_USERNAME` and `MCP_AUTH_ENABLED = True` are both set, and separately if `MCP_AUTH_ENABLED = True` but no usable JWT key material is configured (RSA key/JWKS, or an explicit `MCP_JWT_SECRET` for HMAC) — both previously started with authentication silently weaker than configured. Deployments combining a dev-mode username with JWT auth enabled, or enabling JWT auth without key material, must pick one before upgrading: unset `MCP_DEV_USERNAME` for a real auth deployment, or unset `MCP_AUTH_ENABLED` (or configure the key material) for a dev-mode one. Response caching (`MCP_CACHE_CONFIG["enabled"] = True`) now also excludes every tool with a side effect by default, not only a partial list, so a previously-cached mutating tool call is no longer served from cache; no config change is needed to pick this up.
- [42393](https://github.com/apache/superset/pull/42393): Exported dataset YAML now carries a `uuid` for each metric and column so that custom folder assignments (which reference metrics/columns by UUID) survive an import into another workspace. This affects any export bundle that contains datasets, not just a dataset export: chart, dashboard, database and full-asset exports all embed the same dataset YAML, so a dashboard exported from this release also fails to import into an older one even though no dataset was exported directly. As with `folders` and `currency_code_column`, the affected `datasets/` files fail schema validation (`Unknown field: uuid`) when imported into Superset releases that predate this change; regenerate or hand-edit exports for older targets in mixed-version fleets.
- [42300](https://github.com/apache/superset/pull/42300): Timeseries charts (line/area/bar) with a Y-axis bound in effect — either an explicit `yAxisBounds` or one derived from `truncateYAxis` — now clamp out-of-range data points to that bound instead of letting ECharts drop the point (and the line segments around it) entirely. Any existing chart with a configured Y-axis bound and data outside it will look different after upgrading: a gap becomes a point pinned to the boundary. The clamp also rewrites the value ECharts reads for that point's tooltip and data label, so the displayed value is the bound rather than the true observation.
@@ -185,12 +184,10 @@ misrepresents the entity as unchanged.
- **Storage growth.** Capture writes shadow rows per save, so the metadata
database grows with edit volume. The `version_history.prune_old_versions`
beat task removes rows whose transaction is older than
`SUPERSET_VERSION_HISTORY_RETENTION_DAYS` (default 30).
- **Check a replaced `CELERY_CONFIG`.** Carry both the
`superset.tasks.version_history_retention` import and the
`version_history.prune_old_versions` beat entry; see
[Version-history retention (pruning)](#version-history-retention-pruning) for
the startup-warning behavior.
`SUPERSET_VERSION_HISTORY_RETENTION_DAYS` (default 30). A deployment that
replaces `CELERY_CONFIG` rather than inheriting it must carry both the
`superset.tasks.version_history_retention` import and the beat entry; a
startup warning names whichever is absent.
- **`PUT` responses change shape.** Entity updates now return populated
`old_version_uuid` / `new_version_uuid` fields and an `ETag` header, which
were null or absent while capture was off.
@@ -199,10 +196,7 @@ misrepresents the entity as unchanged.
kill-switch — not removed with the rollout toggles. Setting it to a falsy value
stops capture within a restart, without a revert-and-redeploy. Unlike the
soft-delete toggle, turning it off is a clean stop: existing version rows remain
readable and no entity state is altered. Restore is unavailable (404) while
capture is off. A full rollback also sets
`FEATURE_FLAGS = {"VERSION_HISTORY": False}` to hide the panel — capture off
with the panel left on shows an empty or stale history.
readable and no entity state is altered.
### Scheduled report execution now enforces one application deadline
@@ -664,9 +658,9 @@ ALTER TABLE tagged_object DROP CONSTRAINT <constraint_name>;
ALTER TABLE tagged_object DROP FOREIGN KEY <constraint_name>;
```
### Entity version-history infrastructure
### Entity version-history infrastructure (gated off by default)
Introduces the schema and SQLAlchemy-Continuum wiring that captures version history for charts, dashboards, and datasets, plus read-only `GET /api/v1/{chart,dashboard,dataset}/<uuid>/versions/` endpoints. Capture is governed by the `ENABLE_VERSIONING_CAPTURE` config value — an operational kill-switch (a release toggle that became a permanent ops switch), not a feature flag; see "Version history is on by default" above for the shipped default. With capture off, no save writes version rows; the endpoints continue to serve already-captured rows read-only. The migration is additive; existing entity `PUT` responses gain `old_version_uuid` / `new_version_uuid` body fields and an `ETag` header (both null/absent when capture is off).
Introduces the schema and SQLAlchemy-Continuum wiring that captures version history for charts, dashboards, and datasets, plus read-only `GET /api/v1/{chart,dashboard,dataset}/<uuid>/versions/` endpoints. This ships **inert**: a new config flag `ENABLE_VERSIONING_CAPTURE` defaults to `False`, so no save writes any version rows and the endpoints return empty. It is an operational kill-switch (a release toggle that becomes a permanent ops switch), not a feature flag set it to `True` to enable capture once validated. The migration is additive; existing entity `PUT` responses gain `old_version_uuid` / `new_version_uuid` body fields and an `ETag` header (both null/absent when capture is off).
A few save- and import-path internals change **unconditionally** (independent of the flag), because the versioned mappers must behave correctly whether or not capture is enabled:
@@ -687,7 +681,7 @@ A read-only companion to the version-history endpoints: each entity type gains a
| `q` | string | — | Case-insensitive search over the full history, applied before pagination (so `count` reflects matches) |
| `page` / `page_size` | integer | `0` / `25` | Pagination (`page_size` clamped to 200) |
Authorization reuses the resource's `can_read` permission and per-object `raise_for_access`; related-entity rows are visibility-filtered to what the caller may see. The stream reflects captured history; with capture off it remains readable but stops accruing new entries.
Authorization reuses the resource's `can_read` permission and per-object `raise_for_access`; related-entity rows are visibility-filtered to what the caller may see. The stream is empty unless version capture is on (`ENABLE_VERSIONING_CAPTURE`).
### Version-history retention (pruning)
@@ -707,7 +701,7 @@ Purging is **live by default** (`SOFT_DELETE_PURGE_DRY_RUN=False`), so the reten
Deployments that replace the default `CELERY_CONFIG` must ensure workers register `superset.tasks.deletion_retention` and schedule the `deletion_retention.purge_soft_deleted` task themselves. The shipped Docker development config uses `imports` and includes both entries. While `SOFT_DELETE` is statically enabled, a missing beat entry logs a startup warning; when the override explicitly defines `imports`, a missing purge module is also reported.
Operators can immediately erase a specific entity for compliance (GDPR) via `superset deletion-retention force-purge --uuid <uuid>`; this applies legacy hard-delete semantics — a live chart referencing a force-purged dataset is left without a datasource until re-pointed (the chart is not modified), and it purges the named entity even when it was never soft-deleted. Every scheduled evaluation writes a provisional, content-free record to the new `purge_audit_log` table before the cascade starts. Meaningful retained outcomes survive the entity they name. Blocked audit records carry a stable machine-readable `reason` code (`report_schedule`, `user_attribute`, or `cascade_integrity_failure` for an unexpected cascade failure caused by a database integrity constraint) so the audit table alone answers why an entity was not purged; records finalized before the column existed keep a NULL reason. Apply the migration before rolling out the new code: the audit model declares the column, so a worker on the new code with an un-migrated table fails its write-ahead write and the scheduled purge fails closed until the migration lands. During a rolling deploy, workers still on the old code write reason-less blocked rows and suppress on status alone; both effects are self-healing, since a NULL-reason record never matches a reason code and the next all-new-code run re-anchors the entity. Consecutive scheduled evaluations blocked with the same status **and reason** suppress only the redundant current provisional record — a reason change writes one new blocked record carrying the new code; completed outcomes, outcome transitions, and every force-purge attempt remain independent and immutable. Retained transition records are not automatically expired, so entities whose block reason changes repeatedly can accumulate multiple audit rows. The **scheduled** purge fails closed when its provisional record cannot be written, while **force-purge** proceeds even if the audit write fails — the operator is present and deletion outranks audit for a compliance erasure. Operators can monitor `deletion_retention.blocked_audit_suppressed` and `deletion_retention.blocked_audit_dedupe_fallback` to verify suppression and fail-safe fallback behavior without changing the existing blocked-workload gauge.
Operators can immediately erase a specific entity for compliance (GDPR) via `superset deletion-retention force-purge --uuid <uuid>`; this applies legacy hard-delete semantics — a live chart referencing a force-purged dataset is left without a datasource until re-pointed (the chart is not modified), and it purges the named entity even when it was never soft-deleted. Every scheduled evaluation writes a provisional, content-free record to the new `purge_audit_log` table before the cascade starts. Meaningful retained outcomes survive the entity they name. Consecutive scheduled evaluations with the same blocked outcome suppress only the redundant current provisional record; completed outcomes, outcome transitions, and every force-purge attempt remain independent and immutable. The **scheduled** purge fails closed when its provisional record cannot be written, while **force-purge** proceeds even if the audit write fails — the operator is present and deletion outranks audit for a compliance erasure. Operators can monitor `deletion_retention.blocked_audit_suppressed` and `deletion_retention.blocked_audit_dedupe_fallback` to verify suppression and fail-safe fallback behavior without changing the existing blocked-workload gauge.
### Recently Archived view and permanent delete (purge) endpoints
@@ -903,7 +897,7 @@ The migration is transactional (all-or-nothing) and idempotent — it can be saf
### Soft delete and restore for datasets
**The soft-delete behavior in this section applies only when the `SOFT_DELETE` feature flag is enabled. The flag defaults to `True`** (`@lifecycle: testing`), so on a default deployment `DELETE /api/v1/dataset/<id>` uses the recoverable soft-delete behavior described below. Setting `SOFT_DELETE` to `False` restores legacy permanent hard-delete behavior for subsequent deletes.
**The soft-delete behavior in this section applies only when the `SOFT_DELETE` feature flag is enabled. The flag defaults to `False`** (`@lifecycle: development`), so on a default deployment `DELETE /api/v1/dataset/<id>` continues to **hard-delete permanently** — nothing is recoverable. Enable `SOFT_DELETE` to get the behavior described below.
**Flag-toggle caveat:** the soft-delete visibility filter is evaluated per query while the flag is on. If datasets are soft-deleted during a flag-on window and the flag is later turned **off**, those rows reappear as live datasets in all lists, lookups, and relationship loads (including charts that reference them). The `POST /<uuid>/restore` endpoint and the `dataset_deleted_state` list filter remain functional regardless of the flag, deliberately, so rows soft-deleted during a flag-on window stay discoverable and restorable after a rollback of the flag.
@@ -933,7 +927,7 @@ With the flag enabled: `DELETE /api/v1/dataset/<id>` no longer hard-deletes the
### Soft delete and restore for charts
**Everything in this section applies only when the `SOFT_DELETE` feature flag is enabled. The flag defaults to `True`** (`@lifecycle: testing`), so on a default deployment `DELETE /api/v1/chart/<id>` uses the recoverable soft-delete behavior described below. Setting `SOFT_DELETE` to `False` restores legacy permanent hard-delete behavior for subsequent deletes.
**Everything in this section applies only when the `SOFT_DELETE` feature flag is enabled. The flag defaults to `False`** (`@lifecycle: development`), so on a default deployment `DELETE /api/v1/chart/<id>` continues to **hard-delete permanently** — nothing is recoverable. Enable `SOFT_DELETE` to get the behavior described below.
**Flag-toggle caveat:** the soft-delete visibility filter is evaluated per query while the flag is on. If charts are soft-deleted during a flag-on window and the flag is later turned **off**, those rows reappear as live charts in all lists, lookups, and relationship loads (including dashboards that contained them). The `POST /<uuid>/restore` endpoint and the `chart_deleted_state` list filter remain functional regardless of the flag, deliberately, so rows soft-deleted during a flag-on window stay discoverable and restorable after a rollback of the flag.
@@ -957,7 +951,7 @@ With the flag enabled: `DELETE /api/v1/chart/<id>` no longer hard-deletes the ch
### Soft delete and restore for dashboards
**Everything in this section applies only when the `SOFT_DELETE` feature flag is enabled. The flag defaults to `True`** (`@lifecycle: testing`), so on a default deployment `DELETE /api/v1/dashboard/<id>` uses the recoverable soft-delete behavior described below. Setting `SOFT_DELETE` to `False` restores legacy permanent hard-delete behavior for subsequent deletes.
**Everything in this section applies only when the `SOFT_DELETE` feature flag is enabled. The flag defaults to `False`** (`@lifecycle: development`), so on a default deployment `DELETE /api/v1/dashboard/<id>` continues to **hard-delete permanently** — nothing is recoverable. Enable `SOFT_DELETE` to get the behavior described below.
**Flag-toggle caveat:** the soft-delete visibility filter is evaluated per query while the flag is on. If dashboards are soft-deleted during a flag-on window and the flag is later turned **off**, those rows reappear as live dashboards in all lists and lookups (including slug lookups — if a soft-deleted dashboard's slug was reused while the flag was on, both rows become visible with the same slug). The `POST /<uuid>/restore` endpoint and the `dashboard_deleted_state` list filter remain functional regardless of the flag, deliberately, so rows soft-deleted during a flag-on window stay discoverable and restorable after a rollback of the flag.
-1
View File
@@ -86,7 +86,6 @@
"Israel",
"Italy",
"Italy (regions)",
"Italy (regions and autonomous provinces)",
"Ivory Coast",
"Japan",
"Jordan",
+12 -12
View File
@@ -15,29 +15,29 @@ description of what changed — "Chart renamed to Q3 Revenue", "Added filter on
'Region'" — rather than a raw diff. You can search the history and filter it
down to changes on the entity itself or on the things it depends on.
## Enabling and disabling it
## Enabling it
Two switches are involved, and both matter.
| Setting | Type | Effect |
| --- | --- | --- |
| `VERSION_HISTORY` | Feature flag | Shows the version history UI |
| `ENABLE_VERSIONING_CAPTURE` | Config value | Records versions as entities are saved |
Both default to on. To turn the feature off:
```python
# superset_config.py
FEATURE_FLAGS = {"VERSION_HISTORY": False}
ENABLE_VERSIONING_CAPTURE = False
FEATURE_FLAGS = {"VERSION_HISTORY": True}
ENABLE_VERSIONING_CAPTURE = True
```
Restart Superset and its workers for the capture change to take effect. Existing
history remains readable while capture is off, but **Restore** is unavailable
(404).
Both default to off. They are separate because capture is the expensive half:
an operator may want to start recording history before exposing the UI, so that
there is something to show when they do.
Disable them together: capture off with the UI left on gives a panel that
stops filling — an empty or stale history misrepresents the entity as
unchanged. History only accrues while capture is on; edits made while it was
off are not reconstructed.
Turning the UI on without capture gives a panel that reports "No history yet"
and never fills, so enable capture first — or at the same time. History only
accrues from the moment capture is switched on; earlier edits are not
reconstructed.
## Viewing history
+3 -3
View File
@@ -58,10 +58,10 @@
"@fontsource/inter": "^5.3.0",
"@mdx-js/react": "^3.1.1",
"@saucelabs/theme-github-codeblock": "^0.3.0",
"@storybook/addon-docs": "^10.5.9",
"@storybook/addon-docs": "^10.5.8",
"@superset-ui/core": "^0.20.4",
"@swc/core": "^1.16.0",
"antd": "^6.6.1",
"antd": "^6.6.0",
"baseline-browser-mapping": "^2.11.15",
"caniuse-lite": "^1.0.30001809",
"docusaurus-plugin-openapi-docs": "^5.2.0",
@@ -77,7 +77,7 @@
"react-table": "^7.8.0",
"remark-import-partial": "^0.0.2",
"reselect": "^5.2.0",
"storybook": "^10.5.9",
"storybook": "^10.5.8",
"swagger-ui-react": "^5.32.13",
"swc-loader": "^0.2.7",
"tinycolor2": "^1.4.2",
+12 -12
View File
@@ -93,6 +93,12 @@
"lifecycle": "development",
"description": "Enable semantic layers and show semantic views alongside datasets"
},
{
"name": "SOFT_DELETE",
"default": true,
"lifecycle": "development",
"description": "Temporary rollout / kill-switch gate for soft delete (off = legacy hard delete). An emergency stop, not a clean rollback: flipping ON->OFF resurrects already-soft-deleted rows. Retained through this release as the move-back lever; removed (along with its two gate points \u2014 BaseDAO.delete routing and the do_orm_execute visibility listener) once post-flip confidence is established."
},
{
"name": "TABLE_V2_TIME_COMPARISON_ENABLED",
"default": false,
@@ -104,6 +110,12 @@
"default": false,
"lifecycle": "development",
"description": "Enables the tagging system for organizing assets"
},
{
"name": "VERSION_HISTORY",
"default": true,
"lifecycle": "development",
"description": "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 but stays empty, so the two ship with matching defaults and should be changed together."
}
],
"testing": [
@@ -221,12 +233,6 @@
"lifecycle": "testing",
"description": "Apply RLS rules to SQL Lab queries. Requires query parsing/manipulation. May break queries or allow RLS bypass. Use with care!"
},
{
"name": "SOFT_DELETE",
"default": true,
"lifecycle": "testing",
"description": "Temporary rollout / kill-switch gate for soft delete (off = legacy hard delete). An emergency stop, not a clean rollback: flipping ON->OFF resurrects already-soft-deleted rows. Retained through this release as the move-back lever; removed (along with its two gate points \u2014 BaseDAO.delete routing and the do_orm_execute visibility listener) once post-flip confidence is established."
},
{
"name": "SSH_TUNNELING",
"default": false,
@@ -239,12 +245,6 @@
"default": false,
"lifecycle": "testing",
"description": "Use analogous colors in charts"
},
{
"name": "VERSION_HISTORY",
"default": true,
"lifecycle": "testing",
"description": "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 ship with matching defaults and should be changed together."
}
],
"stable": [
+28 -28
View File
@@ -3789,7 +3789,7 @@
"@rc-component/util" "^1.3.0"
clsx "^2.1.1"
"@rc-component/select@~1.10.0", "@rc-component/select@~1.10.1":
"@rc-component/select@~1.10.0":
version "1.10.1"
resolved "https://registry.yarnpkg.com/@rc-component/select/-/select-1.10.1.tgz#323b2f458a637e8e752f8341094783741c613c34"
integrity sha512-H+yQsl+qED9NilQ3g6zdpsMwUgwVjrcMTkNHAWRVU/MoNCYgTbDgU+MIMgZDK+rVdd2JUfI/MkysMcZZ0cyQKw==
@@ -3824,7 +3824,7 @@
"@rc-component/util" "^1.3.0"
clsx "^2.1.1"
"@rc-component/table@~1.11.1":
"@rc-component/table@~1.11.0":
version "1.11.1"
resolved "https://registry.yarnpkg.com/@rc-component/table/-/table-1.11.1.tgz#7b5c2a7c26fd37b6a403082029b5a72fcb330a4d"
integrity sha512-OWdS6DMmeWb7bJBGqPxYZpQbzBlBiXZUu2sqo6Ii7Sjs9GeK1IsrXrWk26SL2c6KEseabswdxrRj7WUm9LdECw==
@@ -3866,7 +3866,7 @@
"@rc-component/util" "^1.7.0"
clsx "^2.1.1"
"@rc-component/tree-select@~1.16.1":
"@rc-component/tree-select@~1.16.0":
version "1.16.1"
resolved "https://registry.yarnpkg.com/@rc-component/tree-select/-/tree-select-1.16.1.tgz#dcaea96e396e98108cb29cc051840d4fbdda38cc"
integrity sha512-a1Oi6EJhqAhdOxxupdJi6fP0RPHMKn5TcfkX2+llaQ4lF4nwfH7b6SCHcnsybaa2s+pk1yZYwVyeOYkDnEBRdg==
@@ -4122,23 +4122,23 @@
resolved "https://registry.yarnpkg.com/@standard-schema/utils/-/utils-0.3.0.tgz#3d5e608f16c2390c10528e98e59aef6bf73cae7b"
integrity sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==
"@storybook/addon-docs@^10.5.9":
version "10.5.9"
resolved "https://registry.yarnpkg.com/@storybook/addon-docs/-/addon-docs-10.5.9.tgz#6d871977f7ad833dc142d12ee43490105dec4d07"
integrity sha512-8sFsMkZYrrdqCLdV+hnwTwDF7RaBsBPRwl4wfc8ve9Q/7Yhi5REe/Xjvd8x1yn6fBPPw9tnID9dx6Agdnr81fw==
"@storybook/addon-docs@^10.5.8":
version "10.5.8"
resolved "https://registry.yarnpkg.com/@storybook/addon-docs/-/addon-docs-10.5.8.tgz#767c10c7a4cc1b625b93f869b2a2b09fc8514f2e"
integrity sha512-NlHiMKW/UvW/uL8HXFDCEVwoH3qZeGYZ/qlWax4d7H471b/T54MBq2KcB4ZrdA785FfIH3numAJdBb5jwn00Mg==
dependencies:
"@mdx-js/react" "^3.0.0"
"@storybook/csf-plugin" "10.5.9"
"@storybook/csf-plugin" "10.5.8"
"@storybook/icons" "^2.0.2"
"@storybook/react-dom-shim" "10.5.9"
"@storybook/react-dom-shim" "10.5.8"
react "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
react-dom "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
ts-dedent "^2.0.0"
"@storybook/csf-plugin@10.5.9":
version "10.5.9"
resolved "https://registry.yarnpkg.com/@storybook/csf-plugin/-/csf-plugin-10.5.9.tgz#805e4c93a1704b220351d62bb0c74ce3b78c5e10"
integrity sha512-4H5QIHQVtQYCuL43GCRLGjNQhZpQg9gL03ja0DV80kO2Dn9LEt6ol87bSnSjn4VDgcAXtgTzXFvRLknfVgAAqg==
"@storybook/csf-plugin@10.5.8":
version "10.5.8"
resolved "https://registry.yarnpkg.com/@storybook/csf-plugin/-/csf-plugin-10.5.8.tgz#c626c5bfe55d0e279b2457e5cf150a788d1fc637"
integrity sha512-/FHiMyOWWEXfwK/lM0WxmkP9GLzbSJJuzGtfeuNWSOVDnvAMbjavitxfHb5wSbWKIQo0XYC1EJ2Y7x91XNYP4w==
dependencies:
unplugin "^2.3.5"
@@ -4152,10 +4152,10 @@
resolved "https://registry.yarnpkg.com/@storybook/icons/-/icons-2.1.0.tgz#edfc2450a39c5e780f28c6cbc49acd7bff59b41a"
integrity sha512-Fxh9vYpX9bQqFeHRiY8h2ApeRGDzRSMLwJwNZ/AIRqnyOKHxRKL+yFe+ctEkVJmuptRE9u1Hrn8ZZNHyfDKKNg==
"@storybook/react-dom-shim@10.5.9":
version "10.5.9"
resolved "https://registry.yarnpkg.com/@storybook/react-dom-shim/-/react-dom-shim-10.5.9.tgz#549793845bb8b966acd36002d33d7b54cc5c92d4"
integrity sha512-7qZD6CSa64p1m/zX9tG4ALcEHlEk0Bx+5++4RL3MFlRCgCFfAomXsCHTzF0RyF8cI4vl+hS7Y0ryjQchVs6UQA==
"@storybook/react-dom-shim@10.5.8":
version "10.5.8"
resolved "https://registry.yarnpkg.com/@storybook/react-dom-shim/-/react-dom-shim-10.5.8.tgz#40cc3e32af424baa2e4109a325dae2ede29999e2"
integrity sha512-N8D13/Xny+V3kfe1KBgsAHS0nKWXLLdgOOXS9poKdYzVwVCN+CGEGBxWX0zMMtdCptqa6/57em9coPlZMoO+bg==
"@superset-ui/core@^0.20.4":
version "0.20.4"
@@ -6181,10 +6181,10 @@ ansis@^3.2.0:
resolved "https://registry.yarnpkg.com/ansis/-/ansis-3.17.0.tgz#fa8d9c2a93fe7d1177e0c17f9eeb562a58a832d7"
integrity sha512-0qWUglt9JEqLFr3w1I1pbrChn1grhaiAR2ocX1PP/flRmxgtwTzPFFFnfIlD6aMOLQZgSuCRlidD70lvx8yhzg==
antd@^6.6.1:
version "6.6.1"
resolved "https://registry.yarnpkg.com/antd/-/antd-6.6.1.tgz#3235d76413b525b1f3287b87bdaf6ba0e7148521"
integrity sha512-QHIHYoUk9N9nJy1T9fyxWKjY0qApdTEDd/6lzqYng8Uryv9FejNmbhKvYF7obGqB+TuLXQsPVF7fOVgyzM1KrQ==
antd@^6.6.0:
version "6.6.0"
resolved "https://registry.yarnpkg.com/antd/-/antd-6.6.0.tgz#8acb84c54b36594b5c1a9084c8acb6a03b79961b"
integrity sha512-UDwWIbpmrCHB9ZQ+bPh4vQfB6DTI2ulIyoQ0Tc9xxalFblttiNGHl3ySBD9SyV/8+gUjFzfSx1+iU1Fog2i46w==
dependencies:
"@ant-design/colors" "^8.0.1"
"@ant-design/cssinjs" "^2.1.2"
@@ -6217,16 +6217,16 @@ antd@^6.6.1:
"@rc-component/rate" "~1.0.1"
"@rc-component/resize-observer" "^1.1.2"
"@rc-component/segmented" "~1.3.0"
"@rc-component/select" "~1.10.1"
"@rc-component/select" "~1.10.0"
"@rc-component/slider" "~1.1.1"
"@rc-component/steps" "~1.2.2"
"@rc-component/switch" "~1.0.3"
"@rc-component/table" "~1.11.1"
"@rc-component/table" "~1.11.0"
"@rc-component/tabs" "~1.12.0"
"@rc-component/tooltip" "~1.5.0"
"@rc-component/tour" "~2.4.0"
"@rc-component/tree" "~1.4.0"
"@rc-component/tree-select" "~1.16.1"
"@rc-component/tree-select" "~1.16.0"
"@rc-component/trigger" "^3.10.1"
"@rc-component/upload" "~1.1.1"
"@rc-component/util" "^1.12.0"
@@ -14783,10 +14783,10 @@ stop-iteration-iterator@^1.1.0:
es-errors "^1.3.0"
internal-slot "^1.1.0"
storybook@^10.5.9:
version "10.5.9"
resolved "https://registry.yarnpkg.com/storybook/-/storybook-10.5.9.tgz#61f476fd73785dcf09e9198ddf404b9b8c06964a"
integrity sha512-UfdMKSjEhIKr8LbqYyIE5r7vT/drL/PxN75YaouJ+UG0FssEy6cf49OdTF3kstAqVMHskc+zEqyRoiQHZXHwgA==
storybook@^10.5.8:
version "10.5.8"
resolved "https://registry.yarnpkg.com/storybook/-/storybook-10.5.8.tgz#d5f051983e6232c0a73ea02149a72c7bafb43275"
integrity sha512-rR4oFMSiWBSqI0lvsJPtcQUPj8+hzj3TkLu+Mw61Wo6YxPSb5FsLSHai0jZnuaIdKIlmu25KCfwlSQl4e1uvnA==
dependencies:
"@storybook/global" "^5.0.0"
"@storybook/icons" "^2.0.2"
+3 -7
View File
@@ -80,7 +80,7 @@ dependencies = [
# marshmallow 4 compatibility: see superset/marshmallow_compatibility.py for a
# Flask-AppBuilder workaround. Tracking issue:
# https://github.com/apache/superset/issues/33162
"marshmallow>=3.0, <5",
"marshmallow>=4.3.1, <5",
"marshmallow-union>=0.1.15.post1",
"msgpack>=1.2.0, <1.3",
"nh3>=0.3.5, <0.4",
@@ -218,12 +218,8 @@ motherduck = ["apache-superset[duckdb]"]
mysql = ["mysqlclient>=2.2.8, <3"]
ocient = [
# Closed-source vendor package with no public changelog; permissive
# unpinned sqlalchemy>=1.4 declared. Verified compatible with SQLAlchemy
# 2.0 against pyocient>=3.9.0 (discussion #40273): dialect construction,
# error extraction, and GIS-type sanitization all pass under 2.0.52. Note
# pyocient 3.9.0 relocated its geo-type classes from private top-level
# names (pyocient._STPoint) to public ones under pyocient.api
# (pyocient.api.STPoint), which is unrelated to the SQLAlchemy bump.
# unpinned sqlalchemy>=1.4 declared, but SQLAlchemy 2.0 support is
# unverified. Lower confidence than the other bumps in this PR.
"sqlalchemy-ocient>=3.0.0, <4",
"pyocient>=3.9.0, <4",
"shapely",
@@ -1,18 +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.
"""Semantic layer contracts for extension authors."""
+270 -86
View File
@@ -81,12 +81,12 @@
"@visx/xychart": "^4.0.0",
"ag-grid-community": "36.1.0",
"ag-grid-react": "36.1.0",
"antd": "^6.6.1",
"antd": "^6.6.0",
"chrono-node": "^2.10.1",
"classnames": "^2.2.5",
"content-disposition": "^2.0.1",
"d3-scale": "^4.0.2",
"dayjs": "^1.11.23",
"dayjs": "^1.11.22",
"dom-to-image-more": "^3.10.2",
"dom-to-pdf": "^0.3.2",
"echarts": "^6.1.0",
@@ -180,9 +180,9 @@
"@istanbuljs/nyc-config-typescript": "^1.0.1",
"@playwright/test": "^1.62.1",
"@pmmmwh/react-refresh-webpack-plugin": "^0.6.2",
"@storybook/addon-docs": "10.5.9",
"@storybook/addon-links": "10.5.9",
"@storybook/react-webpack5": "10.5.9",
"@storybook/addon-docs": "10.5.8",
"@storybook/addon-links": "10.5.8",
"@storybook/react-webpack5": "10.5.8",
"@storybook/test-runner": "0.24.4",
"@svgr/webpack": "^8.1.0",
"@swc/core": "^1.16.0",
@@ -218,7 +218,7 @@
"babel-loader": "^10.1.1",
"babel-plugin-dynamic-import-node": "^2.3.3",
"babel-plugin-jsx-remove-data-test-id": "^3.0.0",
"baseline-browser-mapping": "^2.11.15",
"baseline-browser-mapping": "^2.11.14",
"cheerio": "1.2.0",
"concurrently": "^10.0.5",
"copy-webpack-plugin": "^14.0.0",
@@ -235,7 +235,7 @@
"eslint-plugin-no-only-tests": "^3.4.0",
"eslint-plugin-react-prefer-function-component": "^5.0.0",
"eslint-plugin-react-you-might-not-need-an-effect": "^1.0.1",
"eslint-plugin-storybook": "10.5.9",
"eslint-plugin-storybook": "10.5.8",
"eslint-plugin-testing-library": "^7.16.2",
"eslint-plugin-theme-colors": "file:eslint-rules/eslint-plugin-theme-colors",
"fetch-mock": "^12.6.0",
@@ -266,7 +266,7 @@
"source-map": "^0.8.0",
"source-map-support": "^0.5.21",
"speed-measure-webpack-plugin": "^1.6.0",
"storybook": "10.5.9",
"storybook": "10.5.8",
"style-loader": "^4.0.0",
"stylelint": "^17.14.1",
"swc-loader": "^0.2.7",
@@ -9946,9 +9946,9 @@
}
},
"node_modules/@rc-component/select": {
"version": "1.10.1",
"resolved": "https://registry.npmjs.org/@rc-component/select/-/select-1.10.1.tgz",
"integrity": "sha512-H+yQsl+qED9NilQ3g6zdpsMwUgwVjrcMTkNHAWRVU/MoNCYgTbDgU+MIMgZDK+rVdd2JUfI/MkysMcZZ0cyQKw==",
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@rc-component/select/-/select-1.10.0.tgz",
"integrity": "sha512-u/3yuF2kEXvTJXPy3P7qkVBkGGZcQo+m1uTuQqJa6qCnwdmDoEn1Rs3zTTi6y/RsLGiQGM0drPN5c/RfflCnow==",
"license": "MIT",
"dependencies": {
"@rc-component/overflow": "^1.0.0",
@@ -10765,16 +10765,16 @@
"license": "MIT"
},
"node_modules/@storybook/addon-docs": {
"version": "10.5.9",
"resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-10.5.9.tgz",
"integrity": "sha512-8sFsMkZYrrdqCLdV+hnwTwDF7RaBsBPRwl4wfc8ve9Q/7Yhi5REe/Xjvd8x1yn6fBPPw9tnID9dx6Agdnr81fw==",
"version": "10.5.8",
"resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-10.5.8.tgz",
"integrity": "sha512-NlHiMKW/UvW/uL8HXFDCEVwoH3qZeGYZ/qlWax4d7H471b/T54MBq2KcB4ZrdA785FfIH3numAJdBb5jwn00Mg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@mdx-js/react": "^3.0.0",
"@storybook/csf-plugin": "10.5.9",
"@storybook/csf-plugin": "10.5.8",
"@storybook/icons": "^2.0.2",
"@storybook/react-dom-shim": "10.5.9",
"@storybook/react-dom-shim": "10.5.8",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"ts-dedent": "^2.0.0"
@@ -10785,7 +10785,7 @@
},
"peerDependencies": {
"@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"storybook": "10.5.9"
"storybook": "10.5.8"
},
"peerDependenciesMeta": {
"@types/react": {
@@ -10794,9 +10794,9 @@
}
},
"node_modules/@storybook/addon-docs/node_modules/@storybook/csf-plugin": {
"version": "10.5.9",
"resolved": "https://registry.npmjs.org/@storybook/csf-plugin/-/csf-plugin-10.5.9.tgz",
"integrity": "sha512-4H5QIHQVtQYCuL43GCRLGjNQhZpQg9gL03ja0DV80kO2Dn9LEt6ol87bSnSjn4VDgcAXtgTzXFvRLknfVgAAqg==",
"version": "10.5.8",
"resolved": "https://registry.npmjs.org/@storybook/csf-plugin/-/csf-plugin-10.5.8.tgz",
"integrity": "sha512-/FHiMyOWWEXfwK/lM0WxmkP9GLzbSJJuzGtfeuNWSOVDnvAMbjavitxfHb5wSbWKIQo0XYC1EJ2Y7x91XNYP4w==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -10809,7 +10809,7 @@
"peerDependencies": {
"esbuild": "*",
"rollup": "*",
"storybook": "10.5.9",
"storybook": "10.5.8",
"vite": "*",
"webpack": "*"
},
@@ -10829,9 +10829,9 @@
}
},
"node_modules/@storybook/addon-docs/node_modules/@storybook/react-dom-shim": {
"version": "10.5.9",
"resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-10.5.9.tgz",
"integrity": "sha512-7qZD6CSa64p1m/zX9tG4ALcEHlEk0Bx+5++4RL3MFlRCgCFfAomXsCHTzF0RyF8cI4vl+hS7Y0ryjQchVs6UQA==",
"version": "10.5.8",
"resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-10.5.8.tgz",
"integrity": "sha512-N8D13/Xny+V3kfe1KBgsAHS0nKWXLLdgOOXS9poKdYzVwVCN+CGEGBxWX0zMMtdCptqa6/57em9coPlZMoO+bg==",
"dev": true,
"license": "MIT",
"funding": {
@@ -10843,7 +10843,7 @@
"@types/react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"storybook": "10.5.9"
"storybook": "10.5.8"
},
"peerDependenciesMeta": {
"@types/react": {
@@ -10855,9 +10855,9 @@
}
},
"node_modules/@storybook/addon-links": {
"version": "10.5.9",
"resolved": "https://registry.npmjs.org/@storybook/addon-links/-/addon-links-10.5.9.tgz",
"integrity": "sha512-ZDbPl6ia6hqjoV+CpQU3DjkXpc0TxUq6+y/rFD8w21dJMdqNWzY8zajHC8r4CfTWANjM1pGPUYisWtTKi1MxZw==",
"version": "10.5.8",
"resolved": "https://registry.npmjs.org/@storybook/addon-links/-/addon-links-10.5.8.tgz",
"integrity": "sha512-mpWw4alBJVGqgVh897LZ2keN/xnMHcH93wKJG+oGg4+cdEUA+06hCs5T4k+AS5Aa+EZ6LvdOoi2VPHssyQlCCA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -10870,7 +10870,7 @@
"peerDependencies": {
"@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"storybook": "10.5.9"
"storybook": "10.5.8"
},
"peerDependenciesMeta": {
"@types/react": {
@@ -10964,15 +10964,15 @@
}
},
"node_modules/@storybook/react-webpack5": {
"version": "10.5.9",
"resolved": "https://registry.npmjs.org/@storybook/react-webpack5/-/react-webpack5-10.5.9.tgz",
"integrity": "sha512-mrCJub/WAt6RAU1P+bpBvcdTHMHePXLuNM+TuJl0Sl3r9Ta0YjDvtdODweud9sTEpVN3ao/fk0LpiaDMfLd84w==",
"version": "10.5.8",
"resolved": "https://registry.npmjs.org/@storybook/react-webpack5/-/react-webpack5-10.5.8.tgz",
"integrity": "sha512-HkPi42WaoNSHC0DAERsJEF7Vhnluzsp/aiuhnH65GGYG5TmdLL9G8KDiYvXHGDCyb4RfoAPYrtzxaLMFfPPFvQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@storybook/builder-webpack5": "10.5.9",
"@storybook/preset-react-webpack": "10.5.9",
"@storybook/react": "10.5.9"
"@storybook/builder-webpack5": "10.5.8",
"@storybook/preset-react-webpack": "10.5.8",
"@storybook/react": "10.5.8"
},
"funding": {
"type": "opencollective",
@@ -10981,7 +10981,7 @@
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"storybook": "10.5.9",
"storybook": "10.5.8",
"typescript": ">= 4.9.x"
},
"peerDependenciesMeta": {
@@ -10991,13 +10991,13 @@
}
},
"node_modules/@storybook/react-webpack5/node_modules/@storybook/builder-webpack5": {
"version": "10.5.9",
"resolved": "https://registry.npmjs.org/@storybook/builder-webpack5/-/builder-webpack5-10.5.9.tgz",
"integrity": "sha512-XTLC95jP75V9NfhoUzDNKDCn9r0ZqXz4ZhrNzQXVDz4vNWkjU3/uDL6Ywh9WFu6Xrb+onQqSR9hlHjS/DOZj7Q==",
"version": "10.5.8",
"resolved": "https://registry.npmjs.org/@storybook/builder-webpack5/-/builder-webpack5-10.5.8.tgz",
"integrity": "sha512-ke5x27gtWQ4gpXCLWxdGkr8ZlJwBykV/KjbBTAlC04dmS9OkI9MBzGj+TteUlgrcaN7LwoNTR5zRmxKStOZYzQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@storybook/core-webpack": "10.5.9",
"@storybook/core-webpack": "10.5.8",
"case-sensitive-paths-webpack-plugin": "^2.4.0",
"cjs-module-lexer": "^1.2.3",
"css-loader": "^7.1.2",
@@ -11019,7 +11019,7 @@
"url": "https://opencollective.com/storybook"
},
"peerDependencies": {
"storybook": "10.5.9"
"storybook": "10.5.8"
},
"peerDependenciesMeta": {
"typescript": {
@@ -11028,9 +11028,9 @@
}
},
"node_modules/@storybook/react-webpack5/node_modules/@storybook/builder-webpack5/node_modules/@storybook/core-webpack": {
"version": "10.5.9",
"resolved": "https://registry.npmjs.org/@storybook/core-webpack/-/core-webpack-10.5.9.tgz",
"integrity": "sha512-YmXR9RJdQpH8EtWEIjLTr5LMGiCXSmZp/A9UkATY5xHM4qG2QwJB++jU2wv4nGDyaPiUcbzT2PQSXN6RXhiUZA==",
"version": "10.5.8",
"resolved": "https://registry.npmjs.org/@storybook/core-webpack/-/core-webpack-10.5.8.tgz",
"integrity": "sha512-HccINB0UbTtnyJtKpaX+C35BRTSnAwnreIMwwI+LpeUd4x9mQg0G9orB7lfBBZwd5LQf8YhM2Vkjiawzo41GLg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -11041,17 +11041,17 @@
"url": "https://opencollective.com/storybook"
},
"peerDependencies": {
"storybook": "10.5.9"
"storybook": "10.5.8"
}
},
"node_modules/@storybook/react-webpack5/node_modules/@storybook/preset-react-webpack": {
"version": "10.5.9",
"resolved": "https://registry.npmjs.org/@storybook/preset-react-webpack/-/preset-react-webpack-10.5.9.tgz",
"integrity": "sha512-LOr8SoM2CejVCHeLyxbdRMiKuQb2q95CE5D75oK3+513mMZ8LxVPVApxy8B6FvFYNe9CTqVK+95jETefOqabTw==",
"version": "10.5.8",
"resolved": "https://registry.npmjs.org/@storybook/preset-react-webpack/-/preset-react-webpack-10.5.8.tgz",
"integrity": "sha512-0JjgVoX5t9Wb+gwddYHx/Ej7KFqwd65lpHXEhBoT4pFWRqVI0pvfHu42M+DRGjsgOye3uE+3pH4yHR3+0/fCHA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@storybook/core-webpack": "10.5.9",
"@storybook/core-webpack": "10.5.8",
"@storybook/react-docgen-typescript-plugin": "1.0.6--canary.9.0c3f3b7.0",
"@types/semver": "^7.7.1",
"magic-string": "^0.30.5",
@@ -11068,7 +11068,7 @@
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"storybook": "10.5.9"
"storybook": "10.5.8"
},
"peerDependenciesMeta": {
"typescript": {
@@ -11077,9 +11077,9 @@
}
},
"node_modules/@storybook/react-webpack5/node_modules/@storybook/preset-react-webpack/node_modules/@storybook/core-webpack": {
"version": "10.5.9",
"resolved": "https://registry.npmjs.org/@storybook/core-webpack/-/core-webpack-10.5.9.tgz",
"integrity": "sha512-YmXR9RJdQpH8EtWEIjLTr5LMGiCXSmZp/A9UkATY5xHM4qG2QwJB++jU2wv4nGDyaPiUcbzT2PQSXN6RXhiUZA==",
"version": "10.5.8",
"resolved": "https://registry.npmjs.org/@storybook/core-webpack/-/core-webpack-10.5.8.tgz",
"integrity": "sha512-HccINB0UbTtnyJtKpaX+C35BRTSnAwnreIMwwI+LpeUd4x9mQg0G9orB7lfBBZwd5LQf8YhM2Vkjiawzo41GLg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -11090,18 +11090,18 @@
"url": "https://opencollective.com/storybook"
},
"peerDependencies": {
"storybook": "10.5.9"
"storybook": "10.5.8"
}
},
"node_modules/@storybook/react-webpack5/node_modules/@storybook/react": {
"version": "10.5.9",
"resolved": "https://registry.npmjs.org/@storybook/react/-/react-10.5.9.tgz",
"integrity": "sha512-kApGOuNT26NkpioTsr1iT/Q2c44tA7OIsNUSyFqtT7W8k3fRn/jQWfrDegYxty0WG0wxdNMZq2ndRfOOF8HaHw==",
"version": "10.5.8",
"resolved": "https://registry.npmjs.org/@storybook/react/-/react-10.5.8.tgz",
"integrity": "sha512-6qqkmqX6imtL+0Z9Uan2tIfYivOI0FiVmWr0zpqqQR15AkJ18JfNcNTQoyjeAlCO0Kei56SWqnu2qLq52TYplg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@storybook/global": "^5.0.0",
"@storybook/react-dom-shim": "10.5.9",
"@storybook/react-dom-shim": "10.5.8",
"react-docgen": "^8.0.2",
"react-docgen-typescript": "^2.2.2"
},
@@ -11114,7 +11114,7 @@
"@types/react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"storybook": "10.5.9",
"storybook": "10.5.8",
"typescript": ">= 4.9.x"
},
"peerDependenciesMeta": {
@@ -11130,9 +11130,9 @@
}
},
"node_modules/@storybook/react-webpack5/node_modules/@storybook/react/node_modules/@storybook/react-dom-shim": {
"version": "10.5.9",
"resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-10.5.9.tgz",
"integrity": "sha512-7qZD6CSa64p1m/zX9tG4ALcEHlEk0Bx+5++4RL3MFlRCgCFfAomXsCHTzF0RyF8cI4vl+hS7Y0ryjQchVs6UQA==",
"version": "10.5.8",
"resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-10.5.8.tgz",
"integrity": "sha512-N8D13/Xny+V3kfe1KBgsAHS0nKWXLLdgOOXS9poKdYzVwVCN+CGEGBxWX0zMMtdCptqa6/57em9coPlZMoO+bg==",
"dev": true,
"license": "MIT",
"funding": {
@@ -11144,7 +11144,7 @@
"@types/react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"storybook": "10.5.9"
"storybook": "10.5.8"
},
"peerDependenciesMeta": {
"@types/react": {
@@ -14928,9 +14928,9 @@
}
},
"node_modules/antd": {
"version": "6.6.1",
"resolved": "https://registry.npmjs.org/antd/-/antd-6.6.1.tgz",
"integrity": "sha512-QHIHYoUk9N9nJy1T9fyxWKjY0qApdTEDd/6lzqYng8Uryv9FejNmbhKvYF7obGqB+TuLXQsPVF7fOVgyzM1KrQ==",
"version": "6.6.0",
"resolved": "https://registry.npmjs.org/antd/-/antd-6.6.0.tgz",
"integrity": "sha512-UDwWIbpmrCHB9ZQ+bPh4vQfB6DTI2ulIyoQ0Tc9xxalFblttiNGHl3ySBD9SyV/8+gUjFzfSx1+iU1Fog2i46w==",
"license": "MIT",
"dependencies": {
"@ant-design/colors": "^8.0.1",
@@ -14964,16 +14964,16 @@
"@rc-component/rate": "~1.0.1",
"@rc-component/resize-observer": "^1.1.2",
"@rc-component/segmented": "~1.3.0",
"@rc-component/select": "~1.10.1",
"@rc-component/select": "~1.10.0",
"@rc-component/slider": "~1.1.1",
"@rc-component/steps": "~1.2.2",
"@rc-component/switch": "~1.0.3",
"@rc-component/table": "~1.11.1",
"@rc-component/table": "~1.11.0",
"@rc-component/tabs": "~1.12.0",
"@rc-component/tooltip": "~1.5.0",
"@rc-component/tour": "~2.4.0",
"@rc-component/tree": "~1.4.0",
"@rc-component/tree-select": "~1.16.1",
"@rc-component/tree-select": "~1.16.0",
"@rc-component/trigger": "^3.10.1",
"@rc-component/upload": "~1.1.1",
"@rc-component/util": "^1.12.0",
@@ -15715,9 +15715,9 @@
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
"version": "2.11.15",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.15.tgz",
"integrity": "sha512-FwMjJJ7HnyZpWe+oWxegG0fezZyBZUagI5LZEoO3GCbtbKNwRfMH9Ue5d5v01PNePBy1QSfPSDTTeVL0Hb9EzA==",
"version": "2.11.14",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.14.tgz",
"integrity": "sha512-JyJ954WzuIR8/FFzX0o5krdSTrBAkcCSRfWSleRsIHSWV+cZe2FI1PKggVkFke1hBldRs+LRxUczzE9iPmgZww==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@@ -18559,9 +18559,9 @@
}
},
"node_modules/dayjs": {
"version": "1.11.23",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.23.tgz",
"integrity": "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==",
"version": "1.11.22",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.22.tgz",
"integrity": "sha512-1YRnxzt/AabP3GHxnaB9/b+ZScCKu5TeF+co+BWG+lnWVIwEcTFc1FVE0WLNmNO3sA6GGXL40i5qkHfbLzpwrg==",
"license": "MIT"
},
"node_modules/debounce": {
@@ -19140,10 +19140,11 @@
}
},
"node_modules/dompurify": {
"version": "3.4.13",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.13.tgz",
"integrity": "sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==",
"version": "3.4.12",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.12.tgz",
"integrity": "sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optional": true,
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
@@ -20236,9 +20237,9 @@
}
},
"node_modules/eslint-plugin-storybook": {
"version": "10.5.9",
"resolved": "https://registry.npmjs.org/eslint-plugin-storybook/-/eslint-plugin-storybook-10.5.9.tgz",
"integrity": "sha512-4Hrqccy/zttV0S/32TSUbqtizzj4sbkgYvv7UzHveH58v96PhrO+fSpOFAqYvzNDzdOU6smKwObFtzX+RZqj4w==",
"version": "10.5.8",
"resolved": "https://registry.npmjs.org/eslint-plugin-storybook/-/eslint-plugin-storybook-10.5.8.tgz",
"integrity": "sha512-bf9W5nZyWdIaCUZf4aEZnEeD1mn+csNYX8dYUQjAo6L7/DkSLtr65R4zFZ1xeS4m6dOXO6UtUySesCSw4e8w1g==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -20247,7 +20248,7 @@
},
"peerDependencies": {
"eslint": ">=8",
"storybook": "10.5.9"
"storybook": "10.5.8"
}
},
"node_modules/eslint-plugin-testing-library": {
@@ -37734,9 +37735,9 @@
}
},
"node_modules/storybook": {
"version": "10.5.9",
"resolved": "https://registry.npmjs.org/storybook/-/storybook-10.5.9.tgz",
"integrity": "sha512-UfdMKSjEhIKr8LbqYyIE5r7vT/drL/PxN75YaouJ+UG0FssEy6cf49OdTF3kstAqVMHskc+zEqyRoiQHZXHwgA==",
"version": "10.5.8",
"resolved": "https://registry.npmjs.org/storybook/-/storybook-10.5.8.tgz",
"integrity": "sha512-rR4oFMSiWBSqI0lvsJPtcQUPj8+hzj3TkLu+Mw61Wo6YxPSb5FsLSHai0jZnuaIdKIlmu25KCfwlSQl4e1uvnA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -42985,7 +42986,7 @@
"d3-scale": "^4.0.2",
"d3-time": "^3.1.0",
"d3-time-format": "^4.1.0",
"dayjs": "^1.11.23",
"dayjs": "^1.11.22",
"dompurify": "^3.4.13",
"fetch-retry": "^6.0.0",
"handlebars": "^4.7.9",
@@ -43145,6 +43146,189 @@
"version": "0.20.3",
"license": "Apache-2.0"
},
"plugins/legacy-plugin-chart-calendar": {
"name": "@superset-ui/legacy-plugin-chart-calendar",
"version": "0.20.3",
"extraneous": true,
"license": "Apache-2.0",
"dependencies": {
"d3-array": "^3.2.4",
"d3-selection": "^3.0.0",
"d3-tip": "^0.9.1",
"prop-types": "^15.8.1"
},
"peerDependencies": {
"@apache-superset/core": "*",
"@emotion/react": "^11.4.1",
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*",
"react": "^18.3.0"
}
},
"plugins/legacy-plugin-chart-chord": {
"name": "@superset-ui/legacy-plugin-chart-chord",
"version": "0.20.3",
"extraneous": true,
"license": "Apache-2.0",
"dependencies": {
"d3": "^3.5.17",
"prop-types": "^15.8.1",
"react": "^19.2.7"
},
"peerDependencies": {
"@apache-superset/core": "*",
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*"
}
},
"plugins/legacy-plugin-chart-country-map": {
"name": "@superset-ui/legacy-plugin-chart-country-map",
"version": "0.20.3",
"extraneous": true,
"license": "Apache-2.0",
"dependencies": {
"d3": "^3.5.17",
"d3-array": "^3.2.4",
"prop-types": "^15.8.1"
},
"peerDependencies": {
"@apache-superset/core": "*",
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*",
"react": "^18.3.0"
}
},
"plugins/legacy-plugin-chart-horizon": {
"name": "@superset-ui/legacy-plugin-chart-horizon",
"version": "0.20.3",
"extraneous": true,
"license": "Apache-2.0",
"dependencies": {
"d3-array": "^3.2.4",
"d3-scale": "^4.0.2",
"prop-types": "^15.8.1"
},
"peerDependencies": {
"@apache-superset/core": "*",
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*",
"react": "^18.3.0"
}
},
"plugins/legacy-plugin-chart-paired-t-test": {
"name": "@superset-ui/legacy-plugin-chart-paired-t-test",
"version": "0.20.3",
"extraneous": true,
"license": "Apache-2.0",
"dependencies": {
"distributions": "^2.2.0",
"prop-types": "^15.8.1",
"reactable": "^1.1.0"
},
"peerDependencies": {
"@apache-superset/core": "*",
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*",
"react": "^18.3.0"
}
},
"plugins/legacy-plugin-chart-parallel-coordinates": {
"name": "@superset-ui/legacy-plugin-chart-parallel-coordinates",
"version": "0.20.3",
"extraneous": true,
"license": "Apache-2.0",
"dependencies": {
"d3v3": "npm:d3@3.5.17",
"prop-types": "^15.8.1"
},
"peerDependencies": {
"@apache-superset/core": "*",
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*",
"react": "^18.3.0"
}
},
"plugins/legacy-plugin-chart-partition": {
"name": "@superset-ui/legacy-plugin-chart-partition",
"version": "0.20.3",
"extraneous": true,
"license": "Apache-2.0",
"dependencies": {
"d3": "^3.5.17",
"d3-hierarchy": "^3.1.2",
"prop-types": "^15.8.1"
},
"peerDependencies": {
"@apache-superset/core": "*",
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*",
"@testing-library/jest-dom": "*",
"@testing-library/react": "^15.0.0",
"react": "^18.3.0",
"react-dom": "^18.3.0"
}
},
"plugins/legacy-plugin-chart-rose": {
"name": "@superset-ui/legacy-plugin-chart-rose",
"version": "0.20.3",
"extraneous": true,
"license": "Apache-2.0",
"dependencies": {
"d3": "^3.5.17",
"nvd3-fork": "^2.0.5",
"prop-types": "^15.8.1"
},
"peerDependencies": {
"@apache-superset/core": "*",
"@emotion/react": "^11.4.1",
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*",
"react": "^18.3.0"
}
},
"plugins/legacy-plugin-chart-world-map": {
"name": "@superset-ui/legacy-plugin-chart-world-map",
"version": "0.20.3",
"extraneous": true,
"license": "Apache-2.0",
"dependencies": {
"d3": "^3.5.17",
"d3-array": "^3.2.4",
"datamaps": "^0.5.10",
"prop-types": "^15.8.1",
"tinycolor2": "^1.6.0"
},
"peerDependencies": {
"@apache-superset/core": "*",
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*",
"react": "^18.3.0"
}
},
"plugins/legacy-preset-chart-nvd3": {
"name": "@superset-ui/legacy-preset-chart-nvd3",
"version": "0.20.3",
"extraneous": true,
"license": "Apache-2.0",
"dependencies": {
"d3": "^3.5.17",
"d3-tip": "^0.9.1",
"dompurify": "^3.4.12",
"fast-safe-stringify": "^2.1.1",
"lodash": "^4.18.1",
"lodash-es": "^4.18.1",
"nvd3-fork": "^2.0.5",
"prop-types": "^15.8.1",
"urijs": "^1.19.11"
},
"peerDependencies": {
"@apache-superset/core": "*",
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*",
"dayjs": "^1.11.21",
"react": "^18.3.0"
}
},
"plugins/plugin-chart-ag-grid-table": {
"name": "@superset-ui/plugin-chart-ag-grid-table",
"version": "0.20.3",
+8 -8
View File
@@ -158,12 +158,12 @@
"@visx/xychart": "^4.0.0",
"ag-grid-community": "36.1.0",
"ag-grid-react": "36.1.0",
"antd": "^6.6.1",
"antd": "^6.6.0",
"chrono-node": "^2.10.1",
"classnames": "^2.2.5",
"content-disposition": "^2.0.1",
"d3-scale": "^4.0.2",
"dayjs": "^1.11.23",
"dayjs": "^1.11.22",
"dom-to-image-more": "^3.10.2",
"dom-to-pdf": "^0.3.2",
"echarts": "^6.1.0",
@@ -257,9 +257,9 @@
"@istanbuljs/nyc-config-typescript": "^1.0.1",
"@playwright/test": "^1.62.1",
"@pmmmwh/react-refresh-webpack-plugin": "^0.6.2",
"@storybook/addon-docs": "10.5.9",
"@storybook/addon-links": "10.5.9",
"@storybook/react-webpack5": "10.5.9",
"@storybook/addon-docs": "10.5.8",
"@storybook/addon-links": "10.5.8",
"@storybook/react-webpack5": "10.5.8",
"@storybook/test-runner": "0.24.4",
"@svgr/webpack": "^8.1.0",
"@swc/core": "^1.16.0",
@@ -295,7 +295,7 @@
"babel-loader": "^10.1.1",
"babel-plugin-dynamic-import-node": "^2.3.3",
"babel-plugin-jsx-remove-data-test-id": "^3.0.0",
"baseline-browser-mapping": "^2.11.15",
"baseline-browser-mapping": "^2.11.14",
"cheerio": "1.2.0",
"concurrently": "^10.0.5",
"copy-webpack-plugin": "^14.0.0",
@@ -312,7 +312,7 @@
"eslint-plugin-no-only-tests": "^3.4.0",
"eslint-plugin-react-prefer-function-component": "^5.0.0",
"eslint-plugin-react-you-might-not-need-an-effect": "^1.0.1",
"eslint-plugin-storybook": "10.5.9",
"eslint-plugin-storybook": "10.5.8",
"eslint-plugin-testing-library": "^7.16.2",
"eslint-plugin-theme-colors": "file:eslint-rules/eslint-plugin-theme-colors",
"fetch-mock": "^12.6.0",
@@ -343,7 +343,7 @@
"source-map": "^0.8.0",
"source-map-support": "^0.5.21",
"speed-measure-webpack-plugin": "^1.6.0",
"storybook": "10.5.9",
"storybook": "10.5.8",
"style-loader": "^4.0.0",
"stylelint": "^17.14.1",
"swc-loader": "^0.2.7",
@@ -17,7 +17,7 @@
* under the License.
*/
import { QueryFormMetric } from '@superset-ui/core';
import { getTotalsMetrics, toTotalsAggregate } from './getTotalsMetrics';
import { getTotalsMetrics } from './getTotalsMetrics';
const simpleMetric = (aggregate: string): QueryFormMetric =>
({
@@ -76,31 +76,4 @@ describe('getTotalsMetrics', () => {
test('returns an empty array when given no metrics', () => {
expect(getTotalsMetrics([], 'AVG')).toEqual([]);
});
test("ORIGINAL keeps each metric's own aggregate", () => {
const metrics = [
simpleMetric('COUNT_DISTINCT'),
sqlMetric(),
savedMetric(),
];
const result = getTotalsMetrics(metrics, 'ORIGINAL');
expect(result).toBe(metrics);
expect(result[0]).toEqual(
expect.objectContaining({ aggregate: 'COUNT_DISTINCT' }),
);
});
});
describe('toTotalsAggregate', () => {
test.each(['SUM', 'AVG'] as const)('passes %s through', value => {
expect(toTotalsAggregate(value)).toBe(value);
});
test.each([undefined, null, '', 'MEDIAN', 'sum'])(
'falls back to ORIGINAL for %p',
value => {
expect(toTotalsAggregate(value)).toBe('ORIGINAL');
},
);
});
@@ -18,46 +18,26 @@
*/
import { isAdhocMetricSimple, QueryFormMetric } from '@superset-ui/core';
/**
* How the "Show summary" totals row aggregates each metric.
*
* ``ORIGINAL`` keeps every metric's own aggregation. It is the default because
* overriding is not universally valid: ``SUM`` over a ``COUNT_DISTINCT`` of a
* non-numeric column (a uuid, say) is rejected outright by the database, and
* over a numeric id column it silently produces a meaningless number.
*/
export type TotalsAggregate = 'ORIGINAL' | 'SUM' | 'AVG';
export type TotalsAggregate = 'SUM' | 'AVG';
/**
* Build the metrics for a chart's "Show summary" totals query.
* Build the metrics for a chart's "Show summary" totals query, overriding
* each Simple (adhoc) metric's aggregate function with the user-chosen
* totals aggregate. The totals query has no GROUP BY, so the database
* evaluates each metric fresh over all rows -- swapping the aggregate here
* is a correct, independent computation, not a re-aggregation of
* already-aggregated per-row values.
*
* With SUM or AVG, each Simple (adhoc) metric is cloned with its aggregate
* replaced. The totals query has no GROUP BY, so the database evaluates each
* metric fresh over all rows -- that swap is an independent computation, not a
* re-aggregation of already-aggregated per-row values.
*
* Custom-SQL and saved (string) metrics always pass through unchanged: there is
* no safe way to rewrite an arbitrary SQL expression's aggregate without
* parsing it, so the totals row keeps their own native aggregate.
* Custom-SQL metrics and saved (string) metrics pass through unchanged:
* there is no safe way to rewrite an arbitrary SQL expression's aggregate
* function without parsing it, so the totals row keeps their own native
* aggregate for those.
*/
export function getTotalsMetrics(
metrics: QueryFormMetric[],
aggregate: TotalsAggregate,
): QueryFormMetric[] {
if (aggregate === 'ORIGINAL') {
return metrics;
}
return metrics.map(metric =>
isAdhocMetricSimple(metric) ? { ...metric, aggregate } : metric,
);
}
/**
* Narrow a raw ``totals_aggregate`` form-data value to a TotalsAggregate.
*
* Anything other than an explicit SUM/AVG — including charts saved before the
* control existed — keeps each metric's own aggregation.
*/
export function toTotalsAggregate(value: unknown): TotalsAggregate {
return value === 'SUM' || value === 'AVG' ? value : 'ORIGINAL';
}
@@ -67,7 +67,7 @@
"d3-scale": "^4.0.2",
"d3-time": "^3.1.0",
"d3-time-format": "^4.1.0",
"dayjs": "^1.11.23",
"dayjs": "^1.11.22",
"dompurify": "^3.4.13",
"fetch-retry": "^6.0.0",
"handlebars": "^4.7.9",
@@ -64,8 +64,9 @@ export default function createSmartNumberFormatter(
description,
formatFunc: value => `${getSign(value)}${formatValue(value)}`,
id:
id ??
(signed ? NumberFormats.SMART_NUMBER_SIGNED : NumberFormats.SMART_NUMBER),
id || signed
? NumberFormats.SMART_NUMBER_SIGNED
: NumberFormats.SMART_NUMBER,
label: label ?? 'Adaptive formatter',
});
}
@@ -24,12 +24,6 @@ describe('createSmartNumberFormatter(options)', () => {
const formatter = createSmartNumberFormatter();
expect(formatter).toBeInstanceOf(NumberFormatter);
});
test('uses the supplied formatter id regardless of signed option', () => {
expect(createSmartNumberFormatter({ id: 'custom' }).id).toBe('custom');
expect(
createSmartNumberFormatter({ id: 'custom-signed', signed: true }).id,
).toBe('custom-signed');
});
describe('using default options', () => {
const formatter = createSmartNumberFormatter();
test('formats 0 correctly', () => {
@@ -38,7 +38,7 @@ import {
getTotalsMetrics,
isTimeComparison,
timeCompareOperator,
toTotalsAggregate,
TotalsAggregate,
} from '@superset-ui/chart-controls';
import { isEmpty } from 'lodash-es';
import { TableChartFormData } from './types';
@@ -696,16 +696,13 @@ export const buildQueryUncached: BuildQuery<TableChartFormData> = (
formData.show_totals &&
queryMode === QueryMode.Aggregate,
);
const totalsAggregate = toTotalsAggregate(formData.totals_aggregate);
// Raw-mode summary columns have no metric of their own to preserve, so
// ORIGINAL has nothing to fall back to; sum them as before.
const rawSummaryAggregate =
totalsAggregate === 'ORIGINAL' ? 'SUM' : totalsAggregate;
const totalsAggregate: TotalsAggregate =
formData.totals_aggregate === 'AVG' ? 'AVG' : 'SUM';
const totalsMetrics =
rawSummaryColumns.length > 0
? rawSummaryColumns.map(columnName => ({
expressionType: 'SIMPLE' as const,
aggregate: rawSummaryAggregate,
aggregate: totalsAggregate,
column: { column_name: columnName },
label: columnName,
}))
@@ -503,18 +503,14 @@ const config: ControlPanelConfig = {
label: t('Summary aggregation'),
renderTrigger: true,
description: t(
'Aggregation used for the summary row. By default each metric ' +
'keeps its own aggregation; Sum and Average override it for ' +
'the summary row only. The override applies to simple ' +
'metrics (a metric built from custom SQL always keeps its ' +
'own aggregation). Overriding a count or a distinct count ' +
'sums the counted column instead, which fails outright on a ' +
'non-numeric column.',
'Aggregation used for the summary row, independent of each ' +
"metric's own aggregation. Only applies to simple metrics " +
'(a metric built from custom SQL keeps its own aggregation ' +
'in the summary row).',
),
default: 'ORIGINAL',
default: 'SUM',
clearable: false,
choices: [
['ORIGINAL', t("Each metric's own")],
['SUM', t('Sum')],
['AVG', t('Average')],
],
@@ -1561,7 +1561,7 @@ describe('plugin-chart-ag-grid-table', () => {
expect(queries[1].metrics).toEqual(['count']);
});
test("defaults aggregate-mode totals to the metric's own aggregate", () => {
test('defaults aggregate-mode totals to SUM for a simple metric', () => {
const simpleMetric = {
expressionType: 'SIMPLE' as const,
column: { column_name: 'sales' },
@@ -1580,29 +1580,9 @@ describe('plugin-chart-ag-grid-table', () => {
{ ownState: {} },
);
expect(queries[1].metrics).toEqual([simpleMetric]);
});
test('keeps COUNT_DISTINCT in aggregate-mode totals by default', () => {
const countDistinctMetric = {
expressionType: 'SIMPLE' as const,
column: { column_name: 'contract_id' },
aggregate: 'COUNT_DISTINCT' as const,
label: 'contracts',
};
const { queries } = buildQuery(
{
viz_type: VizType.Table,
datasource: '11__table',
query_mode: QueryMode.Aggregate,
groupby: ['state'],
metrics: [countDistinctMetric],
show_totals: true,
},
{ ownState: {} },
);
expect(queries[1].metrics).toEqual([countDistinctMetric]);
expect(queries[1].metrics).toEqual([
{ ...simpleMetric, aggregate: 'SUM' },
]);
});
test('overrides aggregate-mode totals to AVG for a simple metric when totals_aggregate is set', () => {
@@ -105,7 +105,7 @@
"source": [
"## Download Data\n",
"\n",
"Download datasets (_Admin 0 - Countries_ in [1:10](https://www.naturalearthdata.com/downloads/10m-cultural-vectors/), and _Admin 1 \u2013 States, Provinces_ in 1:10 and [1:50](https://www.naturalearthdata.com/downloads/50m-cultural-vectors/)) from Natural Earch Data:"
"Download datasets (_Admin 0 - Countries_ in [1:10](https://www.naturalearthdata.com/downloads/10m-cultural-vectors/), and _Admin 1 States, Provinces_ in 1:10 and [1:50](https://www.naturalearthdata.com/downloads/50m-cultural-vectors/)) from Natural Earch Data:"
]
},
{
@@ -584,7 +584,7 @@
" </tr>\n",
" </tbody>\n",
"</table>\n",
"<p>9 rows \u00d7 121 columns</p>\n",
"<p>9 rows × 121 columns</p>\n",
"</div>"
],
"text/plain": [
@@ -926,33 +926,33 @@
" <td>11.0</td>\n",
" <td>11.0</td>\n",
" <td>Q34617</td>\n",
" <td>\u0633\u0627\u0646 \u0628\u064a\u064a\u0631 \u0648\u0645\u064a\u0643\u0644\u0648\u0646</td>\n",
" <td>\u09b8\u09be\u0981 \u09aa\u09bf\u09af\u09bc\u09c7\u09b0 \u0993 \u09ae\u09bf\u0995\u09b2\u09cb\u0981</td>\n",
" <td>سان بيير وميكلون</td>\n",
" <td>সাঁ পিয়ের ও মিকলোঁ</td>\n",
" <td>Saint-Pierre und Miquelon</td>\n",
" <td>Saint Pierre and Miquelon</td>\n",
" <td>San Pedro y Miquel\u00f3n</td>\n",
" <td>San Pedro y Miquelón</td>\n",
" <td>Saint-Pierre-et-Miquelon</td>\n",
" <td>\u03a3\u03b1\u03b9\u03bd-\u03a0\u03b9\u03b5\u03c1 \u03ba\u03b1\u03b9 \u039c\u03b9\u03ba\u03b5\u03bb\u03cc\u03bd</td>\n",
" <td>\u0938\u0928\u094d\u0924 \u092a\u093f\u092f\u0930 \u0914\u0930 \u092e\u093f\u0915\u0932\u093e\u0928</td>\n",
" <td>Saint-Pierre \u00e9s Miquelon</td>\n",
" <td>Σαιν-Πιερ και Μικελόν</td>\n",
" <td>सन्त पियर और मिकलान</td>\n",
" <td>Saint-Pierre és Miquelon</td>\n",
" <td>Saint Pierre dan Miquelon</td>\n",
" <td>Saint-Pierre e Miquelon</td>\n",
" <td>\u30b5\u30f3\u30d4\u30a8\u30fc\u30eb\u5cf6\u30fb\u30df\u30af\u30ed\u30f3\u5cf6</td>\n",
" <td>\uc0dd\ud53c\uc5d0\ub974 \ubbf8\ud074\ub871</td>\n",
" <td>サンピエール島・ミクロン島</td>\n",
" <td>생피에르 미클롱</td>\n",
" <td>Saint-Pierre en Miquelon</td>\n",
" <td>Saint-Pierre i Miquelon</td>\n",
" <td>Saint-Pierre e Miquelon</td>\n",
" <td>\u0421\u0435\u043d-\u041f\u044c\u0435\u0440 \u0438 \u041c\u0438\u043a\u0435\u043b\u043e\u043d</td>\n",
" <td>Сен-Пьер и Микелон</td>\n",
" <td>Saint-Pierre och Miquelon</td>\n",
" <td>Saint Pierre ve Miquelon</td>\n",
" <td>Saint-Pierre v\u00e0 Miquelon</td>\n",
" <td>\u5723\u76ae\u57c3\u5c14\u548c\u5bc6\u514b\u9686</td>\n",
" <td>Saint-Pierre và Miquelon</td>\n",
" <td>圣皮埃尔和密克隆</td>\n",
" <td>1159315673</td>\n",
" <td>\u05e1\u05df-\u05e4\u05d9\u05d9\u05e8 \u05d5\u05de\u05d9\u05e7\u05dc\u05d5\u05df</td>\n",
" <td>\u0421\u0435\u043d-\u041f'\u0454\u0440 \u0456 \u041c\u0456\u043a\u0435\u043b\u043e\u043d</td>\n",
" <td>\u0633\u06cc\u0646\u0679 \u067e\u06cc\u0626\u0631 \u0648 \u0645\u06cc\u06a9\u06cc\u0644\u0648\u0646</td>\n",
" <td>\u0633\u0646 \u067e\u06cc\u0631 \u0648 \u0645\u06cc\u06a9\u0644\u0646</td>\n",
" <td>\u8056\u76ae\u57c3\u8207\u5bc6\u514b\u9686\u7fa4\u5cf6</td>\n",
" <td>סן-פייר ומיקלון</td>\n",
" <td>Сен-П'єр і Мікелон</td>\n",
" <td>سینٹ پیئر و میکیلون</td>\n",
" <td>سن پیر و میکلن</td>\n",
" <td>聖皮埃與密克隆群島</td>\n",
" <td>None</td>\n",
" <td>None</td>\n",
" <td>None</td>\n",
@@ -1051,33 +1051,33 @@
" <td>11.0</td>\n",
" <td>11.0</td>\n",
" <td>None</td>\n",
" <td>\u0645\u064a\u0643\u0644\u0648\u0646 \u0648\u0644\u0627\u0646\u063a\u0644\u064a\u062f</td>\n",
" <td>\u09ae\u09bf\u0995\u09c1\u0987\u09b2\u09a8-\u09b2\u09cd\u09af\u09be\u0982\u09b2\u09c7\u09a1</td>\n",
" <td>ميكلون ولانغليد</td>\n",
" <td>মিকুইলন-ল্যাংলেড</td>\n",
" <td>Miquelon-Langlade</td>\n",
" <td>Miquelon-Langlade</td>\n",
" <td>Miquel\u00f3n-Langlade</td>\n",
" <td>Miquelón-Langlade</td>\n",
" <td>Miquelon-Langlade</td>\n",
" <td>\u039c\u03b9\u03ba\u03b5\u03bb\u03cc\u03bd-\u039b\u03b1\u03b3\u03ba\u03bb\u03ad\u03b9\u03bd\u03c4</td>\n",
" <td>\u092e\u093f\u0915\u0947\u0932\u0949\u0928-\u0932\u0948\u0902\u0917\u0932\u0947\u0921</td>\n",
" <td>Μικελόν-Λαγκλέιντ</td>\n",
" <td>मिकेलॉन-लैंगलेड</td>\n",
" <td>Miquelon-Langlade</td>\n",
" <td>Miquelon-Langlade</td>\n",
" <td>Miquelon-Langlade</td>\n",
" <td>\u30df\u30af\u30ed\u30f3\uff1d\u30e9\u30f3\u30b0\u30e9\u30fc\u30c9</td>\n",
" <td>\ubbf8\ud074\ub871-\ub7ad\uae00\ub808\uc774\ub4dc</td>\n",
" <td>ミクロン=ラングラード</td>\n",
" <td>미클롱-랭글레이드</td>\n",
" <td>Miquelon-Langlade</td>\n",
" <td>Miquelon-Langlade</td>\n",
" <td>Miquel\u00e3o-Langlade</td>\n",
" <td>\u041c\u0438\u043a\u0435\u043b\u043e\u043d-\u041b\u0430\u043d\u0433\u043b\u0430\u0434</td>\n",
" <td>Miquelão-Langlade</td>\n",
" <td>Микелон-Ланглад</td>\n",
" <td>Miquelon-Langlade</td>\n",
" <td>Miquelon-Langlade</td>\n",
" <td>Miquelon-Langlade</td>\n",
" <td>\u5bc6\u514b\u9686-\u6717\u683c\u62c9\u5fb7</td>\n",
" <td>密克隆-朗格拉德</td>\n",
" <td>1159315961</td>\n",
" <td>\u05de\u05d9\u05e8\u05d4</td>\n",
" <td>\u041c\u0456\u043a\u0432\u0435\u043b\u043e\u043d-\u041b\u0430\u043d\u0433\u043b\u0430\u0434\u0435</td>\n",
" <td>\u0645\u06cc\u06a9\u06cc\u0648\u0644\u0648\u0646 \u0644\u06cc\u0646\u06af\u0644\u0627\u0688\u06d2</td>\n",
" <td>\u0645\u06cc\u06a9\u0648\u0626\u0644\u0648\u0646-\u0644\u0627\u0646\u06af\u0644\u06cc\u062f</td>\n",
" <td>\u5bc6\u514b\u9686-\u6717\u683c\u62c9\u5fb7</td>\n",
" <td>מירה</td>\n",
" <td>Міквелон-Лангладе</td>\n",
" <td>میکیولون لینگلاڈے</td>\n",
" <td>میکوئلون-لانگلید</td>\n",
" <td>密克隆-朗格拉德</td>\n",
" <td>None</td>\n",
" <td>None</td>\n",
" <td>None</td>\n",
@@ -1167,48 +1167,48 @@
"2177 PM.97501 None None 1.0 fra SB00 None \n",
"\n",
" min_label max_label min_zoom wikidataid name_ar \\\n",
"2176 11.0 11.0 11.0 Q34617 \u0633\u0627\u0646 \u0628\u064a\u064a\u0631 \u0648\u0645\u064a\u0643\u0644\u0648\u0646 \n",
"2177 11.0 11.0 11.0 None \u0645\u064a\u0643\u0644\u0648\u0646 \u0648\u0644\u0627\u0646\u063a\u0644\u064a\u062f \n",
"2176 11.0 11.0 11.0 Q34617 سان بيير وميكلون \n",
"2177 11.0 11.0 11.0 None ميكلون ولانغليد \n",
"\n",
" name_bn name_de \\\n",
"2176 \u09b8\u09be\u0981 \u09aa\u09bf\u09af\u09bc\u09c7\u09b0 \u0993 \u09ae\u09bf\u0995\u09b2\u09cb\u0981 Saint-Pierre und Miquelon \n",
"2177 \u09ae\u09bf\u0995\u09c1\u0987\u09b2\u09a8-\u09b2\u09cd\u09af\u09be\u0982\u09b2\u09c7\u09a1 Miquelon-Langlade \n",
"2176 সাঁ পিয়ের ও মিকলোঁ Saint-Pierre und Miquelon \n",
"2177 মিকুইলন-ল্যাংলেড Miquelon-Langlade \n",
"\n",
" name_en name_es \\\n",
"2176 Saint Pierre and Miquelon San Pedro y Miquel\u00f3n \n",
"2177 Miquelon-Langlade Miquel\u00f3n-Langlade \n",
"2176 Saint Pierre and Miquelon San Pedro y Miquelón \n",
"2177 Miquelon-Langlade Miquelón-Langlade \n",
"\n",
" name_fr name_el name_hi \\\n",
"2176 Saint-Pierre-et-Miquelon \u03a3\u03b1\u03b9\u03bd-\u03a0\u03b9\u03b5\u03c1 \u03ba\u03b1\u03b9 \u039c\u03b9\u03ba\u03b5\u03bb\u03cc\u03bd \u0938\u0928\u094d\u0924 \u092a\u093f\u092f\u0930 \u0914\u0930 \u092e\u093f\u0915\u0932\u093e\u0928 \n",
"2177 Miquelon-Langlade \u039c\u03b9\u03ba\u03b5\u03bb\u03cc\u03bd-\u039b\u03b1\u03b3\u03ba\u03bb\u03ad\u03b9\u03bd\u03c4 \u092e\u093f\u0915\u0947\u0932\u0949\u0928-\u0932\u0948\u0902\u0917\u0932\u0947\u0921 \n",
"2176 Saint-Pierre-et-Miquelon Σαιν-Πιερ και Μικελόν सन्त पियर और मिकलान \n",
"2177 Miquelon-Langlade Μικελόν-Λαγκλέιντ मिकेलॉन-लैंगलेड \n",
"\n",
" name_hu name_id \\\n",
"2176 Saint-Pierre \u00e9s Miquelon Saint Pierre dan Miquelon \n",
"2176 Saint-Pierre és Miquelon Saint Pierre dan Miquelon \n",
"2177 Miquelon-Langlade Miquelon-Langlade \n",
"\n",
" name_it name_ja name_ko \\\n",
"2176 Saint-Pierre e Miquelon \u30b5\u30f3\u30d4\u30a8\u30fc\u30eb\u5cf6\u30fb\u30df\u30af\u30ed\u30f3\u5cf6 \uc0dd\ud53c\uc5d0\ub974 \ubbf8\ud074\ub871 \n",
"2177 Miquelon-Langlade \u30df\u30af\u30ed\u30f3\uff1d\u30e9\u30f3\u30b0\u30e9\u30fc\u30c9 \ubbf8\ud074\ub871-\ub7ad\uae00\ub808\uc774\ub4dc \n",
"2176 Saint-Pierre e Miquelon サンピエール島・ミクロン島 생피에르 미클롱 \n",
"2177 Miquelon-Langlade ミクロン=ラングラード 미클롱-랭글레이드 \n",
"\n",
" name_nl name_pl \\\n",
"2176 Saint-Pierre en Miquelon Saint-Pierre i Miquelon \n",
"2177 Miquelon-Langlade Miquelon-Langlade \n",
"\n",
" name_pt name_ru name_sv \\\n",
"2176 Saint-Pierre e Miquelon \u0421\u0435\u043d-\u041f\u044c\u0435\u0440 \u0438 \u041c\u0438\u043a\u0435\u043b\u043e\u043d Saint-Pierre och Miquelon \n",
"2177 Miquel\u00e3o-Langlade \u041c\u0438\u043a\u0435\u043b\u043e\u043d-\u041b\u0430\u043d\u0433\u043b\u0430\u0434 Miquelon-Langlade \n",
"2176 Saint-Pierre e Miquelon Сен-Пьер и Микелон Saint-Pierre och Miquelon \n",
"2177 Miquelão-Langlade Микелон-Ланглад Miquelon-Langlade \n",
"\n",
" name_tr name_vi name_zh \\\n",
"2176 Saint Pierre ve Miquelon Saint-Pierre v\u00e0 Miquelon \u5723\u76ae\u57c3\u5c14\u548c\u5bc6\u514b\u9686 \n",
"2177 Miquelon-Langlade Miquelon-Langlade \u5bc6\u514b\u9686-\u6717\u683c\u62c9\u5fb7 \n",
"2176 Saint Pierre ve Miquelon Saint-Pierre và Miquelon 圣皮埃尔和密克隆 \n",
"2177 Miquelon-Langlade Miquelon-Langlade 密克隆-朗格拉德 \n",
"\n",
" ne_id name_he name_uk name_ur \\\n",
"2176 1159315673 \u05e1\u05df-\u05e4\u05d9\u05d9\u05e8 \u05d5\u05de\u05d9\u05e7\u05dc\u05d5\u05df \u0421\u0435\u043d-\u041f'\u0454\u0440 \u0456 \u041c\u0456\u043a\u0435\u043b\u043e\u043d \u0633\u06cc\u0646\u0679 \u067e\u06cc\u0626\u0631 \u0648 \u0645\u06cc\u06a9\u06cc\u0644\u0648\u0646 \n",
"2177 1159315961 \u05de\u05d9\u05e8\u05d4 \u041c\u0456\u043a\u0432\u0435\u043b\u043e\u043d-\u041b\u0430\u043d\u0433\u043b\u0430\u0434\u0435 \u0645\u06cc\u06a9\u06cc\u0648\u0644\u0648\u0646 \u0644\u06cc\u0646\u06af\u0644\u0627\u0688\u06d2 \n",
"2176 1159315673 סן-פייר ומיקלון Сен-П'єр і Мікелон سینٹ پیئر و میکیلون \n",
"2177 1159315961 מירה Міквелон-Лангладе میکیولون لینگلاڈے \n",
"\n",
" name_fa name_zht FCLASS_ISO FCLASS_US FCLASS_FR FCLASS_RU \\\n",
"2176 \u0633\u0646 \u067e\u06cc\u0631 \u0648 \u0645\u06cc\u06a9\u0644\u0646 \u8056\u76ae\u57c3\u8207\u5bc6\u514b\u9686\u7fa4\u5cf6 None None None None \n",
"2177 \u0645\u06cc\u06a9\u0648\u0626\u0644\u0648\u0646-\u0644\u0627\u0646\u06af\u0644\u06cc\u062f \u5bc6\u514b\u9686-\u6717\u683c\u62c9\u5fb7 None None None None \n",
"2176 سن پیر و میکلن 聖皮埃與密克隆群島 None None None None \n",
"2177 میکوئلون-لانگلید 密克隆-朗格拉德 None None None None \n",
"\n",
" FCLASS_ES FCLASS_CN FCLASS_TW FCLASS_IN FCLASS_NP FCLASS_PK FCLASS_DE \\\n",
"2176 None None None None None None None \n",
@@ -1330,7 +1330,7 @@
" 'costa rica',\n",
" 'croatia',\n",
" 'cuba',\n",
" 'cura\u00e7ao',\n",
" 'curaçao',\n",
" 'cyprus',\n",
" 'czech republic',\n",
" 'denmark',\n",
@@ -1343,7 +1343,7 @@
" 'equatorial guinea',\n",
" 'eritrea',\n",
" 'estonia',\n",
" # 'eswatini', # not sure why this doesn't work \u2014 Swaziland isn't available to alias, either.\n",
" # 'eswatini', # not sure why this doesn't work Swaziland isn't available to alias, either.\n",
" 'ethiopia',\n",
" 'falkland islands',\n",
" 'faroe islands',\n",
@@ -1443,7 +1443,7 @@
" 'portugal',\n",
" 'puerto rico',\n",
" 'qatar',\n",
" # 'r\u00e9union', # part of France, in Natural Earth data\n",
" # 'réunion', # part of France, in Natural Earth data\n",
" 'republic of serbia',\n",
" 'romania',\n",
" 'russia',\n",
@@ -1911,34 +1911,34 @@
" <td>9.0</td>\n",
" <td>1159320473</td>\n",
" <td>Q8646</td>\n",
" <td>\u0647\u0648\u0646\u063a \u0643\u0648\u0646\u063a</td>\n",
" <td>\u09b9\u0982\u0995\u0982</td>\n",
" <td>هونغ كونغ</td>\n",
" <td>হংকং</td>\n",
" <td>Hongkong</td>\n",
" <td>Hong Kong</td>\n",
" <td>Hong Kong</td>\n",
" <td>\u0647\u0646\u06af \u06a9\u0646\u06af</td>\n",
" <td>هنگ کنگ</td>\n",
" <td>Hong Kong</td>\n",
" <td>\u03a7\u03bf\u03bd\u03b3\u03ba \u039a\u03bf\u03bd\u03b3\u03ba</td>\n",
" <td>\u05d4\u05d5\u05e0\u05d2 \u05e7\u05d5\u05e0\u05d2</td>\n",
" <td>\u0939\u093e\u0902\u0917\u0915\u093e\u0902\u0917</td>\n",
" <td>Χονγκ Κονγκ</td>\n",
" <td>הונג קונג</td>\n",
" <td>हांगकांग</td>\n",
" <td>Hongkong</td>\n",
" <td>Hong Kong</td>\n",
" <td>Hong Kong</td>\n",
" <td>\u9999\u6e2f</td>\n",
" <td>\ud64d\ucf69</td>\n",
" <td>香港</td>\n",
" <td>홍콩</td>\n",
" <td>Hongkong</td>\n",
" <td>Hongkong</td>\n",
" <td>Hong Kong</td>\n",
" <td>\u0413\u043e\u043d\u043a\u043e\u043d\u0433</td>\n",
" <td>Гонконг</td>\n",
" <td>Hongkong</td>\n",
" <td>Hong Kong</td>\n",
" <td>\u0413\u043e\u043d\u043a\u043e\u043d\u0433</td>\n",
" <td>\u06c1\u0627\u0646\u06af \u06a9\u0627\u0646\u06af</td>\n",
" <td>H\u1ed3ng K\u00f4ng</td>\n",
" <td>\u9999\u6e2f</td>\n",
" <td>\u9999\u6e2f</td>\n",
" <td>Гонконг</td>\n",
" <td>ہانگ کانگ</td>\n",
" <td>Hồng Kông</td>\n",
" <td>香港</td>\n",
" <td>香港</td>\n",
" <td>MULTIPOLYGON (((114.22983 22.55581, 114.23471 ...</td>\n",
" <td>\u9999\u6e2f\u7279\u522b\u884c\u653f\u533a</td>\n",
" <td>香港特别行政区</td>\n",
" <td>CN-91</td>\n",
" </tr>\n",
" <tr>\n",
@@ -1965,34 +1965,34 @@
" <td>8.0</td>\n",
" <td>1159321335</td>\n",
" <td>Q865</td>\n",
" <td>\u062a\u0627\u064a\u0648\u0627\u0646</td>\n",
" <td>\u09a4\u09be\u0987\u0993\u09af\u09bc\u09be\u09a8</td>\n",
" <td>تايوان</td>\n",
" <td>তাইওয়ান</td>\n",
" <td>Republik China</td>\n",
" <td>Taiwan</td>\n",
" <td>Rep\u00fablica de China</td>\n",
" <td>\u062a\u0627\u06cc\u0648\u0627\u0646</td>\n",
" <td>Ta\u00efwan</td>\n",
" <td>\u0394\u03b7\u03bc\u03bf\u03ba\u03c1\u03b1\u03c4\u03af\u03b1 \u03c4\u03b7\u03c2 \u039a\u03af\u03bd\u03b1\u03c2</td>\n",
" <td>\u05d8\u05d0\u05d9\u05d5\u05d5\u05d0\u05df</td>\n",
" <td>\u091a\u0940\u0928\u0940 \u0917\u0923\u0930\u093e\u091c\u094d\u092f</td>\n",
" <td>K\u00ednai K\u00f6zt\u00e1rsas\u00e1g</td>\n",
" <td>República de China</td>\n",
" <td>تایوان</td>\n",
" <td>Taïwan</td>\n",
" <td>Δημοκρατία της Κίνας</td>\n",
" <td>טאיוואן</td>\n",
" <td>चीनी गणराज्य</td>\n",
" <td>Kínai Köztársaság</td>\n",
" <td>Taiwan</td>\n",
" <td>Taiwan</td>\n",
" <td>\u4e2d\u83ef\u6c11\u56fd</td>\n",
" <td>\uc911\ud654\ubbfc\uad6d</td>\n",
" <td>中華民国</td>\n",
" <td>중화민국</td>\n",
" <td>Taiwan</td>\n",
" <td>Republika Chi\u0144ska</td>\n",
" <td>Republika Chińska</td>\n",
" <td>Taiwan</td>\n",
" <td>\u0422\u0430\u0439\u0432\u0430\u043d\u044c</td>\n",
" <td>Тайвань</td>\n",
" <td>Taiwan</td>\n",
" <td>\u00c7in Cumhuriyeti</td>\n",
" <td>\u0420\u0435\u0441\u043f\u0443\u0431\u043b\u0456\u043a\u0430 \u041a\u0438\u0442\u0430\u0439</td>\n",
" <td>\u062a\u0627\u0626\u06cc\u0648\u0627\u0646</td>\n",
" <td>\u0110\u00e0i Loan</td>\n",
" <td>\u4e2d\u534e\u6c11\u56fd</td>\n",
" <td>\u4e2d\u83ef\u6c11\u570b</td>\n",
" <td>Çin Cumhuriyeti</td>\n",
" <td>Республіка Китай</td>\n",
" <td>تائیوان</td>\n",
" <td>Đài Loan</td>\n",
" <td>中华民国</td>\n",
" <td>中華民國</td>\n",
" <td>MULTIPOLYGON (((121.90577 24.9501, 121.83473 2...</td>\n",
" <td>\u4e2d\u56fd\u53f0\u6e7e</td>\n",
" <td>中国台湾</td>\n",
" <td>CN-71</td>\n",
" </tr>\n",
" <tr>\n",
@@ -2019,34 +2019,34 @@
" <td>9.0</td>\n",
" <td>1159320475</td>\n",
" <td>Q14773</td>\n",
" <td>\u0645\u0627\u0643\u0627\u0648</td>\n",
" <td>\u09ae\u09be\u0995\u09be\u0993</td>\n",
" <td>ماكاو</td>\n",
" <td>মাকাও</td>\n",
" <td>Macau</td>\n",
" <td>Macau</td>\n",
" <td>Macao</td>\n",
" <td>\u0645\u0627\u06a9\u0627\u0626\u0648</td>\n",
" <td>ماکائو</td>\n",
" <td>Macao</td>\n",
" <td>\u039c\u03b1\u03ba\u03ac\u03bf\u03c5</td>\n",
" <td>\u05de\u05e7\u05d0\u05d5</td>\n",
" <td>\u092e\u0915\u093e\u0909</td>\n",
" <td>Maka\u00f3</td>\n",
" <td>Μακάου</td>\n",
" <td>מקאו</td>\n",
" <td>मकाउ</td>\n",
" <td>Makaó</td>\n",
" <td>Makau</td>\n",
" <td>Macao</td>\n",
" <td>\u30de\u30ab\u30aa</td>\n",
" <td>\ub9c8\uce74\uc624</td>\n",
" <td>マカオ</td>\n",
" <td>마카오</td>\n",
" <td>Macau</td>\n",
" <td>Makau</td>\n",
" <td>Macau</td>\n",
" <td>\u041c\u0430\u043a\u0430\u043e</td>\n",
" <td>Макао</td>\n",
" <td>Macao</td>\n",
" <td>Makao</td>\n",
" <td>\u0410\u043e\u043c\u0438\u043d\u044c</td>\n",
" <td>\u0645\u06a9\u0627\u0624</td>\n",
" <td>Аоминь</td>\n",
" <td>مکاؤ</td>\n",
" <td>Ma Cao</td>\n",
" <td>\u6fb3\u95e8</td>\n",
" <td>\u6fb3\u9580</td>\n",
" <td>澳门</td>\n",
" <td>澳門</td>\n",
" <td>MULTIPOLYGON (((113.5586 22.16303, 113.56943 2...</td>\n",
" <td>\u6fb3\u95e8\u7279\u522b\u884c\u653f\u533a</td>\n",
" <td>澳门特别行政区</td>\n",
" <td>CN-92</td>\n",
" </tr>\n",
" </tbody>\n",
@@ -2070,34 +2070,34 @@
"2 4 3 MO 20070017 5 0.0 4.0 \n",
"\n",
" max_label ne_id wikidataid name_ar name_bn name_de \\\n",
"0 9.0 1159320473 Q8646 \u0647\u0648\u0646\u063a \u0643\u0648\u0646\u063a \u09b9\u0982\u0995\u0982 Hongkong \n",
"1 8.0 1159321335 Q865 \u062a\u0627\u064a\u0648\u0627\u0646 \u09a4\u09be\u0987\u0993\u09af\u09bc\u09be\u09a8 Republik China \n",
"2 9.0 1159320475 Q14773 \u0645\u0627\u0643\u0627\u0648 \u09ae\u09be\u0995\u09be\u0993 Macau \n",
"0 9.0 1159320473 Q8646 هونغ كونغ হংকং Hongkong \n",
"1 8.0 1159321335 Q865 تايوان তাইওয়ান Republik China \n",
"2 9.0 1159320475 Q14773 ماكاو মাকাও Macau \n",
"\n",
" name_en name_es name_fa name_fr name_el \\\n",
"0 Hong Kong Hong Kong \u0647\u0646\u06af \u06a9\u0646\u06af Hong Kong \u03a7\u03bf\u03bd\u03b3\u03ba \u039a\u03bf\u03bd\u03b3\u03ba \n",
"1 Taiwan Rep\u00fablica de China \u062a\u0627\u06cc\u0648\u0627\u0646 Ta\u00efwan \u0394\u03b7\u03bc\u03bf\u03ba\u03c1\u03b1\u03c4\u03af\u03b1 \u03c4\u03b7\u03c2 \u039a\u03af\u03bd\u03b1\u03c2 \n",
"2 Macau Macao \u0645\u0627\u06a9\u0627\u0626\u0648 Macao \u039c\u03b1\u03ba\u03ac\u03bf\u03c5 \n",
"0 Hong Kong Hong Kong هنگ کنگ Hong Kong Χονγκ Κονγκ \n",
"1 Taiwan República de China تایوان Taïwan Δημοκρατία της Κίνας \n",
"2 Macau Macao ماکائو Macao Μακάου \n",
"\n",
" name_he name_hi name_hu name_id name_it name_ja \\\n",
"0 \u05d4\u05d5\u05e0\u05d2 \u05e7\u05d5\u05e0\u05d2 \u0939\u093e\u0902\u0917\u0915\u093e\u0902\u0917 Hongkong Hong Kong Hong Kong \u9999\u6e2f \n",
"1 \u05d8\u05d0\u05d9\u05d5\u05d5\u05d0\u05df \u091a\u0940\u0928\u0940 \u0917\u0923\u0930\u093e\u091c\u094d\u092f K\u00ednai K\u00f6zt\u00e1rsas\u00e1g Taiwan Taiwan \u4e2d\u83ef\u6c11\u56fd \n",
"2 \u05de\u05e7\u05d0\u05d5 \u092e\u0915\u093e\u0909 Maka\u00f3 Makau Macao \u30de\u30ab\u30aa \n",
"0 הונג קונג हांगकांग Hongkong Hong Kong Hong Kong 香港 \n",
"1 טאיוואן चीनी गणराज्य Kínai Köztársaság Taiwan Taiwan 中華民国 \n",
"2 מקאו मकाउ Makaó Makau Macao マカオ \n",
"\n",
" name_ko name_nl name_pl name_pt name_ru name_sv \\\n",
"0 \ud64d\ucf69 Hongkong Hongkong Hong Kong \u0413\u043e\u043d\u043a\u043e\u043d\u0433 Hongkong \n",
"1 \uc911\ud654\ubbfc\uad6d Taiwan Republika Chi\u0144ska Taiwan \u0422\u0430\u0439\u0432\u0430\u043d\u044c Taiwan \n",
"2 \ub9c8\uce74\uc624 Macau Makau Macau \u041c\u0430\u043a\u0430\u043e Macao \n",
"0 홍콩 Hongkong Hongkong Hong Kong Гонконг Hongkong \n",
"1 중화민국 Taiwan Republika Chińska Taiwan Тайвань Taiwan \n",
"2 마카오 Macau Makau Macau Макао Macao \n",
"\n",
" name_tr name_uk name_ur name_vi name_zh_x name_zht \\\n",
"0 Hong Kong \u0413\u043e\u043d\u043a\u043e\u043d\u0433 \u06c1\u0627\u0646\u06af \u06a9\u0627\u0646\u06af H\u1ed3ng K\u00f4ng \u9999\u6e2f \u9999\u6e2f \n",
"1 \u00c7in Cumhuriyeti \u0420\u0435\u0441\u043f\u0443\u0431\u043b\u0456\u043a\u0430 \u041a\u0438\u0442\u0430\u0439 \u062a\u0627\u0626\u06cc\u0648\u0627\u0646 \u0110\u00e0i Loan \u4e2d\u534e\u6c11\u56fd \u4e2d\u83ef\u6c11\u570b \n",
"2 Makao \u0410\u043e\u043c\u0438\u043d\u044c \u0645\u06a9\u0627\u0624 Ma Cao \u6fb3\u95e8 \u6fb3\u9580 \n",
"0 Hong Kong Гонконг ہانگ کانگ Hồng Kông 香港 香港 \n",
"1 Çin Cumhuriyeti Республіка Китай تائیوان Đài Loan 中华民国 中華民國 \n",
"2 Makao Аоминь مکاؤ Ma Cao 澳门 澳門 \n",
"\n",
" geometry name_zh_y iso_3166_2 \n",
"0 MULTIPOLYGON (((114.22983 22.55581, 114.23471 ... \u9999\u6e2f\u7279\u522b\u884c\u653f\u533a CN-91 \n",
"1 MULTIPOLYGON (((121.90577 24.9501, 121.83473 2... \u4e2d\u56fd\u53f0\u6e7e CN-71 \n",
"2 MULTIPOLYGON (((113.5586 22.16303, 113.56943 2... \u6fb3\u95e8\u7279\u522b\u884c\u653f\u533a CN-92 "
"0 MULTIPOLYGON (((114.22983 22.55581, 114.23471 ... 香港特别行政区 CN-91 \n",
"1 MULTIPOLYGON (((121.90577 24.9501, 121.83473 2... 中国台湾 CN-71 \n",
"2 MULTIPOLYGON (((113.5586 22.16303, 113.56943 2... 澳门特别行政区 CN-92 "
]
},
"execution_count": 14,
@@ -2114,7 +2114,7 @@
"china_sars = china_sars.merge(pd.DataFrame(\n",
" data={\n",
" \"name_en\": [\"Taiwan\", \"Hong Kong\", \"Macau\"],\n",
" \"name_zh\": [\"\u4e2d\u56fd\u53f0\u6e7e\", \"\u9999\u6e2f\u7279\u522b\u884c\u653f\u533a\", \"\u6fb3\u95e8\u7279\u522b\u884c\u653f\u533a\"],\n",
" \"name_zh\": [\"中国台湾\", \"香港特别行政区\", \"澳门特别行政区\"],\n",
" \"iso_3166_2\": [\"CN-71\", \"CN-91\", \"CN-92\"],\n",
" },\n",
"), on=\"name_en\", how=\"left\")\n",
@@ -2252,7 +2252,7 @@
" }\n",
")[[\"geometry\", \"iso_3166_2\", \"name\"]].copy()\n",
"\n",
"# Convert MA01 \u2192 MA-01\n",
"# Convert MA01 MA-01\n",
"morocco_copy[\"iso_3166_2\"] = morocco_copy[\n",
" \"iso_3166_2\"\n",
"].str.replace(\n",
@@ -2290,7 +2290,7 @@
"source": [
"#### Finland\n",
"\n",
"- The \u00c5land Islands (ISO country code AX) is an autonomous region of Finland, and carries the ISO-3166 code FI-01."
"- The Åland Islands (ISO country code AX) is an autonomous region of Finland, and carries the ISO-3166 code FI-01."
]
},
{
@@ -2312,12 +2312,12 @@
"outputs": [],
"source": [
"finland_aland = df_admin0_10m.loc[\n",
" df_admin0_10m.name_en.isin(['\u00c5land']),\n",
" df_admin0_10m.name_en.isin(['Åland']),\n",
" [x for x in df_admin0_10m.columns if x in df.columns]\n",
"]\n",
"finland_aland = finland_aland.merge(pd.DataFrame(\n",
" data={\n",
" \"name_en\": [\"\u00c5land\"],\n",
" \"name_en\": [\"Åland\"],\n",
" \"name_fi\": [\"Ahvenanmaan maakunta\"],\n",
" \"iso_3166_2\": [\"FI-01\"],\n",
" },\n",
@@ -3197,34 +3197,34 @@
"\n",
"# Turkey city name corrections\n",
"# Fix completely wrong spellings\n",
"replace_column('name', turkey, 'Kinkkale', 'K\u0131r\u0131kkale')\n",
"replace_column('name', turkey, 'Kinkkale', 'Kırıkkale')\n",
"replace_column('name', turkey, 'Zinguldak', 'Zonguldak')\n",
"replace_column('name', turkey, 'K. Maras', 'Kahramanmara\u015f')\n",
"replace_column('name', turkey, 'K. Maras', 'Kahramanmaraş')\n",
"\n",
"# Fix missing Turkish characters\n",
"replace_column('name', turkey, 'Adiyaman', 'Ad\u0131yaman')\n",
"replace_column('name', turkey, 'Agri', 'A\u011fr\u0131')\n",
"replace_column('name', turkey, 'Aydin', 'Ayd\u0131n')\n",
"replace_column('name', turkey, 'Balikesir', 'Bal\u0131kesir')\n",
"replace_column('name', turkey, '\u00c7ankiri', '\u00c7ank\u0131r\u0131')\n",
"replace_column('name', turkey, 'Diyarbakir', 'Diyarbak\u0131r')\n",
"replace_column('name', turkey, 'Elazig', 'El\u00e2z\u0131\u011f')\n",
"replace_column('name', turkey, 'Eskisehir', 'Eski\u015fehir')\n",
"replace_column('name', turkey, 'G\u00fcm\u00fcshane', 'G\u00fcm\u00fc\u015fhane')\n",
"replace_column('name', turkey, 'Hakkari', 'Hakk\u00e2ri')\n",
"replace_column('name', turkey, 'Istanbul', '\u0130stanbul')\n",
"replace_column('name', turkey, 'Izmir', '\u0130zmir')\n",
"replace_column('name', turkey, 'I\u011fdir', 'I\u011fd\u0131r')\n",
"replace_column('name', turkey, 'Kirklareli', 'K\u0131rklareli')\n",
"replace_column('name', turkey, 'Kirsehir', 'K\u0131r\u015fehir')\n",
"replace_column('name', turkey, 'Mugla', 'Mu\u011fla')\n",
"replace_column('name', turkey, 'Mus', 'Mu\u015f')\n",
"replace_column('name', turkey, 'Nevsehir', 'Nev\u015fehir')\n",
"replace_column('name', turkey, 'Nigde', 'Ni\u011fde')\n",
"replace_column('name', turkey, 'Sanliurfa', '\u015eanl\u0131urfa')\n",
"replace_column('name', turkey, 'Sirnak', '\u015e\u0131rnak')\n",
"replace_column('name', turkey, 'Tekirdag', 'Tekirda\u011f')\n",
"replace_column('name', turkey, 'Usak', 'U\u015fak')\n",
"replace_column('name', turkey, 'Adiyaman', 'Adıyaman')\n",
"replace_column('name', turkey, 'Agri', 'Ağrı')\n",
"replace_column('name', turkey, 'Aydin', 'Aydın')\n",
"replace_column('name', turkey, 'Balikesir', 'Balıkesir')\n",
"replace_column('name', turkey, 'Çankiri', 'Çankırı')\n",
"replace_column('name', turkey, 'Diyarbakir', 'Diyarbakır')\n",
"replace_column('name', turkey, 'Elazig', 'Elâzığ')\n",
"replace_column('name', turkey, 'Eskisehir', 'Eskişehir')\n",
"replace_column('name', turkey, 'Gümüshane', 'Gümüşhane')\n",
"replace_column('name', turkey, 'Hakkari', 'Hakkâri')\n",
"replace_column('name', turkey, 'Istanbul', 'İstanbul')\n",
"replace_column('name', turkey, 'Izmir', 'İzmir')\n",
"replace_column('name', turkey, 'Iğdir', 'Iğdır')\n",
"replace_column('name', turkey, 'Kirklareli', 'Kırklareli')\n",
"replace_column('name', turkey, 'Kirsehir', 'Kıehir')\n",
"replace_column('name', turkey, 'Mugla', 'Muğla')\n",
"replace_column('name', turkey, 'Mus', 'Muş')\n",
"replace_column('name', turkey, 'Nevsehir', 'Nevşehir')\n",
"replace_column('name', turkey, 'Nigde', 'Niğde')\n",
"replace_column('name', turkey, 'Sanliurfa', 'Şanlıurfa')\n",
"replace_column('name', turkey, 'Sirnak', 'Şırnak')\n",
"replace_column('name', turkey, 'Tekirdag', 'Tekirdağ')\n",
"replace_column('name', turkey, 'Usak', 'Uşak')\n",
"turkey_copy = turkey.copy()"
]
},
@@ -3263,18 +3263,18 @@
"\n",
"# Region names corresponding to NUTS-1\n",
"\n",
"region_name_dict = {'TR1':'\u0130stanbul',\n",
" 'TR2':'Bat\u0131 Marmara',\n",
"region_name_dict = {'TR1':'İstanbul',\n",
" 'TR2':'Batı Marmara',\n",
" 'TR3':'Ege',\n",
" 'TR4':'Do\u011fu Marmara',\n",
" 'TR5':'Bat\u0131 Anadolu',\n",
" 'TR4':'Doğu Marmara',\n",
" 'TR5':'Batı Anadolu',\n",
" 'TR6':'Akdeniz',\n",
" 'TR7':'Orta Anadolu',\n",
" 'TR8':'Bat\u0131 Karadeniz',\n",
" 'TR9':'Do\u011fu Karadeniz',\n",
" 'TRA':'Kuzeydo\u011fu Anadolu',\n",
" 'TRC':'G\u00fcneydo\u011fu Anadolu',\n",
" 'TRB':'Ortado\u011fu Anadolu'\n",
" 'TR8':'Batı Karadeniz',\n",
" 'TR9':'Doğu Karadeniz',\n",
" 'TRA':'Kuzeydoğu Anadolu',\n",
" 'TRC':'Güneydoğu Anadolu',\n",
" 'TRB':'Ortadoğu Anadolu'\n",
" }\n",
"\n",
"\n",
@@ -3517,8 +3517,8 @@
"france_copy = france.copy()\n",
"reposition(france_copy, france.name=='Guadeloupe', 57.4, 25.4, 1.5, 1.5)\n",
"reposition(france_copy, france.name=='Martinique', 58.4, 27.1, 1.5, 1.5)\n",
"reposition(france_copy, france.name=='Guyane fran\u00e7aise', 52, 37.7, 0.35, 0.35)\n",
"reposition(france_copy, france.name=='La R\u00e9union', -55, 62.8, 1.5, 1.5)\n",
"reposition(france_copy, france.name=='Guyane française', 52, 37.7, 0.35, 0.35)\n",
"reposition(france_copy, france.name=='La Réunion', -55, 62.8, 1.5, 1.5)\n",
"reposition(france_copy, france.name=='Mayotte', -43, 54.3, 1.5, 1.5)\n",
"\n",
"not speed_run and france_copy.plot(figsize=(8, 8), **plot_styles)"
@@ -3669,8 +3669,8 @@
"france_overseas = france.copy()\n",
"reposition(france_overseas, france.name=='Guadeloupe', 53.2, 29, 1.5, 1.5)\n",
"reposition(france_overseas, france.name=='Martinique', 52.8, 27.5, 1.5, 1.5)\n",
"reposition(france_overseas, france.name=='Guyane fran\u00e7aise', 45, 35.5, 0.3, 0.3)\n",
"reposition(france_overseas, france.name=='La R\u00e9union', -58.2, 60.5, 1.5, 1.5)\n",
"reposition(france_overseas, france.name=='Guyane française', 45, 35.5, 0.3, 0.3)\n",
"reposition(france_overseas, france.name=='La Réunion', -58.2, 60.5, 1.5, 1.5)\n",
"reposition(france_overseas, france.name=='Mayotte', -50.5, 52.2, 2, 2)\n",
"\n",
"# Tahiti\n",
@@ -3713,7 +3713,7 @@
"france_overseas = pd.concat([france_overseas, saint_martin_data], ignore_index=True)\n",
"reposition(france_overseas, france_overseas.admin=='Saint Martin', 54.8, 30.3, 5, 5)\n",
"\n",
"# Saint Barth\u00e9l\u00e9my\n",
"# Saint Barthélémy\n",
"saint_barthelemy_data = df[(df['admin'] == 'Saint Barthelemy')]\n",
"france_overseas = pd.concat([france_overseas, saint_barthelemy_data], ignore_index=True)\n",
"reposition(france_overseas, france_overseas.admin=='Saint Barthelemy', 54.5, 30, 8, 8)\n",
@@ -3729,13 +3729,13 @@
"france_overseas = pd.concat([france_overseas, paris_and_littlecrowndpts_copy], ignore_index=True)\n",
"\n",
"# Update metadata properly\n",
"france_overseas.loc[france_overseas['name'] == 'Windward Islands', ['name', 'iso_3166_2']] = ['Polyn\u00e9sie fran\u00e7aise', 'FR-PF']\n",
"france_overseas.loc[france_overseas['name'] == 'Archipel des Kerguelen', ['name', 'iso_3166_2']] = ['Terres australes et antarctiques fran\u00e7aises', 'FR-TF']\n",
"france_overseas.loc[france_overseas['name'] == 'Windward Islands', ['name', 'iso_3166_2']] = ['Polynésie française', 'FR-PF']\n",
"france_overseas.loc[france_overseas['name'] == 'Archipel des Kerguelen', ['name', 'iso_3166_2']] = ['Terres australes et antarctiques françaises', 'FR-TF']\n",
"france_overseas.loc[france_overseas['admin'] == 'Wallis and Futuna', ['name', 'iso_3166_2']] = ['Wallis et Futuna', 'FR-WF']\n",
"france_overseas.loc[france_overseas['admin'] == 'New Caledonia', ['name', 'iso_3166_2']] = ['Nouvelle-Cal\u00e9donie', 'FR-NC']\n",
"france_overseas.loc[france_overseas['admin'] == 'New Caledonia', ['name', 'iso_3166_2']] = ['Nouvelle-Calédonie', 'FR-NC']\n",
"france_overseas.loc[france_overseas['admin'] == 'Saint Pierre and Miquelon', ['name', 'iso_3166_2']] = ['Saint-Pierre-et-Miquelon', 'FR-PM']\n",
"france_overseas.loc[france_overseas['admin'] == 'Saint Martin', ['name', 'iso_3166_2']] = ['Saint-Martin', 'FR-MF']\n",
"france_overseas.loc[france_overseas['admin'] == 'Saint Barthelemy', ['name', 'iso_3166_2']] = ['Saint-Barth\u00e9l\u00e9my', 'FR-BL']\n",
"france_overseas.loc[france_overseas['admin'] == 'Saint Barthelemy', ['name', 'iso_3166_2']] = ['Saint-Barthélémy', 'FR-BL']\n",
"\n",
"# Plot data\n",
"france_overseas = france_overseas.rename(columns={'NAME_1': 'name','ISO': 'iso_3166_2'})\n",
@@ -3821,51 +3821,6 @@
"not speed_run and italy_regions.plot(figsize=(10, 7), **plot_styles)"
]
},
{
"cell_type": "markdown",
"metadata": {
"id": "65aIalqEt1LR"
},
"source": [
"#### Italy Regions and Autonomous Provinces"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {
"execution": {
"iopub.execute_input": "2026-07-27T19:54:28.892892Z",
"iopub.status.busy": "2026-07-27T19:54:28.892454Z",
"iopub.status.idle": "2026-07-27T19:54:31.123499Z",
"shell.execute_reply": "2026-07-27T19:54:31.122932Z"
}
},
"outputs": [],
"source": [
"trento_and_bozen = df[(df.admin == 'Italy') & (df.iso_3166_2.isin(['IT-TN', 'IT-BZ']))][['geometry','iso_3166_2','name']]\n",
"\n",
"italy_regions_and_autonomous_provinces = pd.concat([italy_regions, trento_and_bozen])\n",
"\n",
"italy_regions_and_autonomous_provinces = italy_regions_and_autonomous_provinces[italy_regions_and_autonomous_provinces['iso_3166_2'] != 'IT-32']"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {
"execution": {
"iopub.execute_input": "2026-07-27T19:54:28.892892Z",
"iopub.status.busy": "2026-07-27T19:54:28.892454Z",
"iopub.status.idle": "2026-07-27T19:54:31.123499Z",
"shell.execute_reply": "2026-07-27T19:54:31.122932Z"
}
},
"outputs": [],
"source": [
"not speed_run and italy_regions_and_autonomous_provinces.plot(figsize=(10, 7), **plot_styles)"
]
},
{
"cell_type": "markdown",
"metadata": {
@@ -4376,86 +4331,86 @@
"output_type": "stream",
"text": [
"Kon Tum\n",
"\u0110\u1eafk N\u00f4ng\n",
"\u0110\u1eafk L\u1eafk\n",
"Đắk Nông\n",
"Đắk Lắk\n",
"Gia Lai\n",
"B\u00ecnh Ph\u01b0\u1edbc\n",
"T\u00e2y Ninh\n",
"Bình Phước\n",
"Tây Ninh\n",
"Long An\n",
"\u0110\u1ed3ng Th\u00e1p\n",
"Đồng Tháp\n",
"An Giang\n",
"Ki\u00ean Giang\n",
"\u0110i\u1ec7n Bi\u00ean\n",
"S\u01a1n La\n",
"Thanh H\u00f3a\n",
"Ngh\u1ec7 An\n",
"H\u00e0 T\u0129nh\n",
"Qu\u1ea3ng B\u00ecnh\n",
"Qu\u1ea3ng Tr\u1ecb\n",
"Th\u1eeba Thi\u00ean - Hu\u1ebf\n",
"Qu\u1ea3ng Nam\n",
"H\u00e0 Giang\n",
"Cao B\u1eb1ng\n",
"L\u00e0o Cai\n",
"Lai Ch\u00e2u\n",
"L\u1ea1ng S\u01a1n\n",
"Qu\u1ea3ng Ninh\n",
"S\u00f3c Tr\u0103ng\n",
"Ti\u1ec1n Giang\n",
"B\u00e0 R\u1ecba - V\u0169ng T\u00e0u\n",
"Th\u00e0nh ph\u1ed1 H\u1ed3 Ch\u00ed Minh\n",
"Kh\u00e1nh H\u00f2a\n",
"C\u00e0 Mau\n",
"B\u1ea1c Li\u00eau\n",
"H\u1eadu Giang\n",
"V\u0129nh Long\n",
"Tr\u00e0 Vinh\n",
"B\u1ebfn Tre\n",
"\u0110\u1ed3ng Nai\n",
"B\u00ecnh Thu\u1eadn\n",
"Ninh Thu\u1eadn\n",
"Ph\u00fa Y\u00ean\n",
"B\u00ecnh \u0110\u1ecbnh\n",
"Qu\u1ea3ng Ng\u00e3i\n",
"\u0110\u00e0 N\u1eb5ng\n",
"Ninh B\u00ecnh\n",
"Nam \u0110\u1ecbnh\n",
"Th\u00e1i B\u00ecnh\n",
"H\u1ea3i Ph\u00f2ng\n",
"H\u00f2a B\u00ecnh\n",
"Tuy\u00ean Quang\n",
"Y\u00ean B\u00e1i\n",
"V\u0129nh Ph\u00fac\n",
"Ph\u00fa Th\u1ecd\n",
"H\u00e0 N\u1ed9i\n",
"B\u1eafc K\u1ea1n\n",
"H\u01b0ng Y\u00ean\n",
"B\u1eafc Ninh\n",
"B\u1eafc Giang\n",
"Th\u00e1i Nguy\u00ean\n",
"H\u1ea3i D\u01b0\u01a1ng\n",
"H\u00e0 Nam\n",
"B\u00ecnh D\u01b0\u01a1ng\n",
"L\u00e2m \u0110\u1ed3ng\n",
"C\u1ea7n Th\u01a1\n"
"Kiên Giang\n",
"Điện Biên\n",
"Sơn La\n",
"Thanh Hóa\n",
"Ngh An\n",
"Hà Tĩnh\n",
"Quảng Bình\n",
"Quảng Trị\n",
"Thừa Thiên - Huế\n",
"Qung Nam\n",
"Hà Giang\n",
"Cao Bng\n",
"Lào Cai\n",
"Lai Châu\n",
"Lạng Sơn\n",
"Qung Ninh\n",
"Sóc Trăng\n",
"Tin Giang\n",
"Bà Rịa - Vũng Tàu\n",
"Thành phố Hồ Chí Minh\n",
"Khánh Hòa\n",
"Cà Mau\n",
"Bạc Liêu\n",
"Hu Giang\n",
"Vĩnh Long\n",
"Trà Vinh\n",
"Bến Tre\n",
"Đồng Nai\n",
"Bình Thuận\n",
"Ninh Thun\n",
"Phú Yên\n",
"Bình Định\n",
"Quảng Ngãi\n",
"Đà Nẵng\n",
"Ninh Bình\n",
"Nam Định\n",
"Thái Bình\n",
"Hải Phòng\n",
"Hòa Bình\n",
"Tuyên Quang\n",
"Yên Bái\n",
"Vĩnh Phúc\n",
"Phú Thọ\n",
"Hà Nội\n",
"Bắc Kạn\n",
"Hưng Yên\n",
"Bc Ninh\n",
"Bc Giang\n",
"Thái Nguyên\n",
"Hải Dương\n",
"Hà Nam\n",
"Bình Dương\n",
"Lâm Đồng\n",
"Cần Thơ\n"
]
}
],
"source": [
"vietnam = df[df.admin == 'Vietnam']\n",
"vietnam_copy = vietnam.copy()\n",
"replace_column('name', vietnam_copy, '\u00d0ong Th\u00e1p', '\u0110\u1ed3ng Th\u00e1p')\n",
"replace_column('name', vietnam_copy, 'Son La', 'S\u01a1n La')\n",
"replace_column('name', vietnam_copy, 'Ha Tinh', 'H\u00e0 T\u0129nh')\n",
"replace_column('name', vietnam_copy, 'Qu\u00e0ng Nam', 'Qu\u1ea3ng Nam')\n",
"replace_column('name', vietnam_copy, 'Lai Chau', 'Lai Ch\u00e2u')\n",
"replace_column('name', vietnam_copy, 'H\u1ed3 Ch\u00ed Minh city', 'Th\u00e0nh ph\u1ed1 H\u1ed3 Ch\u00ed Minh')\n",
"replace_column('name', vietnam_copy, 'Hau Giang', 'H\u1eadu Giang')\n",
"replace_column('name', vietnam_copy, 'Ha Noi', 'H\u00e0 N\u1ed9i')\n",
"replace_column('name', vietnam_copy, 'Can Tho', 'C\u1ea7n Th\u01a1')\n",
"replace_column('name', vietnam_copy, '\u0110\u00f4ng Nam B\u1ed9', '\u0110\u1ed3ng Nai')\n",
"replace_column('name', vietnam_copy, '\u0110\u00f4ng B\u1eafc', 'B\u1eafc K\u1ea1n')\n",
"replace_column('name', vietnam_copy, '\u0110\u1ed3ng B\u1eb1ng S\u00f4ng H\u1ed3ng', 'H\u01b0ng Y\u00ean')\n",
"replace_column('name', vietnam_copy, 'Ðong Tháp', 'Đồng Tháp')\n",
"replace_column('name', vietnam_copy, 'Son La', 'Sơn La')\n",
"replace_column('name', vietnam_copy, 'Ha Tinh', 'Hà Tĩnh')\n",
"replace_column('name', vietnam_copy, 'Quàng Nam', 'Qung Nam')\n",
"replace_column('name', vietnam_copy, 'Lai Chau', 'Lai Châu')\n",
"replace_column('name', vietnam_copy, 'Hồ Chí Minh city', 'Thành phố Hồ Chí Minh')\n",
"replace_column('name', vietnam_copy, 'Hau Giang', 'Hu Giang')\n",
"replace_column('name', vietnam_copy, 'Ha Noi', 'Hà Nội')\n",
"replace_column('name', vietnam_copy, 'Can Tho', 'Cần Thơ')\n",
"replace_column('name', vietnam_copy, 'Đông Nam Bộ', 'Đồng Nai')\n",
"replace_column('name', vietnam_copy, 'Đông Bắc', 'Bắc Kạn')\n",
"replace_column('name', vietnam_copy, 'Đồng Bằng Sông Hồng', 'Hưng Yên')\n",
"for i in vietnam_copy['name']:\n",
" print(i)"
]
@@ -4499,7 +4454,6 @@
" \"turkey\": turkey_copy,\n",
" \"turkey_regions\": turkey_regions,\n",
" \"italy_regions\": italy_regions,\n",
" \"italy_regions_and_autonomous_provinces\": italy_regions_and_autonomous_provinces,\n",
" \"philippines_regions\": philippines_regions,\n",
" \"latvia\": latvia_copy,\n",
" \"netherlands\": netherlands_copy,\n",
@@ -4538,7 +4492,7 @@
"aruba has only one subdivision - removing from countries array\n",
"british indian ocean territory has only one subdivision - removing from countries array\n",
"cayman islands has only one subdivision - removing from countries array\n",
"cura\u00e7ao has only one subdivision - removing from countries array\n",
"curaçao has only one subdivision - removing from countries array\n",
"falkland islands has only one subdivision - removing from countries array\n",
"faroe islands has only one subdivision - removing from countries array\n",
"gibraltar has only one subdivision - removing from countries array\n",
@@ -4574,7 +4528,7 @@
"name": "stdout",
"output_type": "stream",
"text": [
"cura\u00e7ao has only one subdivision - removing from countries array\n",
"curaçao has only one subdivision - removing from countries array\n",
"falkland islands has only one subdivision - removing from countries array\n",
"faroe islands has only one subdivision - removing from countries array\n"
]
@@ -103,7 +103,6 @@ import iran from './countries/iran.geojson';
import israel from './countries/israel.geojson';
import italy from './countries/italy.geojson';
import italy_regions from './countries/italy_regions.geojson';
import italy_regions_and_autonomous_provinces from './countries/italy_regions_and_autonomous_provinces.geojson';
import ivory_coast from './countries/ivory_coast.geojson';
import japan from './countries/japan.geojson';
import jordan from './countries/jordan.geojson';
@@ -307,7 +306,6 @@ export const countries = {
israel,
italy,
italy_regions,
italy_regions_and_autonomous_provinces,
ivory_coast,
japan,
jordan,
@@ -432,9 +430,6 @@ export const countryOptions = Object.keys(countries).map(x => {
if (x === 'italy_regions') {
return [x, 'Italy (regions)'];
}
if (x === 'italy_regions_and_autonomous_provinces') {
return [x, 'Italy (regions and autonomous provinces)'];
}
if (x === 'france_regions') {
return [x, 'France (regions)'];
}
@@ -33,6 +33,6 @@
{ "type": "Feature", "properties": { "ISO": "IR-25", "NAME_1": "Yazd" }, "geometry": { "type": "Polygon", "coordinates": [ [ [ 53.650710076708663, 32.61286754000497 ], [ 54.70904341032508, 32.920083929729572 ], [ 54.814670038291581, 32.970700994954939 ], [ 54.908204380227914, 33.088161526533213 ], [ 54.986339146034709, 33.329955553206219 ], [ 55.040082635105477, 33.385688585459832 ], [ 55.139404737638529, 33.430362861356969 ], [ 55.217229445082808, 33.437261664194409 ], [ 55.274590285313366, 33.470644639738339 ], [ 55.359856397954388, 33.594642238948154 ], [ 55.386418085026548, 33.665464788968791 ], [ 55.375255974983247, 34.344363918859756 ], [ 55.484706658785228, 34.365163682857656 ], [ 55.741125116131172, 34.360331935770205 ], [ 55.850885858295669, 34.407254137268581 ], [ 56.210450474010145, 34.906060898691521 ], [ 56.619211053447316, 34.993678289721288 ], [ 57.016602818165495, 35.148294175835531 ], [ 57.268370395577278, 35.201314195393763 ], [ 57.37957807766611, 35.177336330707078 ], [ 57.671239861630283, 34.993936672139682 ], [ 57.705553013161023, 34.930994777786736 ], [ 57.692013788105783, 34.86686432502853 ], [ 57.595172154271495, 34.788807075386217 ], [ 57.198917271115135, 34.557735908285508 ], [ 57.13080773289056, 34.456295071360444 ], [ 57.004820591397163, 34.142567449728006 ], [ 56.9966557148893, 33.973482164370353 ], [ 57.05887413883039, 33.685256863513416 ], [ 57.105796340328823, 33.623581041032196 ], [ 57.304440545394812, 33.603737291442826 ], [ 57.559928826753946, 33.653243313528094 ], [ 57.602716913155007, 33.607716376009932 ], [ 57.642301060445561, 33.54405101214445 ], [ 57.71702518066752, 33.121699530808655 ], [ 57.782240838144162, 32.996797594033694 ], [ 58.04062300025123, 32.871068834059429 ], [ 58.147696568142067, 32.748595689139734 ], [ 58.152554151852598, 32.671882025734874 ], [ 58.10036095639299, 32.568141588163769 ], [ 58.224384393125206, 32.352547512457704 ], [ 58.222213982789071, 32.297563788138291 ], [ 58.173948195053129, 32.146048489246368 ], [ 58.040726353038735, 31.994533189455126 ], [ 58.003932732809346, 31.907200019265474 ], [ 57.901199985590836, 31.771549384496495 ], [ 57.834744093764868, 31.637268175286067 ], [ 56.761631300743886, 32.03008657533519 ], [ 56.634093865639386, 32.049181016990303 ], [ 56.566397738564774, 31.978926906851257 ], [ 56.358451776328366, 31.879036363537352 ], [ 56.287758416617578, 31.815086777932436 ], [ 55.756834750623227, 31.576264146373262 ], [ 55.712289666834636, 31.495183823874356 ], [ 55.684487746312413, 31.110917873960716 ], [ 55.514989048006157, 31.046787421202509 ], [ 55.32637006962301, 31.024773261276948 ], [ 55.116460401726158, 31.043247586207144 ], [ 54.554427525110157, 30.957774767091792 ], [ 54.466474237296211, 30.873361314273211 ], [ 54.420275506209634, 30.797913722740077 ], [ 54.398054640709063, 30.725075792014195 ], [ 54.400638462195275, 30.675466417141422 ], [ 54.515566848231458, 30.450441393055826 ], [ 54.539441359231319, 30.350550848842602 ], [ 54.603830194407919, 30.297349962131022 ], [ 54.591221144440226, 29.973106187400617 ], [ 54.616749301838809, 29.847971707528245 ], [ 54.430197381004291, 29.793220527205563 ], [ 54.227212355265976, 29.882879137362295 ], [ 54.071769646851692, 29.984268297443975 ], [ 54.043140904029485, 30.044161282317305 ], [ 54.007587518149421, 30.263191840231229 ], [ 53.964075962235825, 30.329983628841376 ], [ 53.80460249241105, 30.499068915098348 ], [ 53.638411086002804, 30.755151476559377 ], [ 53.404936964569231, 31.261502996865772 ], [ 53.276469354377184, 31.395086574985442 ], [ 53.125160760160952, 31.51394236874529 ], [ 52.870706007576189, 31.597451483998782 ], [ 52.827091098875087, 31.744419257542688 ], [ 52.824093866238911, 31.813846544482431 ], [ 52.904915806319366, 32.164600328542292 ], [ 52.883728468693846, 32.505199692911503 ], [ 53.060358513834331, 32.576694037399875 ], [ 53.164641554664001, 32.640333563742956 ], [ 53.261069777348325, 32.672011216944099 ], [ 53.334036900182753, 32.67412995043685 ], [ 53.650710076708663, 32.61286754000497 ] ] ] } },
{ "type": "Feature", "properties": { "ISO": "IR-26", "NAME_1": "Qom" }, "geometry": { "type": "Polygon", "coordinates": [ [ [ 51.788911574109818, 34.54197459605075 ], [ 51.451671176683419, 34.469730942728802 ], [ 51.158355747219957, 34.452522691469028 ], [ 51.063064406097453, 34.418235379259329 ], [ 51.013765089587196, 34.357903143914939 ], [ 50.983689405941277, 34.161610216338374 ], [ 51.004670037991843, 34.11838288036563 ], [ 51.032988723350854, 34.105799668819657 ], [ 50.802201776190998, 34.157889513290399 ], [ 50.699262323397477, 34.20488922825524 ], [ 50.441190219652867, 34.224784653788731 ], [ 50.32874230321471, 34.317879747052643 ], [ 50.30445438016551, 34.367463284403016 ], [ 50.30869184805033, 34.408520209140306 ], [ 50.264146763362419, 34.466423651730111 ], [ 50.158313429820907, 34.492106838759582 ], [ 50.060024855162908, 34.577062893038089 ], [ 50.069429966020095, 34.628739325459492 ], [ 50.162757603280738, 34.671992498954637 ], [ 50.173402947587931, 34.692042955018337 ], [ 50.152629022011752, 34.716124172492528 ], [ 50.153972609148582, 34.781494858700739 ], [ 50.21112674290481, 34.819373684547884 ], [ 50.301147088267498, 34.809374295387386 ], [ 50.388170200094635, 34.829915676067571 ], [ 50.447598097873879, 34.862058417262119 ], [ 50.57151818181859, 34.878129787859393 ], [ 50.693681268375826, 34.915956935963777 ], [ 50.723033481609889, 35.107883206244935 ], [ 50.784735141613453, 35.218419093866089 ], [ 51.072159457692806, 35.213251450893722 ], [ 51.31235151570985, 35.153952745222966 ], [ 51.882342564157966, 34.875494290429117 ], [ 51.893194614040169, 34.754002997440352 ], [ 51.866116163929803, 34.66646312077637 ], [ 51.788911574109818, 34.54197459605075 ] ] ] } },
{ "type": "Feature", "properties": { "ISO": "IR-07", "NAME_1": "Tehran" }, "geometry": { "type": "Polygon", "coordinates": [ [ [ 50.784735141613453, 35.218419093866089 ], [ 50.8712414886038, 35.4471906603207 ], [ 50.870828078353099, 35.517031358410463 ], [ 50.798687778717976, 35.604777939750079 ], [ 50.825095940725078, 35.654454591106457 ], [ 50.889640414373616, 35.686726828380415 ], [ 50.942082799381637, 35.739169213388493 ], [ 50.970321006209417, 35.799679657490174 ], [ 51.059069657138934, 35.803713687037032 ], [ 51.075205776225573, 35.916666515247471 ], [ 51.135716220327254, 35.97314292980235 ], [ 51.216396812163111, 35.997347107083328 ], [ 51.297077403998969, 35.989279047989669 ], [ 51.353553818553848, 36.017517255716768 ], [ 51.356896600397761, 36.116271267305137 ], [ 51.465003697062969, 36.064698188570503 ], [ 51.618275995141062, 36.054052843363991 ], [ 51.754391717004125, 36.010980536122815 ], [ 51.856504348396925, 35.921554469962814 ], [ 51.950245395908269, 35.799804796354238 ], [ 52.029206984015048, 35.77091767021426 ], [ 52.108168573021203, 35.767171128744565 ], [ 52.177001580758315, 35.789960435925366 ], [ 52.306812778986512, 35.917394518242418 ], [ 52.398486768949226, 35.976202298397311 ], [ 52.625863072322886, 35.931347154447622 ], [ 52.740274692622961, 35.881014309162993 ], [ 52.816342400881069, 35.86825023046373 ], [ 52.901401807947025, 35.889695950507701 ], [ 52.944810011972436, 35.881556912421559 ], [ 53.0347270036483, 35.831094875927761 ], [ 53.066249628117816, 35.718905341008679 ], [ 53.079375441123659, 35.618136298551292 ], [ 53.047232699929111, 35.528374334707735 ], [ 52.888275994941182, 35.410190335415621 ], [ 52.674955682358814, 35.336189683806822 ], [ 52.594133742278359, 35.338721829348913 ], [ 52.21968631437187, 35.414221095926791 ], [ 51.982078077840981, 35.54431651499516 ], [ 51.922443475386046, 35.54684866053725 ], [ 51.870146926239613, 35.569818833971965 ], [ 51.853403762073924, 35.555452785717478 ], [ 51.821054315304366, 35.403420721988709 ], [ 51.822501255228701, 35.315260727700377 ], [ 51.980631137916646, 35.125246487135655 ], [ 51.968022087948953, 35.063725694285324 ], [ 51.916862420364396, 34.998587551174523 ], [ 51.882342564157966, 34.875494290429117 ], [ 51.31235151570985, 35.153952745222966 ], [ 51.072159457692806, 35.213251450893722 ], [ 50.784735141613453, 35.218419093866089 ] ] ] } },
{ "type": "Feature", "properties": { "ISO": "IR-32", "NAME_1": "Alborz" }, "geometry": { "type": "Polygon", "coordinates": [ [ [ 50.696471795436992, 35.550543525163562 ], [ 50.689753858853464, 35.639246120911707 ], [ 50.650996534762271, 35.676478989813518 ], [ 50.594772576992796, 35.677047431493747 ], [ 50.581956822349468, 35.650072333271567 ], [ 50.620714146440662, 35.60635407178296 ], [ 50.610378858697345, 35.574831448212763 ], [ 50.527696568441854, 35.62911754054204 ], [ 50.285540805663629, 35.666014513558935 ], [ 50.238618605064573, 35.741022853721688 ], [ 50.2302470229817, 35.771925361466231 ], [ 50.242752720161889, 35.81083771428905 ], [ 50.297736443581982, 35.853961697474347 ], [ 50.500204706282148, 35.934266872717956 ], [ 50.534517856913567, 35.960802721368452 ], [ 50.625881789413029, 36.164769599037754 ], [ 50.47188602092308, 36.239648748890602 ], [ 50.420829706126028, 36.296389472396186 ], [ 50.449665155422565, 36.331684474958536 ], [ 50.563146599735774, 36.339177557897983 ], [ 50.966222772263109, 36.292203681354806 ], [ 51.029888137027854, 36.27179149008515 ], [ 51.086008742009824, 36.222259630477538 ], [ 51.127970005211523, 36.20745433175199 ], [ 51.291991001283634, 36.178102118517927 ], [ 51.356896600397761, 36.116271267305137 ], [ 51.353553818553848, 36.017517255716768 ], [ 51.297077403998969, 35.989279047989669 ], [ 51.216396812163111, 35.997347107083328 ], [ 51.135716220327254, 35.97314292980235 ], [ 51.075205776225573, 35.916666515247471 ], [ 51.059069657138934, 35.803713687037032 ], [ 50.970321006209417, 35.799679657490174 ], [ 50.942082799381637, 35.739169213388493 ], [ 50.889640414373616, 35.686726828380415 ], [ 50.825095940725078, 35.654454591106457 ], [ 50.798687778717976, 35.604777939750079 ], [ 50.75269575410573, 35.601341458441539 ], [ 50.724893832684188, 35.555168564877363 ], [ 50.696471795436992, 35.550543525163562 ] ] ] } }
{ "type": "Feature", "properties": { "ISO": "IR-30", "NAME_1": "Alborz" }, "geometry": { "type": "Polygon", "coordinates": [ [ [ 50.696471795436992, 35.550543525163562 ], [ 50.689753858853464, 35.639246120911707 ], [ 50.650996534762271, 35.676478989813518 ], [ 50.594772576992796, 35.677047431493747 ], [ 50.581956822349468, 35.650072333271567 ], [ 50.620714146440662, 35.60635407178296 ], [ 50.610378858697345, 35.574831448212763 ], [ 50.527696568441854, 35.62911754054204 ], [ 50.285540805663629, 35.666014513558935 ], [ 50.238618605064573, 35.741022853721688 ], [ 50.2302470229817, 35.771925361466231 ], [ 50.242752720161889, 35.81083771428905 ], [ 50.297736443581982, 35.853961697474347 ], [ 50.500204706282148, 35.934266872717956 ], [ 50.534517856913567, 35.960802721368452 ], [ 50.625881789413029, 36.164769599037754 ], [ 50.47188602092308, 36.239648748890602 ], [ 50.420829706126028, 36.296389472396186 ], [ 50.449665155422565, 36.331684474958536 ], [ 50.563146599735774, 36.339177557897983 ], [ 50.966222772263109, 36.292203681354806 ], [ 51.029888137027854, 36.27179149008515 ], [ 51.086008742009824, 36.222259630477538 ], [ 51.127970005211523, 36.20745433175199 ], [ 51.291991001283634, 36.178102118517927 ], [ 51.356896600397761, 36.116271267305137 ], [ 51.353553818553848, 36.017517255716768 ], [ 51.297077403998969, 35.989279047989669 ], [ 51.216396812163111, 35.997347107083328 ], [ 51.135716220327254, 35.97314292980235 ], [ 51.075205776225573, 35.916666515247471 ], [ 51.059069657138934, 35.803713687037032 ], [ 50.970321006209417, 35.799679657490174 ], [ 50.942082799381637, 35.739169213388493 ], [ 50.889640414373616, 35.686726828380415 ], [ 50.825095940725078, 35.654454591106457 ], [ 50.798687778717976, 35.604777939750079 ], [ 50.75269575410573, 35.601341458441539 ], [ 50.724893832684188, 35.555168564877363 ], [ 50.696471795436992, 35.550543525163562 ] ] ] } }
]
}
@@ -1,58 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import fs from 'fs';
import path from 'path';
import { countryOptions } from '../src/countries';
type ItalyFeature = { properties: { ISO: string; NAME_1: string } };
test('countryOptions includes labeled entries for the Italy region variants', () => {
expect(countryOptions).toContainEqual(['italy_regions', 'Italy (regions)']);
expect(countryOptions).toContainEqual([
'italy_regions_and_autonomous_provinces',
'Italy (regions and autonomous provinces)',
]);
});
test('italy_regions_and_autonomous_provinces geojson has the expected shape', () => {
// jest maps `.geojson` imports to an empty object mock, so the file is
// read from disk directly to verify its actual shape.
const geojsonPath = path.join(
__dirname,
'../src/countries/italy_regions_and_autonomous_provinces.geojson',
);
const geojson = JSON.parse(fs.readFileSync(geojsonPath, 'utf-8'));
const features: ItalyFeature[] = geojson.features;
expect(features).toHaveLength(21);
features.forEach(feature => {
expect(feature.properties).toEqual(
expect.objectContaining({
ISO: expect.any(String),
NAME_1: expect.any(String),
}),
);
});
const isoCodes = features.map(feature => feature.properties.ISO);
expect(new Set(isoCodes).size).toBe(isoCodes.length);
expect(isoCodes).toContain('IT-BZ');
expect(isoCodes).toContain('IT-TN');
expect(isoCodes).not.toContain('IT-32');
});
@@ -1,69 +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 fs from 'fs';
import path from 'path';
type Feature = {
properties: {
ISO: string;
NAME_1: string;
};
};
// `.geojson` imports are mocked out to an empty object by the Jest module
// mapper (see jest.config.js), so the file is read from disk directly to
// exercise the real, committed data.
function loadIranGeoJson(): { features: Feature[] } {
const filePath = path.join(__dirname, '../src/countries/iran.geojson');
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
}
test('every Iranian province has its own distinct ISO 3166-2 code', () => {
const { features } = loadIranGeoJson();
// Pin the feature count too, so dropping a province other than
// Tehran/Alborz (which would still leave every remaining ISO code
// distinct) doesn't slip past the checks below.
expect(features.length).toBe(31);
// Sanity check: every province name in this file is unique, so a
// duplicate ISO code below can only mean two different provinces were
// mistakenly assigned the same code (as opposed to one province being
// split across multiple polygon features).
const names = features.map(feature => feature.properties.NAME_1);
expect(new Set(names).size).toBe(names.length);
const isoByName = new Map(
features.map(feature => [
feature.properties.NAME_1,
feature.properties.ISO,
]),
);
const isoCodes = features.map(feature => feature.properties.ISO);
expect(new Set(isoCodes).size).toBe(isoCodes.length);
// Tehran and Alborz were split into separate provinces in 2010, but the
// GeoJSON still assigned both the same ISO code (IR-07), which used to
// make it impossible to distinguish them on the Country Map chart. Alborz
// now uses its pre-2020 ISO 3166-2 code, IR-32.
expect(isoByName.get('Tehran')).toBe('IR-07');
expect(isoByName.get('Alborz')).toBe('IR-32');
});
@@ -34,7 +34,7 @@ import {
getTotalsMetrics,
isTimeComparison,
timeCompareOperator,
toTotalsAggregate,
TotalsAggregate,
} from '@superset-ui/chart-controls';
import { isEmpty } from 'lodash-es';
import { TableChartFormData } from './types';
@@ -349,7 +349,8 @@ export const buildQuery: BuildQuery<TableChartFormData> = (
formData.show_totals &&
queryMode === QueryMode.Aggregate
) {
const totalsAggregate = toTotalsAggregate(formData.totals_aggregate);
const totalsAggregate: TotalsAggregate =
formData.totals_aggregate === 'AVG' ? 'AVG' : 'SUM';
extraQueries.push({
...queryObject,
columns: [],
@@ -475,18 +475,14 @@ const config: ControlPanelConfig = {
type: 'SelectControl',
label: t('Summary aggregation'),
description: t(
'Aggregation used for the summary row. By default each metric ' +
'keeps its own aggregation; Sum and Average override it for ' +
'the summary row only. The override applies to simple ' +
'metrics (a metric built from custom SQL always keeps its ' +
'own aggregation). Overriding a count or a distinct count ' +
'sums the counted column instead, which fails outright on a ' +
'non-numeric column.',
'Aggregation used for the summary row, independent of each ' +
"metric's own aggregation. Only applies to simple metrics " +
'(a metric built from custom SQL keeps its own aggregation ' +
'in the summary row).',
),
default: 'ORIGINAL',
default: 'SUM',
clearable: false,
choices: [
['ORIGINAL', t("Each metric's own")],
['SUM', t('Sum')],
['AVG', t('Average')],
],
@@ -340,7 +340,7 @@ describe('plugin-chart-table', () => {
label: 'sum_sales',
};
test("defaults to each metric's own aggregate", () => {
test('defaults the totals query metric aggregate to SUM', () => {
const { queries } = buildQueryCached({
...basicFormData,
query_mode: QueryMode.Aggregate,
@@ -350,28 +350,9 @@ describe('plugin-chart-table', () => {
});
expect(queries).toHaveLength(2);
expect(queries[1].metrics).toEqual([simpleMetric]);
});
test('keeps COUNT_DISTINCT in the summary row by default', () => {
// Overriding this to SUM sums the counted column instead of counting
// it, which is meaningless on a numeric id and is rejected outright by
// the database on a non-numeric one (e.g. a uuid).
const countDistinctMetric = {
expressionType: 'SIMPLE' as const,
column: { column_name: 'contract_id' },
aggregate: 'COUNT_DISTINCT' as const,
label: 'contracts',
};
const { queries } = buildQueryCached({
...basicFormData,
query_mode: QueryMode.Aggregate,
metrics: [countDistinctMetric],
groupby: ['category'],
show_totals: true,
});
expect(queries[1].metrics).toEqual([countDistinctMetric]);
expect(queries[1].metrics).toEqual([
{ ...simpleMetric, aggregate: 'SUM' },
]);
});
test('overrides simple metric aggregate with totals_aggregate for the summary query only', () => {
@@ -669,11 +669,11 @@ describe('UploadDataModal Collapse Tabs', () => {
useRedux: true,
});
const generalInfoTab = screen.getByRole('tab', {
name: /General information/i,
name: /expanded General information/i,
});
expect(generalInfoTab).toHaveAttribute('aria-expanded', 'true');
const fileSettingsTab = screen.getByRole('tab', {
name: /File settings/i,
name: /collapsed File settings/i,
});
await userEvent.click(fileSettingsTab);
await waitFor(() => {
@@ -689,11 +689,11 @@ describe('UploadDataModal Collapse Tabs', () => {
useRedux: true,
});
const generalInfoTab = screen.getByRole('tab', {
name: /General information/i,
name: /expanded General information/i,
});
expect(generalInfoTab).toHaveAttribute('aria-expanded', 'true');
const fileSettingsTab = screen.getByRole('tab', {
name: /File settings/i,
name: /collapsed File settings/i,
});
await userEvent.click(fileSettingsTab);
await waitFor(() => {
@@ -709,11 +709,11 @@ describe('UploadDataModal Collapse Tabs', () => {
useRedux: true,
});
const generalInfoTab = screen.getByRole('tab', {
name: /General information/i,
name: /expanded General information/i,
});
expect(generalInfoTab).toHaveAttribute('aria-expanded', 'true');
const fileSettingsTab = screen.getByRole('tab', {
name: /File settings/i,
name: /collapsed File settings/i,
});
await userEvent.click(fileSettingsTab);
await waitFor(() => {
@@ -223,7 +223,7 @@ describe('ChartPage', () => {
window.history.pushState(
{},
'',
`/explore/?${URL_PARAMS.dashboardPageId.name}=${dashboardPageId}`,
`/?${URL_PARAMS.dashboardPageId.name}=${dashboardPageId}`,
);
const { getByTestId } = render(<ChartPage />, {
useRouter: true,
@@ -261,13 +261,13 @@ describe('ChartPage', () => {
window.history.pushState(
{},
'',
`/explore/?${URL_PARAMS.dashboardPageId.name}=${dashboardPageId}`,
`/?${URL_PARAMS.dashboardPageId.name}=${dashboardPageId}`,
);
const { getByTestId } = render(
<>
<Link
to={{
pathname: '/explore/',
pathname: '/',
search: `?${URL_PARAMS.dashboardPageId.name}=${dashboardPageId}`,
state: { saveAction: 'overwrite' },
}}
@@ -324,7 +324,7 @@ describe('ChartPage', () => {
});
render(
<>
<Link to="/explore/?slice_id=99">Navigate away</Link>
<Link to="/?slice_id=99">Navigate away</Link>
<ChartPage />
</>,
{
@@ -382,7 +382,7 @@ describe('ChartPage', () => {
<>
<Link
to={{
pathname: '/explore/',
pathname: '/',
search: `?${URL_PARAMS.sliceId.name}=${formData.slice_id}`,
state: toChartStateHistoryState({
...formData,
@@ -392,7 +392,7 @@ describe('ChartPage', () => {
>
Change the chart
</Link>
<Link to="/explore/?slice_id=99">Navigate away</Link>
<Link to="/?slice_id=99">Navigate away</Link>
<ChartPage />
</>,
{ useRouter: true, useRedux: true, useDnd: true },
@@ -433,14 +433,14 @@ describe('ChartPage', () => {
<>
<Link
to={{
pathname: '/explore/',
pathname: '/',
search: `?${URL_PARAMS.sliceId.name}=99`,
state: toChartStateHistoryState({ ...formData, slice_id: 99 }),
}}
>
Another chart
</Link>
<Link to="/explore/?slice_id=100">Navigate away</Link>
<Link to="/?slice_id=100">Navigate away</Link>
<ChartPage />
</>,
{ useRouter: true, useRedux: true, useDnd: true },
@@ -477,14 +477,14 @@ describe('ChartPage', () => {
<>
<Link
to={{
pathname: '/explore/',
pathname: '/',
search: `?${URL_PARAMS.sliceId.name}=${formData.slice_id}`,
state: toChartStateHistoryState(formData),
}}
>
Change the chart
</Link>
<Link to="/explore/?slice_id=99">Navigate away</Link>
<Link to="/?slice_id=99">Navigate away</Link>
<ChartPage />
</>,
{ useRouter: true, useRedux: true, useDnd: true, store },
@@ -507,32 +507,6 @@ describe('ChartPage', () => {
window.history.back();
await waitFor(() => expect(loads()).toBe(1));
});
test('does not re-fetch explore data when navigating to a dashboard', async () => {
const exploreApiRoute = 'glob:*/api/v1/explore/*';
const exploreFormData = getExploreFormData({
viz_type: VizType.Table,
show_cell_bars: true,
});
fetchMock.get(exploreApiRoute, {
result: { dataset: { id: 1 }, form_data: exploreFormData },
});
render(
<>
<Link to="/dashboard/5/">Go to dashboard</Link>
<ChartPage />
</>,
{ useRouter: true, useRedux: true, useDnd: true },
);
await waitFor(() =>
expect(fetchMock.callHistory.calls(exploreApiRoute).length).toBe(1),
);
fireEvent.click(screen.getByText('Go to dashboard'));
await new Promise(resolve => setTimeout(resolve, 0));
expect(fetchMock.callHistory.calls(exploreApiRoute).length).toBe(1);
});
});
test('does not show error toast when request is aborted on unmount', async () => {
@@ -585,7 +559,7 @@ describe('ChartPage', () => {
render(
<>
<Link to="/explore/?slice_id=99">Navigate</Link>
<Link to="/?slice_id=99">Navigate</Link>
<ChartPage />
</>,
{
@@ -56,11 +56,6 @@ const isValidResult = (rv: JsonObject): boolean =>
const hasDatasetId = (rv: JsonObject): boolean =>
isDefined(rv?.result?.dataset?.id);
const EXPLORE_ROUTE_PREFIX = '/explore/';
const isExploreRoute = (pathname: string): boolean =>
pathname.startsWith(EXPLORE_ROUTE_PREFIX);
const fetchExploreData = async (
exploreUrlParams: URLSearchParams,
signal?: AbortSignal,
@@ -317,8 +312,6 @@ export default function ExplorePage() {
// Other REPLACE: ignored (URL sync from updateHistory).
// Entries holding a chart state of the loaded chart are skipped: Explore
// pushed them itself, and ExploreViewContainer restores a popped one in place.
// Navigations that leave Explore must not trigger a re-fetch while the page
// is unmounting, as the destination's URL params are not chart params.
useEffect(() => {
const unlisten = history.listen((loc: Location, action: Action) => {
const saveAction = (loc.state as Record<string, unknown>)?.saveAction as
@@ -333,9 +326,6 @@ export default function ExplorePage() {
return;
}
}
if (!isExploreRoute(loc.pathname)) {
return;
}
if (action === 'PUSH' || action === 'POP') {
setIsLoaded(false);
loadExploreData(loc, saveAction);
@@ -182,7 +182,7 @@ test('With sql role - renders all panels on the page on page load', async () =>
test('With sql role - renders distinct recent activities', async () => {
await renderWelcome();
const recentPanel = screen.getByRole('button', { name: 'Recents' });
const recentPanel = screen.getByRole('button', { name: 'collapsed Recents' });
userEvent.click(recentPanel);
await waitFor(() =>
expect(
+12
View File
@@ -32,11 +32,13 @@ from superset.commands.chart.exceptions import (
DashboardsForbiddenError,
DashboardsNotFoundValidationError,
)
from superset.commands.exceptions import DatasourceTypeInvalidError
from superset.commands.utils import get_datasource_by_id, populate_subjects
from superset.daos.chart import ChartDAO
from superset.daos.dashboard import DashboardDAO
from superset.exceptions import SupersetSecurityException
from superset.utils import json
from superset.utils.core import DatasourceType
from superset.utils.decorators import on_error, transaction
logger = logging.getLogger(__name__)
@@ -71,6 +73,16 @@ class CreateChartCommand(CreateMixin, BaseCommand):
# Validate/Populate datasource
try:
# Slice.datasource only ever resolves the ``table`` relationship
# (see Slice.datasource in superset/models/slice.py), so a chart
# pointed at any other datasource_type would "create"
# successfully but could never actually render. Reject those
# up front instead of failing later -- either at this lookup
# (SavedQuery/Query have no ``.name`` attribute, so accessing it
# below raises an unhandled AttributeError) or silently, by
# producing a permanently broken chart.
if datasource_type != DatasourceType.TABLE:
raise DatasourceTypeInvalidError()
datasource = get_datasource_by_id(datasource_id, datasource_type)
self._properties["datasource_name"] = datasource.name
security_manager.raise_for_access(datasource=datasource)
+17 -1
View File
@@ -35,6 +35,7 @@ from superset.commands.chart.exceptions import (
DashboardsNotFoundValidationError,
DatasourceTypeUpdateRequiredValidationError,
)
from superset.commands.exceptions import DatasourceTypeInvalidError
from superset.commands.utils import (
compute_subjects,
get_datasource_by_id,
@@ -49,6 +50,7 @@ from superset.models.dashboard import Dashboard
from superset.models.slice import Slice
from superset.tags.models import ObjectType
from superset.utils import json
from superset.utils.core import DatasourceType
from superset.utils.decorators import on_error, transaction
from superset.versioning.changes.normalization import (
register_matching_normalization_context,
@@ -221,8 +223,22 @@ class UpdateChartCommand(UpdateMixin, BaseCommand):
exceptions.append(ex)
# Validate/Populate datasource
if datasource_id is not None:
# An empty datasource_type was already flagged above via
# DatasourceTypeUpdateRequiredValidationError; skip this block so
# we don't clobber that message with DatasourceTypeInvalidError.
if datasource_id is not None and datasource_type:
try:
# Slice.datasource only ever resolves the ``table``
# relationship (see Slice.datasource in
# superset/models/slice.py), so repointing a chart at any
# other datasource_type would "succeed" but leave the chart
# permanently unable to render. Reject those up front
# instead of failing later -- either at this lookup
# (SavedQuery/Query have no ``.name`` attribute, so
# accessing it below raises an unhandled AttributeError) or
# silently.
if datasource_type != DatasourceType.TABLE:
raise DatasourceTypeInvalidError()
datasource = get_datasource_by_id(datasource_id, datasource_type)
self._properties["datasource_name"] = datasource.name
security_manager.raise_for_access(datasource=datasource)
+36
View File
@@ -52,6 +52,42 @@ class DatabaseRequiredFieldValidationError(ValidationError):
)
class DatabaseExtraJSONValidationError(ValidationError):
"""
Marshmallow validation error for database encrypted extra must be a valid JSON
"""
def __init__(self, json_error: str = "") -> None:
super().__init__(
[
_(
"Field cannot be decoded by JSON. %(json_error)s",
json_error=json_error,
)
],
field_name="extra",
)
class DatabaseExtraValidationError(ValidationError):
"""
Marshmallow validation error for database encrypted extra must be a valid JSON
"""
def __init__(self, key: str = "") -> None:
super().__init__(
[
_(
"The metadata_params in Extra field "
"is not configured correctly. The key "
"%{key}s is invalid.",
key=key,
)
],
field_name="extra",
)
class DatabaseConnectionSyncPermissionsError(CommandException):
status = 500
message = _("Unable to sync permissions for this database connection.")
+23 -99
View File
@@ -24,10 +24,8 @@ purge rolls back. The record is written ``pending`` *before* the purge and
flipped to ``confirmed`` *after* it commits, so a crash leaves at most a
``pending`` row, never a missing one. ``pending`` rows are reconciled on the
next run. Completed records are immutable. Consecutive scheduled evaluations
that remain blocked for the same reason may discard only their current
redundant provisional row a reason change retains one new row carrying the
new code; force-purge and other meaningful outcomes are retained
independently.
that remain blocked may discard only their current redundant provisional row;
force-purge and other meaningful outcomes are retained independently.
The dedicated ``purge_audit_log`` table is content-free (no name or PII; only
action, actor, UTC time, entity type, UUID, and affected referrers) and is never
@@ -89,10 +87,6 @@ class _AuditRecoverySnapshot:
entity_type: str
entity_uuid: str | None
created_on: datetime
# Sourced from the finalization call's reason argument, never from the
# row: pending rows are reason-less by design, so reading the row here
# would silently record NULL.
reason: str | None
def _utc_now() -> datetime:
@@ -142,18 +136,8 @@ def write_ahead(
session.close()
def finalize(
record_id: UUID | None,
status: str,
*,
reason: str | None = None,
**details: Any,
) -> None:
"""Finalize a pending attempt on the dedicated audit session.
``reason`` is persisted only for blocked outcomes; the audit records a
cause for a purge that did not happen, never for one that did.
"""
def finalize(record_id: UUID | None, status: str, **details: Any) -> None:
"""Finalize a pending attempt on the dedicated audit session."""
if record_id is None:
return
session = _dedicated_session()
@@ -164,8 +148,6 @@ def finalize(
referrers = details.get("affected_referrers")
if referrers:
values["affected_referrers"] = ",".join(referrers)
if reason is not None and status == STATUS_BLOCKED:
values["reason"] = reason
removed_dashboard_slices = details.get("removed_dashboard_slices")
if removed_dashboard_slices is not None:
values["removed_dashboard_slices"] = removed_dashboard_slices
@@ -210,21 +192,12 @@ def fail(record_id: UUID | None) -> None:
finalize(record_id, STATUS_FAILED)
def block(record_id: UUID | None, reason: str | None) -> None:
"""Mark an attempt blocked by ordinary deletion policy.
``reason`` is a stable machine code from the closed ``REASON_*``
vocabulary in :mod:`superset.commands.deletion_retention.purge_policy`.
It is required by signature (every blocked outcome has a classified
cause); ``None`` is tolerated defensively so a threading gap can never
block a purge, and leaves the persisted reason NULL.
"""
finalize(record_id, STATUS_BLOCKED, reason=reason)
def block(record_id: UUID | None) -> None:
"""Mark an attempt blocked by ordinary deletion policy."""
finalize(record_id, STATUS_BLOCKED)
def _capture_recovery_snapshot(
record: PurgeAuditLog, reason: str | None
) -> _AuditRecoverySnapshot:
def _capture_recovery_snapshot(record: PurgeAuditLog) -> _AuditRecoverySnapshot:
"""Capture the content-free fields needed for fail-safe recovery."""
return _AuditRecoverySnapshot(
id=cast(UUID, record.id),
@@ -232,37 +205,26 @@ def _capture_recovery_snapshot(
entity_type=str(record.entity_type),
entity_uuid=record.entity_uuid,
created_on=cast(datetime, record.created_on),
reason=reason,
)
def _retention_predecessor(
session: Session, current: PurgeAuditLog
) -> PurgeAuditLog | None:
"""Return the latest row that could unambiguously precede ``current``.
The latest same-entity retention row by ``created_on`` deliberately not
bounded by ``current.created_on``. If another visible row has a later
timestamp, it surfaces here so the caller's strictly-older check retains
the current row. Timestamps provide database ordering for this predicate,
not causal ordering across workers.
"""
"""Return the latest row that could unambiguously precede ``current``."""
predecessor: PurgeAuditLog | None = session.execute(
sa.select(PurgeAuditLog)
.where(PurgeAuditLog.entity_uuid == current.entity_uuid)
.where(PurgeAuditLog.entity_type == current.entity_type)
.where(PurgeAuditLog.trigger == TRIGGER_RETENTION)
.where(PurgeAuditLog.created_on <= current.created_on)
.where(PurgeAuditLog.id != current.id)
.order_by(PurgeAuditLog.created_on.desc())
.limit(1)
).scalar_one_or_none()
if predecessor is None:
return predecessor
# A timestamp-tied row differing in status OR reason makes the
# predecessor ambiguous. ``is_distinct_from`` keeps the reason
# comparison NULL-safe on all supported dialects (reason is nullable;
# status is not).
tied_mixed_exists: bool = session.execute(
tied_mixed_status_exists: bool = session.execute(
sa.select(
sa.exists().where(
PurgeAuditLog.entity_uuid == current.entity_uuid,
@@ -270,43 +232,23 @@ def _retention_predecessor(
PurgeAuditLog.trigger == TRIGGER_RETENTION,
PurgeAuditLog.created_on == predecessor.created_on,
PurgeAuditLog.id != current.id,
sa.or_(
PurgeAuditLog.status != predecessor.status,
PurgeAuditLog.reason.is_distinct_from(predecessor.reason),
),
PurgeAuditLog.status != predecessor.status,
)
)
).scalar_one()
if tied_mixed_exists:
if tied_mixed_status_exists:
return None
return predecessor
def _suppress_redundant_block(
session: Session,
current: PurgeAuditLog,
predecessor: PurgeAuditLog | None,
reason: str | None,
session: Session, current: PurgeAuditLog, predecessor: PurgeAuditLog | None
) -> bool:
"""Delete a pending row only against a strictly older same-reason block."""
if not reason:
# Fail safe on a threading gap: a missing current code must retain
# the row (and be visible), never silently revive the status-only
# predicate.
logger.warning(
"deletion_retention: blocked audit row %s has no reason code; "
"refusing suppression",
current.id,
)
return False
"""Delete only a pending row with a strictly older blocked predecessor."""
if (
predecessor is None
or predecessor.created_on >= current.created_on
or predecessor.status != STATUS_BLOCKED
# A reason-less (pre-feature) predecessor never matches: the first
# post-upgrade block of a long-blocked entity is retained once and
# becomes the new suppression anchor.
or predecessor.reason != reason
):
return False
deleted_rows: int | None = session.execute(
@@ -322,7 +264,7 @@ def _suppress_redundant_block(
return deleted_rows == 1
def _retain_blocked(session: Session, record_id: UUID, reason: str | None) -> None:
def _retain_blocked(session: Session, record_id: UUID) -> None:
"""Conditionally retain the current provisional row as blocked."""
session.execute(
sa.update(PurgeAuditLog.__table__)
@@ -330,7 +272,7 @@ def _retain_blocked(session: Session, record_id: UUID, reason: str | None) -> No
PurgeAuditLog.__table__.c.id == record_id,
PurgeAuditLog.__table__.c.status == STATUS_PENDING,
)
.values(status=STATUS_BLOCKED, removed_dashboard_slices=0, reason=reason)
.values(status=STATUS_BLOCKED, removed_dashboard_slices=0)
)
@@ -343,11 +285,7 @@ def _recover_retention_blocked(
current: PurgeAuditLog | None = recovery_session.get(PurgeAuditLog, record_id)
if current is not None:
if current.status == STATUS_PENDING:
_retain_blocked(
recovery_session,
record_id,
snapshot.reason if snapshot else None,
)
_retain_blocked(recovery_session, record_id)
recovery_session.commit()
return "fallback"
if snapshot is None:
@@ -362,7 +300,6 @@ def _recover_retention_blocked(
entity_uuid=snapshot.entity_uuid,
removed_dashboard_slices=0,
created_on=snapshot.created_on,
reason=snapshot.reason,
)
)
recovery_session.commit()
@@ -382,14 +319,9 @@ def _recover_retention_blocked(
def finalize_retention_blocked(
record_id: UUID | None, reason: str | None
record_id: UUID | None,
) -> RetentionBlockedDisposition:
"""Finalize a scheduled blocker, suppressing only proven redundant evidence.
``reason`` is the stable machine code for the block (see
:func:`block`); it is persisted on retained rows and captured in the
snapshot used by the crash-recovery path.
"""
"""Finalize a scheduled blocker, suppressing only proven redundant evidence."""
if record_id is None:
return "fallback"
session: Session = _dedicated_session()
@@ -398,17 +330,15 @@ def finalize_retention_blocked(
current: PurgeAuditLog | None = session.get(PurgeAuditLog, record_id)
if current is None:
return "fallback"
snapshot = _capture_recovery_snapshot(current, reason)
snapshot = _capture_recovery_snapshot(current)
if current.status != STATUS_PENDING or current.trigger != TRIGGER_RETENTION:
return "retained"
predecessor: PurgeAuditLog | None = None
if current.entity_uuid is not None:
predecessor = _retention_predecessor(session, current)
suppressed: bool = _suppress_redundant_block(
session, current, predecessor, reason
)
suppressed: bool = _suppress_redundant_block(session, current, predecessor)
if not suppressed:
_retain_blocked(session, record_id, reason)
_retain_blocked(session, record_id)
session.commit()
return "suppressed" if suppressed else "retained"
except SQLAlchemyError:
@@ -453,12 +383,6 @@ def reconcile_pending(stale_before: datetime | None = None) -> dict[str, int]:
``confirmed``. A surviving or unresolvable entity means the attempt did
not durably purge it and is finalized as failed; normal selection may
retry.
An attempt that had already decided ``blocked`` when its worker died is
indistinguishable here from any other stalled attempt, so it reconciles
as failed with no reason: pending rows carry no reason by design, and
inventing one would assert evidence this process never witnessed. The
next scheduled attempt re-anchors the entity with its real code.
"""
cutoff = stale_before or _utc_now() - _PENDING_STALE_AFTER
reconciled = absent = failed = 0
@@ -183,7 +183,7 @@ class ForcePurgeCommand:
removed_dashboard_slices=result.removed_dashboard_slices,
)
elif result.blocked_reason is not None:
audit.block(record_id, result.blocker.code if result.blocker else None)
audit.block(record_id)
else:
audit.fail(record_id)
if result.purged:
@@ -53,11 +53,9 @@ from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from superset.commands.deletion_retention.purge_policy import (
BlockerReason,
get_purge_policy,
PurgeBlockedError,
PurgeEntityPolicy,
REASON_CASCADE_INTEGRITY_FAILURE,
)
logger: logging.Logger = logging.getLogger(__name__)
@@ -146,17 +144,7 @@ class CascadeResult:
dangling_chart_uuids: list[str] = field(default_factory=list)
removed_dashboard_slices: int = 0
version_rows_removed: int = 0
blocker: BlockerReason | None = None
@property
def blocked_reason(self) -> str | None:
"""Return the operator-facing blocker phrase, if the purge was blocked."""
return self.blocker.phrase if self.blocker else None
@property
def blocked_reason_code(self) -> str | None:
"""Return the stable audit code, if the purge was blocked."""
return self.blocker.code if self.blocker else None
blocked_reason: str | None = None
class PurgeRaceLostError(Exception):
@@ -279,21 +267,20 @@ def cascade_hard_delete(
purged=False,
entity_type=entity_type,
entity_uuid=uuid,
blocker=ex.reason,
blocked_reason=str(ex),
)
except IntegrityError as ex:
# Not a policy decision: a database integrity constraint failed.
# Not a policy decision: a restrictive FK the cascade did not handle.
# Two audiences, two messages. The curated reason goes to the caller
# (and from there into a user toast), because raw driver text carries
# the failing SQL and bind parameters. The constraint detail goes to
# the log at WARNING, because an entity permanently unpurgeable after
# an integrity failure represents a cascade defect someone has to be
# able to diagnose. The stable audit code identifies this as an unexpected
# cascade failure rather than intended policy behavior without
# claiming which kind of constraint the database reported.
# the log at WARNING, because an entity permanently unpurgeable via an
# unknown FK is a cascade-coverage bug someone has to be able to
# diagnose -- reported at INFO as a policy block, it read as intended
# behaviour.
logger.warning(
"deletion_retention: %s id=%s purge failed on a database "
"integrity constraint: %s",
"deletion_retention: %s id=%s purge failed on a restrictive "
"foreign key the cascade does not handle: %s",
entity_type,
entity_id,
ex,
@@ -302,10 +289,7 @@ def cascade_hard_delete(
purged=False,
entity_type=entity_type,
entity_uuid=uuid,
blocker=BlockerReason(
REASON_CASCADE_INTEGRITY_FAILURE,
"cascade blocked by a database integrity constraint",
),
blocked_reason="blocked by database references",
)
return CascadeResult(
@@ -24,7 +24,7 @@ from dataclasses import dataclass
from enum import Enum
from functools import lru_cache
from types import MappingProxyType
from typing import Any, cast, NamedTuple
from typing import Any, cast
import sqlalchemy as sa
from sqlalchemy.orm import Mapper, Session
@@ -36,44 +36,10 @@ from superset.utils.sqlalchemy_events import (
logger: logging.Logger = logging.getLogger(__name__)
# Stable machine-readable reason codes persisted on purge audit records.
# The values are frozen identifiers pinned by a golden-set test: they equal
# the related-table names at introduction by coincidence, never by derivation,
# so a physical table rename changes only the blocker mapping's key and
# leaves the persisted code untouched — audit history and the suppression
# predicate compare these literals.
REASON_REPORT_SCHEDULE: str = "report_schedule"
REASON_USER_ATTRIBUTE: str = "user_attribute"
REASON_CASCADE_INTEGRITY_FAILURE: str = "cascade_integrity_failure"
ALL_REASON_CODES: frozenset[str] = frozenset(
{
REASON_REPORT_SCHEDULE,
REASON_USER_ATTRIBUTE,
REASON_CASCADE_INTEGRITY_FAILURE,
}
)
class BlockerReason(NamedTuple):
"""One blocker's persisted audit code paired with its operator phrase."""
code: str
phrase: str
class PurgeBlockedError(Exception):
"""Raised when ordinary deletion policy forbids purging an entity."""
def __init__(self, reason: BlockerReason) -> None:
super().__init__(reason.phrase)
self.reason: BlockerReason = reason
@property
def reason_code(self) -> str:
"""Return the stable machine-readable blocker code."""
return self.reason.code
class DependencyClassification(str, Enum):
"""Describe how purge treats a persistence dependency."""
@@ -140,21 +106,11 @@ class DependencyPolicy:
key: DependencyKey
classification: DependencyClassification
phase: ExecutionPhase | None = None
blocker: BlockerReason | None = None
blocked_reason: str | None = None
optional_listener: bool = False
listener_action: ListenerAction | None = None
version_column: str | None = None
@property
def blocked_reason(self) -> str | None:
"""Return the operator-facing blocker phrase, if this policy blocks."""
return self.blocker.phrase if self.blocker else None
@property
def blocked_reason_code(self) -> str | None:
"""Return the stable audit code, if this policy blocks."""
return self.blocker.code if self.blocker else None
@dataclass(frozen=True)
class PurgeEntityPolicy:
@@ -460,7 +416,7 @@ def purge_policy_registry() -> Mapping[type[Any], PurgeEntityPolicy]:
keys: tuple[DependencyKey, ...],
classifications: tuple[DependencyClassification, ...],
synthetic: tuple[DependencyPolicy, ...],
blocked_reasons: Mapping[str, BlockerReason] = MappingProxyType({}),
blocked_reasons: Mapping[str, str] = MappingProxyType({}),
version_columns: Mapping[str, str] = MappingProxyType({}),
) -> tuple[DependencyPolicy, ...]:
phases: dict[DependencyClassification, ExecutionPhase | None] = {
@@ -472,22 +428,15 @@ def purge_policy_registry() -> Mapping[type[Any], PurgeEntityPolicy]:
}
if len(keys) != len(classifications):
raise ValueError("Every dependency key requires one classification")
def declare(
key: DependencyKey, classification: DependencyClassification
) -> DependencyPolicy:
blocker: BlockerReason | None = blocked_reasons.get(key.related_table)
return DependencyPolicy(
key,
classification,
phases[classification],
blocker=blocker,
version_column=version_columns.get(key.related_table),
)
return (
tuple(
declare(key, classification)
DependencyPolicy(
key,
classification,
phases[classification],
blocked_reason=blocked_reasons.get(key.related_table),
version_column=version_columns.get(key.related_table),
)
for key, classification in zip(keys, classifications, strict=True)
)
+ synthetic
@@ -590,12 +539,7 @@ def purge_policy_registry() -> Mapping[type[Any], PurgeEntityPolicy]:
DependencyClassification.PRESERVE,
),
(tag_cleanup, chart_membership_versions),
# Keyed by related table; the audit code is declared, not derived.
{
"report_schedule": BlockerReason(
REASON_REPORT_SCHEDULE, "associated alerts or reports exist"
)
},
{"report_schedule": "associated alerts or reports exist"},
{"slices_version": "id"},
),
validate=validate_deletion_allowed,
@@ -743,16 +687,10 @@ def purge_policy_registry() -> Mapping[type[Any], PurgeEntityPolicy]:
DependencyClassification.PRESERVE,
),
(tag_cleanup, dashboard_membership_versions),
# Keyed by related table; the audit code is declared, not derived.
# Declaration order is part of the audit contract: the first
# matching blocker's code is the one recorded.
{
"report_schedule": BlockerReason(
REASON_REPORT_SCHEDULE, "associated alerts or reports exist"
),
"user_attribute": BlockerReason(
REASON_USER_ATTRIBUTE,
"a user has this dashboard set as their welcome page",
"report_schedule": "associated alerts or reports exist",
"user_attribute": (
"a user has this dashboard set as their welcome page"
),
},
{"dashboards_version": "id"},
@@ -955,9 +893,9 @@ def validate_deletion_allowed(
if session.execute(
sa.select(sa.literal(1)).select_from(table).where(*predicates).limit(1)
).first():
if dependency.blocker is None:
if dependency.blocked_reason is None:
raise RuntimeError(f"Missing blocker reason for {key.describe()}")
raise PurgeBlockedError(dependency.blocker)
raise PurgeBlockedError(dependency.blocked_reason)
def count_dashboard_slices(
+2 -2
View File
@@ -138,8 +138,8 @@ class BaseRestoreVersionCommand(BaseCommand):
# With capture off, Continuum's write listeners are detached: a
# revert would mutate the live entity with NO new version row —
# a destructive, untracked write. The whole restore surface is
# therefore inert under the kill-switch (404, indistinguishable from
# "no such version"). Existing history remains readable.
# therefore inert under the kill-switch, matching the read-side
# convention (404, indistinguishable from "no such version").
if not capture_enabled():
raise self.not_found_exc()
entity = find_active_by_uuid(self.model_cls, self._uuid)
+15 -7
View File
@@ -708,7 +708,7 @@ DEFAULT_FEATURE_FLAGS: dict[str, bool] = {
# the move-back lever; removed (along with its two gate points —
# BaseDAO.delete routing and the do_orm_execute visibility listener) once
# post-flip confidence is established.
# @lifecycle: testing
# @lifecycle: development
"SOFT_DELETE": True,
# Enable semantic layers and show semantic views alongside datasets
# @lifecycle: development
@@ -742,9 +742,9 @@ DEFAULT_FEATURE_FLAGS: dict[str, bool] = {
"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
# ship with matching defaults and should be changed together.
# @lifecycle: testing
# with capture off the panel renders but stays empty, so the two ship
# with matching defaults and should be changed together.
# @lifecycle: development
"VERSION_HISTORY": True,
# =================================================================
# IN TESTING
@@ -1694,9 +1694,17 @@ DATETIME_FORMAT_DETECTION_SAMPLE_SIZE = 1000
# The limit for the Superset Meta DB when the feature flag ENABLE_SUPERSET_META_DB is on
SUPERSET_META_DB_LIMIT: int | None = 1000
# Master switch for entity-version-history capture. A falsy value disables
# version writes while keeping existing history available read-only through the
# ``/versions/`` endpoints; Restore is unavailable while capture is disabled.
# Master switch for entity-version-history capture. Capture is enabled by
# default, so saves write shadow rows and a ``version_transaction`` /
# ``version_changes`` record. Set this to a falsy value in
# ``superset_config.py`` (or via the environment variable of the same name) to
# disable the before-flush listeners while keeping the /versions/ endpoints
# available read-only.
# Capture ships on. It is an operational escape hatch — set the environment
# variable to a falsy value when a versioning-induced regression needs a
# 30-second recovery instead of revert-and-redeploy — not a feature flag,
# and it remains permanently as the kill-switch rather than being removed
# with the rollout toggles.
ENABLE_VERSIONING_CAPTURE: bool = utils.parse_boolean_string(
os.environ.get("ENABLE_VERSIONING_CAPTURE", "true")
)
+10 -3
View File
@@ -791,9 +791,16 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
Must be called after all versioned model classes have been imported so
that VERSIONED_MODELS can be populated and configure_mappers() has run.
``ENABLE_VERSIONING_CAPTURE`` gates the baseline and change-record
listener registrations. When disabled, initialization also detaches
SQLAlchemy-Continuum's write listeners.
``ENABLE_VERSIONING_CAPTURE`` (ships default ``False``) gates the two
before-flush listener registrations. The flag is operational, not
feature: with it off the infrastructure is inert (no save writes
shadow rows); flipping it on activates capture. The switch also lets
an operator who observes a versioning-induced regression (e.g. a
save-path slowdown attributable to the change-record listener)
disable capture in ``superset_config.py`` and restart workers a
30-second recovery instead of revert-and-redeploy. Shadow tables
already created by the migration stay; they just stop accumulating
new rows.
The fallback here is ``False`` so that any app-factory path that
does not load ``superset.config`` (some test factories, embedded
@@ -665,8 +665,6 @@ def add_legend_config(form_data: Dict[str, Any], config: XYChartConfig) -> None:
# Canonical form_data key is camelCase; the echarts plugins read
# `legendOrientation` directly off form_data.
form_data["legendOrientation"] = config.legend.position
if config.legend_orientation:
form_data["legendOrientation"] = config.legend_orientation
def add_color_scheme(form_data: Dict[str, Any], color_scheme: str | None) -> None:
@@ -1417,8 +1415,6 @@ def map_filter_operator(op: str) -> str:
"NOT LIKE": "NOT LIKE",
"IN": "IN",
"NOT IN": "NOT IN",
"IS NULL": "IS NULL",
"IS NOT NULL": "IS NOT NULL",
}
return operator_map.get(op, op)
@@ -19,6 +19,7 @@
from __future__ import annotations
import re
from collections.abc import Mapping
from typing import Any, ClassVar
@@ -28,10 +29,7 @@ from superset.mcp_service.chart.chart_utils import (
)
from superset.mcp_service.chart.plugin import BaseChartPlugin
from superset.mcp_service.chart.schemas import ColumnRef, HistogramChartConfig
from superset.mcp_service.chart.validation.dataset_validator import (
DatasetValidator,
is_numeric_column,
)
from superset.mcp_service.chart.validation.dataset_validator import DatasetValidator
from superset.mcp_service.common.error_schemas import ChartGenerationError
@@ -118,13 +116,27 @@ class HistogramChartPlugin(BaseChartPlugin):
# Column existence is validated separately; don't double-report.
return None
if is_numeric_column(col_info):
def _is_numeric(col: dict[str, Any]) -> bool:
if col.get("is_numeric", False):
return True
# Backends report many spellings (BIGINT, SMALLINT, REAL, NUMBER,
# DOUBLE PRECISION); match numeric tokens at word boundaries so
# INTERVAL/POINT (which merely contain "INT") stay non-numeric.
type_upper = str(col.get("type", "")).upper()
return bool(
re.search(
r"\b(?:TINY|SMALL|MEDIUM|BIG)?INT(?:EGER)?\b"
r"|\bFLOAT\b|\bDOUBLE\b|\bDECIMAL\b"
r"|\bNUMERIC\b|\bREAL\b|\bNUMBER\b",
type_upper,
)
)
if _is_numeric(col_info):
return None
numeric_columns = sorted(
col["name"]
for col in dataset_context.available_columns
if is_numeric_column(col)
col["name"] for col in dataset_context.available_columns if _is_numeric(col)
)
return ChartGenerationError(
error_type="non_numeric_histogram_column",
+5 -24
View File
@@ -706,7 +706,7 @@ class ColumnRef(UnknownFieldCheckMixin):
None,
min_length=1,
max_length=255,
validation_alias=AliasChoices("name", "column_name", "column"),
validation_alias=AliasChoices("name", "column_name"),
)
label: str | None = Field(None, max_length=500)
dtype: str | None = None
@@ -884,32 +884,17 @@ class FilterConfig(UnknownFieldCheckMixin):
"NOT LIKE",
"IN",
"NOT IN",
"IS NULL",
"IS NOT NULL",
] = Field(
...,
description=(
"LIKE/ILIKE use % wildcards. IN/NOT IN take a list. "
"IS NULL/IS NOT NULL omit value."
),
description="LIKE/ILIKE use % wildcards. IN/NOT IN take a list.",
validation_alias=AliasChoices("op", "operator", "opr"),
)
value: str | int | float | bool | list[str | int | float | bool] | None = Field(
None,
description="For IN/NOT IN, provide a list. Omit for null operators.",
value: str | int | float | bool | list[str | int | float | bool] = Field(
...,
description="For IN/NOT IN, provide a list.",
validation_alias=AliasChoices("value", "val"),
)
@model_validator(mode="after")
def validate_value(self) -> "FilterConfig":
"""Null checks have no comparator; every other operator requires one."""
if self.op in {"IS NULL", "IS NOT NULL"}:
if self.value is not None:
raise ValueError(f"Filter operator {self.op!r} must not have 'value'.")
elif self.value is None:
raise ValueError(f"Filter operator {self.op!r} requires 'value'.")
return self
@field_validator("column")
@classmethod
def sanitize_column(cls, v: str) -> str:
@@ -1677,10 +1662,6 @@ class XYChartConfig(BaseChartConfig):
None,
validation_alias=AliasChoices("legend", "show_legend"),
)
legend_orientation: LEGEND_POSITION_LITERAL | None = Field(
None,
description="Legend placement around the chart",
)
x_axis_time_format: str | None = Field(
None,
description=(
@@ -22,8 +22,6 @@ Validates that referenced columns exist in the dataset schema.
import difflib
import logging
import re
from collections.abc import Mapping
from typing import Any, Dict, List, Tuple, TypeVar
from superset.mcp_service.chart.schemas import (
@@ -40,18 +38,6 @@ _C = TypeVar("_C", bound=ChartConfig)
logger = logging.getLogger(__name__)
_NUMERIC_TYPE_PATTERN = re.compile(
r"\b(?:(?:TINY|SMALL|MEDIUM|BIG)?INT(?:EGER)?|INT[248]|FLOAT[48]?|"
r"DOUBLE(?:\s+PRECISION)?|DECIMAL|NUMERIC|REAL|NUMBER|(?:SMALL)?MONEY)\b"
)
def is_numeric_column(column: Mapping[str, Any]) -> bool:
"""Return whether dataset metadata identifies a numeric SQL column."""
if column.get("is_numeric", False):
return True
return bool(_NUMERIC_TYPE_PATTERN.search(str(column.get("type") or "").upper()))
def is_dataset_column_temporal(
column: Any, column_name: str, db_engine_spec: Any
@@ -716,11 +702,11 @@ class DatasetValidator:
"STDDEV",
"VAR",
]
type_name = str(col_info.get("type") or "").strip().upper()
if (
col_ref.aggregate in numeric_aggs
and type_name not in {"", "UNKNOWN"}
and not is_numeric_column(col_info)
and not col_info.get("is_numeric", False)
and col_info.get("type", "").upper()
not in ["INTEGER", "FLOAT", "DOUBLE", "DECIMAL", "NUMERIC"]
):
from superset.mcp_service.utils.error_builder import ( # noqa: E501
ChartErrorBuilder,
@@ -1,60 +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.
"""Add a reason to purge_audit_log.
Adds a nullable ``reason`` column to ``purge_audit_log`` holding a stable
machine code identifying which policy rule blocked a purge (or the
cascade-integrity failure class). Written at finalization for
blocked outcomes only; NULL for confirmed, failed, non-blocked, and
pre-existing rows. No backfill: the information was never captured for
historical records, and readers treat the column as optional.
Apply this migration before deploying the code that depends on it. The
audit model declares the column, so a worker running the new code against
the un-migrated table cannot write its write-ahead record; the scheduled
purge then fails closed (nothing is purged unaudited) and logs a
write-ahead warning every run until the migration lands.
The downgrade discards every recorded block reason -- the rows survive and
revert to reason-less, exactly like pre-feature history.
Revision ID: 39097d124752
Revises: 1072de5ed955
Create Date: 2026-08-24 12:00:00.000000
"""
import sqlalchemy as sa
from superset.migrations.shared.utils import add_columns, drop_columns
# revision identifiers, used by Alembic.
revision: str = "39097d124752"
down_revision: str = "1072de5ed955"
def upgrade() -> None:
"""Add the nullable ``reason`` column to ``purge_audit_log``."""
add_columns(
"purge_audit_log",
sa.Column("reason", sa.String(64), nullable=True),
)
def downgrade() -> None:
"""Drop the ``reason`` column from ``purge_audit_log``."""
drop_columns("purge_audit_log", "reason")
-6
View File
@@ -72,12 +72,6 @@ class PurgeAuditLog(Model):
# Comma-joined UUIDs of charts left dangling / dashboards that lost a join
# row (force-purge visibility). Free text, content-free.
affected_referrers = Column(Text, nullable=True)
# Stable machine code identifying which rule blocked the purge (or the
# cascade-integrity failure class). Written at finalization for
# blocked outcomes only; NULL for confirmed, failed, non-blocked, and
# pre-feature rows. Vocabulary: REASON_* constants in
# superset.commands.deletion_retention.purge_policy.
reason: Column[str] = Column(String(64), nullable=True)
removed_dashboard_slices = Column(Integer, nullable=False, default=0)
created_on = Column(
DateTime()
+12 -17
View File
@@ -32,7 +32,6 @@ import logging
from collections.abc import Iterator
from datetime import datetime, timedelta
from typing import Any, cast
from uuid import UUID
import sqlalchemy as sa
from flask import current_app
@@ -46,7 +45,6 @@ from superset.commands.deletion_retention.purge_cascade import (
entity_uuid,
suppress_purge_association_versions,
)
from superset.commands.deletion_retention.purge_policy import BlockerReason
from superset.commands.deletion_retention.window import resolve_retention_window
from superset.extensions import celery_app, feature_flag_manager, stats_logger_manager
from superset.models.helpers import (
@@ -207,19 +205,6 @@ def _purge_model(
return purged, would, failures, blocked
def _finalize_blocked(record_id: UUID | None, blocker: BlockerReason) -> None:
"""Finalize a blocked retention outcome and count suppression metrics."""
disposition: audit.RetentionBlockedDisposition = audit.finalize_retention_blocked(
record_id, blocker.code
)
if disposition == "suppressed":
stats_logger_manager.instance.incr(f"{_METRIC_PREFIX}.blocked_audit_suppressed")
elif disposition == "fallback":
stats_logger_manager.instance.incr(
f"{_METRIC_PREFIX}.blocked_audit_dedupe_fallback"
)
def _purge_one(
model: type[SoftDeleteMixin], entity_id: int, cutoff: datetime
) -> CascadeResult | None:
@@ -294,8 +279,18 @@ def _purge_one(
affected_referrers=result.dangling_chart_uuids,
removed_dashboard_slices=result.removed_dashboard_slices,
)
elif result.blocker is not None:
_finalize_blocked(record_id, result.blocker)
elif result.blocked_reason is not None:
disposition: audit.RetentionBlockedDisposition = (
audit.finalize_retention_blocked(record_id)
)
if disposition == "suppressed":
stats_logger_manager.instance.incr(
f"{_METRIC_PREFIX}.blocked_audit_suppressed"
)
elif disposition == "fallback":
stats_logger_manager.instance.incr(
f"{_METRIC_PREFIX}.blocked_audit_dedupe_fallback"
)
else:
audit.fail(record_id)
return result
@@ -379,37 +379,37 @@ msgstr "虛擬"
msgid "%s aggregates(s)"
msgstr "%s 聚合"
#, python-format
#, fuzzy, python-format
msgid "%s column"
msgid_plural "%s columns"
msgstr[0] "%s 個欄位"
msgstr[0] "%s "
#, python-format
msgid "%s column(s)"
msgstr "%s 個欄位"
msgstr "%s "
#, python-format
#, fuzzy, python-format
msgid "%s day ago"
msgid_plural "%s days ago"
msgstr[0] "%s 天前"
msgstr[0] "1前"
#, python-format
#, fuzzy, python-format
msgid "%s hr ago"
msgid_plural "%s hr ago"
msgstr[0] "%s 小時前"
msgstr[0] "%s "
#, python-format
#, fuzzy, python-format
msgid "%s imported"
msgstr "已匯入 %s"
msgstr "數據集已導入"
#, python-format
#, fuzzy, python-format
msgid "%s item"
msgid_plural "%s items"
msgstr[0] "%s 個項"
msgstr[0] "%s 個項"
#, python-format
#, fuzzy, python-format
msgid "%s item(s)"
msgstr "%s 個項"
msgstr "%s 個項"
# Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: ar, cs, de,
# es, fa, fr, ja, lv, mi, nl, pl, pt_BR, ru, sk, sl, sr, sr_Latn, tr, uk]
@@ -419,15 +419,15 @@ msgid ""
"all selected objects."
msgstr "%s 個項目無法標記,因為您對所有選取的物件沒有編輯權限。"
#, python-format
#, fuzzy, python-format
msgid "%s metric"
msgid_plural "%s metrics"
msgstr[0] "%s 個指標"
msgstr[0] "排序指標"
#, python-format
#, fuzzy, python-format
msgid "%s min ago"
msgid_plural "%s min ago"
msgstr[0] "%s 分鐘前"
msgstr[0] ""
#, python-format
msgid ""
@@ -451,47 +451,47 @@ msgstr[0] "%s 個選項"
msgid "%s option(s)"
msgstr "%s 個選項"
#, python-format
#, fuzzy, python-format
msgid "%s out of %s column"
msgid_plural "%s out of %s columns"
msgstr[0] "已選取 %s%s 個欄位"
msgstr[0] "自定義列"
#, python-format
#, fuzzy, python-format
msgid "%s out of %s metric"
msgid_plural "%s out of %s metrics"
msgstr[0] "已選取 %s%s 個指標"
msgstr[0] "排序指標"
#, python-format
#, fuzzy, python-format
msgid "%s out of %s selected"
msgstr "已選取 %s%s 個項目"
msgstr "%s 已選定"
#, python-format
#, fuzzy, python-format
msgid "%s recipients"
msgstr "%s 收件者"
msgstr "%s 最近"
#, python-format
#, fuzzy, python-format
msgid "%s record..."
msgid_plural "%s records..."
msgstr[0] "%s 筆記錄..."
msgstr[0] "%s 異常"
#, python-format
#, fuzzy, python-format
msgid "%s row"
msgid_plural "%s rows"
msgstr[0] "%s "
msgstr[0] "%s "
#, python-format
#, fuzzy, python-format
msgid "%s s ago"
msgid_plural "%s s ago"
msgstr[0] "%s 秒前"
msgstr[0] "30 天之前"
#, python-format
msgid "%s saved metric(s)"
msgstr "%s 保存的指標"
#, python-format
#, fuzzy, python-format
msgid "%s second"
msgid_plural "%s seconds"
msgstr[0] "%s 秒"
msgstr[0] "5 秒"
# Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: de, es, sr,
# sr_Latn]
@@ -511,13 +511,13 @@ msgstr "%s 個語意檢視新增失敗"
msgid "%s semantic view(s) failed to add: %s"
msgstr "%s 個語意檢視新增失敗:%s"
#, python-format
#, fuzzy, python-format
msgid "%s tab selected"
msgstr "已選取「%s」分頁"
msgstr "%s 已選定"
#, python-format
#, fuzzy, python-format
msgid "%s updated"
msgstr "更新 %s"
msgstr "上次更新 %s"
#, python-format
msgid "%s%s"
@@ -677,11 +677,13 @@ msgstr "每年年初的頻率"
msgid "10 minute"
msgstr "10 分鐘"
#, fuzzy
msgid "10 seconds"
msgstr "10 秒"
msgstr "30 秒"
#, fuzzy
msgid "10/90 percentiles"
msgstr "10/90 百分位"
msgstr "9/91 百分位"
#. do-not-translate
msgid "10000"
@@ -694,8 +696,9 @@ msgstr "週"
msgid "104 weeks ago"
msgstr "104 週之前"
#, fuzzy
msgid "12 hours"
msgstr "12 小時"
msgstr "1 小時"
msgid "15 minute"
msgstr "15 分鐘"
@@ -758,8 +761,9 @@ msgstr "2/98 百分位"
msgid "22"
msgstr "22"
#, fuzzy
msgid "24 hours"
msgstr "24 小時"
msgstr "6 小時"
#, fuzzy
msgid "28 days"
@@ -827,8 +831,9 @@ msgstr "5 秒"
msgid "5 seconds"
msgstr "5 秒"
#, fuzzy
msgid "5/95 percentiles"
msgstr "5/95 百分位"
msgstr "9/91 百分位"
#, fuzzy
msgid "52 weeks"
@@ -35,12 +35,14 @@ from superset.extensions import cache_manager, db, security_manager
from superset.models.core import Database, FavStar, FavStarClassName
from superset.models.dashboard import Dashboard
from superset.models.slice import Slice
from superset.models.sql_lab import SavedQuery
from superset.reports.models import ReportSchedule, ReportScheduleType
from superset.subjects.models import Subject
from superset.subjects.types import SubjectType
from superset.tags.models import ObjectType, Tag, TaggedObject, TagType
from superset.utils import json
from superset.utils.core import get_example_default_schema
from superset.utils.database import get_example_database
from tests.integration_tests.base_api_tests import ApiEditorsTestCaseMixin
from tests.integration_tests.base_tests import (
subjects_from_users,
@@ -660,6 +662,46 @@ class TestChartApi(ApiEditorsTestCaseMixin, InsertChartMixin, SupersetTestCase):
response = json.loads(rv.data.decode("utf-8"))
assert response == {"message": {"datasource_id": ["Datasource does not exist"]}}
def test_create_chart_from_saved_query_rejected_cleanly(self):
"""
Chart API: creating a chart with datasource_type="saved_query" must
fail with a clean validation error, not the unhandled 500 "Fatal
error" reported in apache/superset#29697. Slice.datasource only
ever resolves the "table" relationship, so even a chart that
"created" successfully with this datasource_type could never
actually render -- "saved_query" is a real, existing row here
(not a bad ID), reproducing the original report exactly rather
than a not-found case.
"""
self.login(ADMIN_USERNAME)
example_db = get_example_database()
saved_query = SavedQuery(
db_id=example_db.id,
label="issue-29697-repro",
schema=get_example_default_schema(),
sql="SELECT 1 AS value",
)
db.session.add(saved_query)
db.session.commit()
saved_query_id = saved_query.id
chart_data = {
"slice_name": "issue-29697-repro-chart",
"datasource_id": saved_query_id,
"datasource_type": "saved_query",
"viz_type": "table",
}
rv = self.post_assert_metric("/api/v1/chart/", chart_data, "post")
db.session.delete(db.session.query(SavedQuery).get(saved_query_id))
db.session.commit()
assert rv.status_code == 422
response = json.loads(rv.data.decode("utf-8"))
assert response == {
"message": {"datasource_type": ["Datasource type is invalid"]}
}
@pytest.mark.usefixtures("load_world_bank_dashboard_with_slices")
def test_create_chart_validate_user_is_dashboard_editor(self):
"""
@@ -29,10 +29,6 @@ from sqlalchemy.orm import Session
from superset import db
from superset.commands.deletion_retention import audit
from superset.commands.deletion_retention.audit import PurgeAuditLog
from superset.commands.deletion_retention.purge_policy import (
REASON_CASCADE_INTEGRITY_FAILURE,
REASON_REPORT_SCHEDULE,
)
from superset.models.slice import Slice
from superset.tasks.deletion_retention import _purge_impl
@@ -79,7 +75,6 @@ class TestPurgeAudit(DeletionRetentionTestBase):
assert row.trigger == audit.TRIGGER_RETENTION
assert row.actor == audit.ACTOR_SYSTEM
assert row.confirmed_on is not None
assert row.reason is None
assert isinstance(row.id, UUID)
def test_known_failure_finalizes_audit_row(self) -> None:
@@ -102,7 +97,6 @@ class TestPurgeAudit(DeletionRetentionTestBase):
row = db.session.query(PurgeAuditLog).filter_by(entity_uuid=chart_uuid).one()
assert row.status == audit.STATUS_FAILED
assert row.confirmed_on is None
assert row.reason is None
def test_reconcile_confirms_pending_after_entity_commit(self) -> None:
"""A crash after entity commit is reconciled to confirmed."""
@@ -178,9 +172,6 @@ class TestPurgeAudit(DeletionRetentionTestBase):
row = db.session.get(PurgeAuditLog, record_id)
assert row.status == audit.STATUS_TARGET_ABSENT
assert row.removed_dashboard_slices == 0
# The reconcile crash window is the documented reason-losing path:
# finalized rows here never carry a fabricated code.
assert row.reason is None
def test_blocked_attempt_does_not_keep_the_intended_removal_count(self) -> None:
"""The write-ahead row records what the purge INTENDED to remove;
@@ -193,7 +184,7 @@ class TestPurgeAudit(DeletionRetentionTestBase):
entity_uuid="00000000-0000-0000-0000-00000000cafe",
removed_dashboard_slices=7,
)
audit.block(record_id, REASON_REPORT_SCHEDULE)
audit.block(record_id)
row = db.session.query(PurgeAuditLog).filter_by(id=record_id).one()
assert row.status == audit.STATUS_BLOCKED
@@ -203,56 +194,20 @@ class TestPurgeAudit(DeletionRetentionTestBase):
record_id: UUID = self._write_retention_record(entity_uuid="first-block")
disposition: audit.RetentionBlockedDisposition = (
audit.finalize_retention_blocked(record_id, REASON_REPORT_SCHEDULE)
audit.finalize_retention_blocked(record_id)
)
record: PurgeAuditLog = self._get_audit_record(record_id)
assert disposition == "retained"
assert record.status == audit.STATUS_BLOCKED
assert record.reason == REASON_REPORT_SCHEDULE
def test_reason_is_persisted_only_for_blocked_outcomes(self) -> None:
"""A reason offered for a non-blocked outcome is refused, not stored.
The audit records a cause for a purge that did not happen; a
confirmed or failed row asserting a blocker would misreport its own
outcome.
"""
for status in (
audit.STATUS_CONFIRMED,
audit.STATUS_FAILED,
audit.STATUS_TARGET_ABSENT,
):
record_id: UUID = self._write_retention_record(
entity_uuid=f"non-blocked-{status}"
)
audit.finalize(record_id, status, reason=REASON_REPORT_SCHEDULE)
record: PurgeAuditLog = self._get_audit_record(record_id)
db.session.refresh(record)
assert record.status == status
assert record.reason is None
def test_finalized_reason_is_immutable(self) -> None:
"""A second finalization attempt never rewrites the recorded reason."""
record_id: UUID = self._write_retention_record(entity_uuid="reason-immutable")
audit.finalize_retention_blocked(record_id, REASON_REPORT_SCHEDULE)
audit.block(record_id, "some_other_code")
audit.finalize_retention_blocked(record_id, "some_other_code")
record: PurgeAuditLog = self._get_audit_record(record_id)
db.session.refresh(record)
assert record.status == audit.STATUS_BLOCKED
assert record.reason == REASON_REPORT_SCHEDULE
def test_repeated_retention_block_suppresses_current_provisional(self) -> None:
first_id: UUID = self._write_retention_record(entity_uuid="repeat-block")
audit.finalize_retention_blocked(first_id, REASON_REPORT_SCHEDULE)
audit.finalize_retention_blocked(first_id)
second_id: UUID = self._write_retention_record(entity_uuid="repeat-block")
disposition: audit.RetentionBlockedDisposition = (
audit.finalize_retention_blocked(second_id, REASON_REPORT_SCHEDULE)
audit.finalize_retention_blocked(second_id)
)
assert disposition == "suppressed"
@@ -260,160 +215,18 @@ class TestPurgeAudit(DeletionRetentionTestBase):
assert first.status == audit.STATUS_BLOCKED
assert db.session.get(PurgeAuditLog, second_id) is None
def test_reason_change_breaks_suppression_exactly_once(self) -> None:
"""A reason change writes one new blocked row, then re-suppresses.
The suppression predicate keys on status AND reason: same-reason
nights suppress; the night the reason changes is retained with the
new code and becomes the new anchor.
"""
entity: str = "reason-change"
first_id: UUID = self._write_retention_record(entity_uuid=entity)
assert (
audit.finalize_retention_blocked(first_id, REASON_REPORT_SCHEDULE)
== "retained"
)
second_id: UUID = self._write_retention_record(entity_uuid=entity)
assert (
audit.finalize_retention_blocked(second_id, REASON_REPORT_SCHEDULE)
== "suppressed"
)
changed_id: UUID = self._write_retention_record(entity_uuid=entity)
assert (
audit.finalize_retention_blocked(
changed_id, REASON_CASCADE_INTEGRITY_FAILURE
)
== "retained"
)
repeat_id: UUID = self._write_retention_record(entity_uuid=entity)
assert (
audit.finalize_retention_blocked(
repeat_id, REASON_CASCADE_INTEGRITY_FAILURE
)
== "suppressed"
)
rows: list[PurgeAuditLog] = (
db.session.query(PurgeAuditLog).filter_by(entity_uuid=entity).all()
)
assert {row.reason for row in rows} == {
REASON_REPORT_SCHEDULE,
REASON_CASCADE_INTEGRITY_FAILURE,
}
assert len(rows) == 2
assert all(row.status == audit.STATUS_BLOCKED for row in rows)
def test_mixed_reason_timestamp_tie_is_ambiguous_and_retains(self) -> None:
"""Tied predecessors differing only in reason refuse suppression."""
timestamp: datetime = datetime.utcnow()
first_id: UUID = self._write_retention_record(
entity_uuid="mixed-reason-tie", created_on=timestamp
)
audit.finalize_retention_blocked(first_id, REASON_REPORT_SCHEDULE)
second_id: UUID = self._write_retention_record(
entity_uuid="mixed-reason-tie", created_on=timestamp
)
audit.finalize_retention_blocked(second_id, REASON_CASCADE_INTEGRITY_FAILURE)
current_id: UUID = self._write_retention_record(
entity_uuid="mixed-reason-tie", created_on=timestamp + timedelta(seconds=1)
)
disposition: audit.RetentionBlockedDisposition = (
audit.finalize_retention_blocked(current_id, REASON_REPORT_SCHEDULE)
)
assert disposition == "retained"
current: PurgeAuditLog = self._get_audit_record(current_id)
assert current.status == audit.STATUS_BLOCKED
def test_null_reason_historical_predecessor_never_suppresses(self) -> None:
"""The first post-upgrade block of a long-blocked entity is retained.
Pre-feature blocked rows carry NULL; NULL never matches a current
code, so the entity anchors once with its code and same-code nights
suppress against the new anchor.
"""
entity: str = "null-historical"
prior_id: UUID = self._write_retention_record(entity_uuid=entity)
audit.finalize(prior_id, audit.STATUS_BLOCKED)
prior: PurgeAuditLog = self._get_audit_record(prior_id)
assert prior.reason is None
current_id: UUID = self._write_retention_record(entity_uuid=entity)
assert (
audit.finalize_retention_blocked(current_id, REASON_REPORT_SCHEDULE)
== "retained"
)
current: PurgeAuditLog = self._get_audit_record(current_id)
assert current.reason == REASON_REPORT_SCHEDULE
repeat_id: UUID = self._write_retention_record(entity_uuid=entity)
assert (
audit.finalize_retention_blocked(repeat_id, REASON_REPORT_SCHEDULE)
== "suppressed"
)
def test_none_current_code_never_suppresses_and_warns(self) -> None:
"""A missing current code fails safe: retained, with a warning."""
entity: str = "none-current-code"
first_id: UUID = self._write_retention_record(entity_uuid=entity)
audit.finalize_retention_blocked(first_id, REASON_REPORT_SCHEDULE)
current_id: UUID = self._write_retention_record(entity_uuid=entity)
with patch(
"superset.commands.deletion_retention.audit.logger.warning"
) as warning:
disposition: audit.RetentionBlockedDisposition = (
audit.finalize_retention_blocked(current_id, None)
)
assert disposition == "retained"
assert warning.called
current: PurgeAuditLog = self._get_audit_record(current_id)
assert current.status == audit.STATUS_BLOCKED
assert current.reason is None
def test_predecessor_is_the_latest_row_overall(self) -> None:
"""A newer same-entity row forbids suppressing against an older one.
With rows timestamped both before and after the current attempt, the
later-timestamped row is selected, fails the strictly-older check, and
causes retention. This verifies timestamp ordering, not causal order
across workers.
"""
timestamp: datetime = datetime.utcnow()
older_id: UUID = self._write_retention_record(
entity_uuid="latest-overall", created_on=timestamp - timedelta(seconds=1)
)
audit.finalize_retention_blocked(older_id, REASON_REPORT_SCHEDULE)
newer_id: UUID = self._write_retention_record(
entity_uuid="latest-overall", created_on=timestamp + timedelta(seconds=1)
)
audit.finalize(newer_id, audit.STATUS_BLOCKED, reason=REASON_REPORT_SCHEDULE)
current_id: UUID = self._write_retention_record(
entity_uuid="latest-overall", created_on=timestamp
)
disposition: audit.RetentionBlockedDisposition = (
audit.finalize_retention_blocked(current_id, REASON_REPORT_SCHEDULE)
)
assert disposition == "retained"
current: PurgeAuditLog = self._get_audit_record(current_id)
assert current.status == audit.STATUS_BLOCKED
def test_equal_timestamp_is_ambiguous_and_retains_current(self) -> None:
timestamp: datetime = datetime.utcnow()
first_id: UUID = self._write_retention_record(
entity_uuid="equal-time", created_on=timestamp
)
audit.finalize_retention_blocked(first_id, REASON_REPORT_SCHEDULE)
audit.finalize_retention_blocked(first_id)
second_id: UUID = self._write_retention_record(
entity_uuid="equal-time", created_on=timestamp
)
disposition: audit.RetentionBlockedDisposition = (
audit.finalize_retention_blocked(second_id, REASON_REPORT_SCHEDULE)
audit.finalize_retention_blocked(second_id)
)
assert disposition == "retained"
@@ -444,7 +257,7 @@ class TestPurgeAudit(DeletionRetentionTestBase):
session.close()
disposition: audit.RetentionBlockedDisposition = (
audit.finalize_retention_blocked(current_id, REASON_REPORT_SCHEDULE)
audit.finalize_retention_blocked(current_id)
)
assert predecessor is None
@@ -460,10 +273,10 @@ class TestPurgeAudit(DeletionRetentionTestBase):
newer_id: UUID = self._write_retention_record(
entity_uuid="overlap", created_on=current_time + timedelta(seconds=1)
)
audit.finalize_retention_blocked(newer_id, REASON_REPORT_SCHEDULE)
audit.finalize_retention_blocked(newer_id)
disposition: audit.RetentionBlockedDisposition = (
audit.finalize_retention_blocked(current_id, REASON_REPORT_SCHEDULE)
audit.finalize_retention_blocked(current_id)
)
assert disposition == "retained"
@@ -478,7 +291,7 @@ class TestPurgeAudit(DeletionRetentionTestBase):
)
disposition: audit.RetentionBlockedDisposition = (
audit.finalize_retention_blocked(current_id, REASON_REPORT_SCHEDULE)
audit.finalize_retention_blocked(current_id)
)
assert disposition == "retained"
@@ -487,7 +300,7 @@ class TestPurgeAudit(DeletionRetentionTestBase):
null_id: UUID = self._write_retention_record(entity_uuid=None)
disposition: audit.RetentionBlockedDisposition = (
audit.finalize_retention_blocked(null_id, REASON_REPORT_SCHEDULE)
audit.finalize_retention_blocked(null_id)
)
record: PurgeAuditLog = self._get_audit_record(null_id)
@@ -499,13 +312,13 @@ class TestPurgeAudit(DeletionRetentionTestBase):
chart_id: UUID = self._write_retention_record(
entity_uuid="shared-type", entity_type="slices"
)
audit.finalize_retention_blocked(chart_id, REASON_REPORT_SCHEDULE)
audit.finalize_retention_blocked(chart_id)
dashboard_id: UUID = self._write_retention_record(
entity_uuid="shared-type", entity_type="dashboards"
)
dashboard_disposition: audit.RetentionBlockedDisposition = (
audit.finalize_retention_blocked(dashboard_id, REASON_REPORT_SCHEDULE)
audit.finalize_retention_blocked(dashboard_id)
)
assert dashboard_disposition == "retained"
@@ -515,7 +328,7 @@ class TestPurgeAudit(DeletionRetentionTestBase):
audit.fail(record_id)
disposition: audit.RetentionBlockedDisposition = (
audit.finalize_retention_blocked(record_id, REASON_REPORT_SCHEDULE)
audit.finalize_retention_blocked(record_id)
)
record: PurgeAuditLog = self._get_audit_record(record_id)
@@ -530,7 +343,7 @@ class TestPurgeAudit(DeletionRetentionTestBase):
side_effect=audit.SQLAlchemyError("lookup failed"),
):
disposition: audit.RetentionBlockedDisposition = (
audit.finalize_retention_blocked(record_id, REASON_REPORT_SCHEDULE)
audit.finalize_retention_blocked(record_id)
)
record: PurgeAuditLog = self._get_audit_record(record_id)
@@ -539,7 +352,7 @@ class TestPurgeAudit(DeletionRetentionTestBase):
def test_suppression_delete_failure_recovers_blocked_evidence(self) -> None:
first_id: UUID = self._write_retention_record(entity_uuid="delete-failure")
audit.finalize_retention_blocked(first_id, REASON_REPORT_SCHEDULE)
audit.finalize_retention_blocked(first_id)
current_id: UUID = self._write_retention_record(entity_uuid="delete-failure")
with patch(
@@ -547,7 +360,7 @@ class TestPurgeAudit(DeletionRetentionTestBase):
side_effect=audit.SQLAlchemyError("delete failed"),
):
disposition: audit.RetentionBlockedDisposition = (
audit.finalize_retention_blocked(current_id, REASON_REPORT_SCHEDULE)
audit.finalize_retention_blocked(current_id)
)
record: PurgeAuditLog = self._get_audit_record(current_id)
@@ -570,18 +383,16 @@ class TestPurgeAudit(DeletionRetentionTestBase):
),
):
disposition: audit.RetentionBlockedDisposition = (
audit.finalize_retention_blocked(record_id, REASON_REPORT_SCHEDULE)
audit.finalize_retention_blocked(record_id)
)
record: PurgeAuditLog = self._get_audit_record(record_id)
assert disposition == "fallback"
assert record.status == audit.STATUS_BLOCKED
# The recovery retain branch carries the argument-sourced snapshot reason.
assert record.reason == REASON_REPORT_SCHEDULE
def test_uncertain_suppression_commit_recreates_absent_evidence(self) -> None:
first_id: UUID = self._write_retention_record(entity_uuid="absent-current")
audit.finalize_retention_blocked(first_id, REASON_REPORT_SCHEDULE)
audit.finalize_retention_blocked(first_id)
current_id: UUID = self._write_retention_record(entity_uuid="absent-current")
primary_session: Session = audit._dedicated_session()
recovery_session: Session = audit._dedicated_session()
@@ -599,16 +410,12 @@ class TestPurgeAudit(DeletionRetentionTestBase):
),
):
disposition: audit.RetentionBlockedDisposition = (
audit.finalize_retention_blocked(current_id, REASON_REPORT_SCHEDULE)
audit.finalize_retention_blocked(current_id)
)
record: PurgeAuditLog = self._get_audit_record(current_id)
assert disposition == "fallback"
assert record.status == audit.STATUS_BLOCKED
# The recovery re-insert branch sources the reason from the snapshot
# (populated from the call argument, never from the reason-less
# pending row).
assert record.reason == REASON_REPORT_SCHEDULE
def test_failed_fallback_leaves_pending_evidence_for_reconciliation(self) -> None:
record_id: UUID = self._write_retention_record(entity_uuid="fallback-failure")
@@ -631,7 +438,7 @@ class TestPurgeAudit(DeletionRetentionTestBase):
),
):
disposition: audit.RetentionBlockedDisposition = (
audit.finalize_retention_blocked(record_id, REASON_REPORT_SCHEDULE)
audit.finalize_retention_blocked(record_id)
)
record: PurgeAuditLog = self._get_audit_record(record_id)
@@ -657,34 +464,31 @@ class TestPurgeAudit(DeletionRetentionTestBase):
entity_type="slices",
entity_uuid="indeterminate-rowcount",
created_on=timestamp - timedelta(seconds=1),
reason=REASON_REPORT_SCHEDULE,
)
result: MagicMock = MagicMock(rowcount=-1)
session: MagicMock = MagicMock()
session.execute.return_value = result
with pytest.raises(audit.SQLAlchemyError, match="indeterminate"):
audit._suppress_redundant_block(
session, current, predecessor, REASON_REPORT_SCHEDULE
)
audit._suppress_redundant_block(session, current, predecessor)
def test_overlap_duplicates_do_not_cause_unbounded_sequential_growth(self) -> None:
timestamp: datetime = datetime.utcnow()
first_id: UUID = self._write_retention_record(
entity_uuid="bounded-overlap", created_on=timestamp
)
audit.finalize_retention_blocked(first_id, REASON_REPORT_SCHEDULE)
audit.finalize_retention_blocked(first_id)
overlap_id: UUID = self._write_retention_record(
entity_uuid="bounded-overlap", created_on=timestamp
)
audit.finalize_retention_blocked(overlap_id, REASON_REPORT_SCHEDULE)
audit.finalize_retention_blocked(overlap_id)
later_id: UUID = self._write_retention_record(
entity_uuid="bounded-overlap",
created_on=timestamp + timedelta(seconds=1),
)
disposition: audit.RetentionBlockedDisposition = (
audit.finalize_retention_blocked(later_id, REASON_REPORT_SCHEDULE)
audit.finalize_retention_blocked(later_id)
)
retained_count: int = (
@@ -712,7 +516,7 @@ class TestPurgeAudit(DeletionRetentionTestBase):
current_id: UUID = self._write_retention_record(entity_uuid=entity_uuid)
disposition: audit.RetentionBlockedDisposition = (
audit.finalize_retention_blocked(current_id, REASON_REPORT_SCHEDULE)
audit.finalize_retention_blocked(current_id)
)
current: PurgeAuditLog = self._get_audit_record(current_id)
@@ -140,7 +140,6 @@ class TestForcePurge(DeletionRetentionTestBase):
assert self.exists(Slice, chart_id)
row = db.session.query(PurgeAuditLog).filter_by(entity_uuid=chart_uuid).one()
assert row.status == "blocked"
assert row.reason == "report_schedule"
log_info.assert_called_once_with(
"force_purge: blocked %s uuid=%s reason=%s",
"chart",
@@ -24,7 +24,6 @@ guarantee under FK enforcement OFF, and the version-tables-absent no-op.
from __future__ import annotations
from collections.abc import Callable
from dataclasses import replace
from datetime import datetime, timedelta
from typing import Any
@@ -46,9 +45,6 @@ from superset.commands.deletion_retention.purge_cascade import (
from superset.commands.deletion_retention.purge_policy import (
get_purge_policy,
PurgeEntityPolicy,
REASON_CASCADE_INTEGRITY_FAILURE,
REASON_REPORT_SCHEDULE,
REASON_USER_ATTRIBUTE,
)
from superset.connectors.sqla.models import (
RLSFilterTables,
@@ -290,7 +286,6 @@ class TestSoftDeletePurge(DeletionRetentionTestBase):
.one()
)
assert row.status == audit.STATUS_BLOCKED
assert row.reason == REASON_REPORT_SCHEDULE
def test_repeated_report_blocker_preserves_counts_and_suppresses_noise(
self,
@@ -773,7 +768,6 @@ class TestExplicitBlockerGuards(DeletionRetentionTestBase):
assert result.purged is False
assert result.blocked_reason is not None
assert "welcome page" in result.blocked_reason
assert result.blocked_reason_code == REASON_USER_ATTRIBUTE
assert self.exists(Dashboard, dashboard_id)
finally:
self._restore_welcome(attribute, created, previous)
@@ -818,120 +812,10 @@ class TestExplicitBlockerGuards(DeletionRetentionTestBase):
db.session.commit()
assert result.purged is False
assert (
result.blocked_reason
== "cascade blocked by a database integrity constraint"
)
assert result.blocked_reason == "blocked by database references"
assert "SQL:" not in result.blocked_reason
assert result.blocked_reason_code == REASON_CASCADE_INTEGRITY_FAILURE
assert self.exists(Slice, chart_id)
def test_three_way_distinction_is_readable_from_the_audit_alone(self) -> None:
"""Report, welcome, and database-integrity blocks write distinct codes.
The audit table is the durable record: each of the three
non-completing outcomes must be identifiable from its row alone,
with no SQL fragments and the integrity case keeping blocked status.
"""
chart: Slice = self.make_chart("threeway_report")
report: ReportSchedule = ReportSchedule(
type="Report",
name="retention_it_threeway",
crontab="0 0 * * *",
chart=chart,
)
db.session.add(report)
db.session.commit()
chart_uuid: str = str(chart.uuid)
self.soft_delete(chart, days_ago=90)
dashboard: Dashboard = self.make_dashboard("threeway_welcome")
dashboard_uuid: str = str(dashboard.uuid)
self.soft_delete(dashboard, days_ago=90)
attribute: UserAttribute
created: bool
previous: int | None
attribute, created, previous = self._set_welcome(dashboard.id)
fk_chart: Slice = self.make_chart("threeway_fk")
fk_uuid: str = str(fk_chart.uuid)
self.soft_delete(fk_chart, days_ago=90)
real_get_policy: Callable[[type[Any]], PurgeEntityPolicy] = get_purge_policy
def fail_fk_chart_cleanup(
session: Session, policy: PurgeEntityPolicy, entity_id: int
) -> None:
if entity_id == fk_chart.id:
raise IntegrityError("FOREIGN KEY constraint failed", None, Exception())
real_get_policy(Slice).delete_associations(session, policy, entity_id)
def patched_policy(model: type[Any]) -> PurgeEntityPolicy:
policy: PurgeEntityPolicy = real_get_policy(model)
if model is Slice:
return replace(policy, delete_associations=fail_fk_chart_cleanup)
return policy
try:
with patch(
"superset.commands.deletion_retention.purge_cascade.get_purge_policy",
side_effect=patched_policy,
):
_purge(window=30)
rows: dict[str, audit.PurgeAuditLog] = {
uuid: db.session.query(audit.PurgeAuditLog)
.filter_by(entity_uuid=uuid)
.one()
for uuid in (chart_uuid, dashboard_uuid, fk_uuid)
}
assert rows[chart_uuid].reason == REASON_REPORT_SCHEDULE
assert rows[dashboard_uuid].reason == REASON_USER_ATTRIBUTE
assert rows[fk_uuid].reason == REASON_CASCADE_INTEGRITY_FAILURE
assert len({row.reason for row in rows.values()}) == 3
for row in rows.values():
assert row.status == audit.STATUS_BLOCKED
assert "SQL" not in row.reason
assert "?" not in row.reason
finally:
self._restore_welcome(attribute, created, previous)
def test_first_declared_blocker_wins_in_the_audit_record(self) -> None:
"""A dashboard blocked by both rules records the first-declared code.
Declaration order is part of the audit contract: report_schedule is
declared before user_attribute, so a dashboard that is both
report-referenced and someone's welcome page records
REASON_REPORT_SCHEDULE.
"""
dashboard: Dashboard = self.make_dashboard("firstmatch")
report: ReportSchedule = ReportSchedule(
type="Report",
name="retention_it_firstmatch",
crontab="0 0 * * *",
dashboard=dashboard,
)
db.session.add(report)
db.session.commit()
dashboard_uuid: str = str(dashboard.uuid)
self.soft_delete(dashboard, days_ago=90)
attribute: UserAttribute
created: bool
previous: int | None
attribute, created, previous = self._set_welcome(dashboard.id)
try:
_purge(window=30)
row: audit.PurgeAuditLog = (
db.session.query(audit.PurgeAuditLog)
.filter_by(entity_uuid=dashboard_uuid)
.one()
)
assert row.status == audit.STATUS_BLOCKED
assert row.reason == REASON_REPORT_SCHEDULE
finally:
self._restore_welcome(attribute, created, previous)
def test_policy_action_failure_rolls_back_prior_phases(self) -> None:
"""A later policy-action failure restores earlier association cleanup."""
chart: Slice = self.make_chart("action_rollback")
@@ -0,0 +1,154 @@
# 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.
"""Unit tests for CreateChartCommand.
Regression coverage for apache/superset#29697: POST /api/v1/chart/ with
datasource_type="saved_query" (or "query") crashes with an unhandled
AttributeError -- reported to API clients as an opaque 500 "Fatal error" --
because SavedQuery and Query models have no ``.name`` attribute, and because
Slice.datasource only ever resolves a ``table``-typed datasource, so even a
successfully created chart of another type could never actually render.
"""
import pytest
from pytest_mock import MockerFixture
from superset.commands.chart.create import CreateChartCommand
from superset.commands.chart.exceptions import ChartForbiddenError, ChartInvalidError
from superset.commands.exceptions import DatasourceTypeInvalidError
from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
from superset.exceptions import SupersetSecurityException
def _base_mocks(mocker: MockerFixture) -> None:
mocker.patch(
"superset.commands.chart.create.DashboardDAO.find_by_ids", return_value=[]
)
mocker.patch(
"superset.commands.chart.create.populate_subjects",
side_effect=lambda properties, exceptions: None,
)
@pytest.mark.parametrize("datasource_type", ["saved_query", "query"])
def test_create_chart_rejects_non_table_datasource_type(
mocker: MockerFixture, datasource_type: str
) -> None:
"""A chart can only ever query a table-backed datasource -- Slice.datasource
only ever resolves the ``table`` relationship, so any other type would
produce a chart that "creates" successfully but can never render.
The two types fail differently before this fix, which is exactly why
both are covered here:
- "saved_query": SavedQuery has no ``.name`` attribute, so validation
crashes with an unhandled AttributeError -- surfaced to API clients as
an opaque 500 "Fatal error" (apache/superset#29697).
- "query": Query *does* define a synthetic ``.name`` property (used for
CTAS table naming, not as a real display name), so this one doesn't
crash -- it silently "succeeds" and creates a chart with a nonsense
name and a datasource that Slice.datasource can never resolve.
``get_datasource_by_id`` is mocked with ``spec=`` the real model classes
so accessing ``.name`` on the mock behaves exactly like the real ORM
objects do if the new guard doesn't stop the code from getting there;
``raise_for_access`` is mocked to a no-op so nothing downstream masks
that behavior.
"""
from superset.models.sql_lab import Query, SavedQuery
_base_mocks(mocker)
model_cls = SavedQuery if datasource_type == "saved_query" else Query
get_datasource_by_id = mocker.patch(
"superset.commands.chart.create.get_datasource_by_id",
return_value=mocker.MagicMock(spec=model_cls),
)
mocker.patch("superset.commands.chart.create.security_manager.raise_for_access")
with pytest.raises(ChartInvalidError) as exc_info:
CreateChartCommand(
{
"datasource_id": 11,
"datasource_type": datasource_type,
"slice_name": "some_name",
"viz_type": "table",
}
).validate()
assert any(
isinstance(ex, DatasourceTypeInvalidError) for ex in exc_info.value._exceptions
)
# The invalid type must be rejected before ever touching the datasource
# lookup, not caught incidentally by some downstream failure.
get_datasource_by_id.assert_not_called()
def test_create_chart_accepts_table_datasource(mocker: MockerFixture) -> None:
"""The one supported datasource_type must keep working."""
_base_mocks(mocker)
datasource = mocker.MagicMock(name="table_datasource")
datasource.name = "my_table"
mocker.patch(
"superset.commands.chart.create.get_datasource_by_id",
return_value=datasource,
)
mocker.patch("superset.commands.chart.create.security_manager.raise_for_access")
cmd = CreateChartCommand(
{
"datasource_id": 11,
"datasource_type": "table",
"slice_name": "some_name",
"viz_type": "table",
}
)
cmd.validate()
assert cmd._properties["datasource_name"] == "my_table"
def test_create_chart_datasource_access_denied_still_raises_forbidden(
mocker: MockerFixture,
) -> None:
"""The invalid-type guard must not shadow the existing access-denied path
for a legitimately table-typed datasource the user can't access."""
_base_mocks(mocker)
datasource = mocker.MagicMock()
datasource.name = "my_table"
mocker.patch(
"superset.commands.chart.create.get_datasource_by_id",
return_value=datasource,
)
mocker.patch(
"superset.commands.chart.create.security_manager.raise_for_access",
side_effect=SupersetSecurityException(
SupersetError(
error_type=SupersetErrorType.DATASOURCE_SECURITY_ACCESS_ERROR,
message="No access",
level=ErrorLevel.ERROR,
)
),
)
with pytest.raises(ChartForbiddenError):
CreateChartCommand(
{
"datasource_id": 11,
"datasource_type": "table",
"slice_name": "some_name",
"viz_type": "table",
}
).validate()
+74 -1
View File
@@ -17,8 +17,13 @@
import pytest
from pytest_mock import MockerFixture
from superset.commands.chart.exceptions import ChartForbiddenError, ChartInvalidError
from superset.commands.chart.exceptions import (
ChartForbiddenError,
ChartInvalidError,
DatasourceTypeUpdateRequiredValidationError,
)
from superset.commands.chart.update import UpdateChartCommand
from superset.commands.exceptions import DatasourceTypeInvalidError
from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
from superset.exceptions import SupersetSecurityException
from superset.utils import json
@@ -238,3 +243,71 @@ def test_update_chart_query_context_without_datasource_is_allowed(
1,
{"query_context": query_context, "query_context_generation": True},
).validate()
@pytest.mark.parametrize("datasource_type", ["saved_query", "query"])
def test_update_chart_rejects_repointing_to_non_table_datasource(
mocker: MockerFixture, datasource_type: str
) -> None:
"""Repointing a chart's datasource_id must be rejected the same way
CreateChartCommand rejects it (apache/superset#29697): Slice.datasource
only ever resolves the ``table`` relationship, so repointing at a
saved_query or query datasource would "succeed" but leave the chart
permanently unable to render -- or, for saved_query specifically, crash
on SavedQuery's missing ``.name`` attribute before that point is even
reached. This is a regular (non-query-context) update, so it goes
through editorship + compute_subjects, unlike the query-context-only
tests above."""
find_by_id = mocker.patch("superset.commands.chart.update.ChartDAO.find_by_id")
find_by_id.return_value = mocker.MagicMock(id=1, tags=[], dashboards=[])
mocker.patch("superset.commands.chart.update.security_manager.raise_for_editorship")
mocker.patch(
"superset.commands.chart.update.compute_subjects",
side_effect=lambda model, properties, exceptions: None,
)
get_datasource_by_id = mocker.patch(
"superset.commands.chart.update.get_datasource_by_id"
)
with pytest.raises(ChartInvalidError) as exc_info:
UpdateChartCommand(
1, {"datasource_id": 11, "datasource_type": datasource_type}
).validate()
assert any(
isinstance(ex, DatasourceTypeInvalidError) for ex in exc_info.value._exceptions
)
get_datasource_by_id.assert_not_called()
def test_update_chart_missing_datasource_type_keeps_required_error(
mocker: MockerFixture,
) -> None:
"""When datasource_id is given without datasource_type, the response
must keep reporting DatasourceTypeUpdateRequiredValidationError
("Datasource type is required") rather than having it overwritten by
DatasourceTypeInvalidError ("Datasource type is invalid") -- both
exceptions key their message under ``datasource_type``, and
normalized_messages() only keeps the last one written for a given key."""
find_by_id = mocker.patch("superset.commands.chart.update.ChartDAO.find_by_id")
find_by_id.return_value = mocker.MagicMock(id=1, tags=[], dashboards=[])
mocker.patch("superset.commands.chart.update.security_manager.raise_for_editorship")
mocker.patch(
"superset.commands.chart.update.compute_subjects",
side_effect=lambda model, properties, exceptions: None,
)
get_datasource_by_id = mocker.patch(
"superset.commands.chart.update.get_datasource_by_id"
)
with pytest.raises(ChartInvalidError) as exc_info:
UpdateChartCommand(1, {"datasource_id": 11}).validate()
assert any(
isinstance(ex, DatasourceTypeUpdateRequiredValidationError)
for ex in exc_info.value._exceptions
)
assert not any(
isinstance(ex, DatasourceTypeInvalidError) for ex in exc_info.value._exceptions
)
get_datasource_by_id.assert_not_called()
@@ -1,182 +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.
"""Golden-set tests pinning the purge-audit reason-code vocabulary."""
from __future__ import annotations
from dataclasses import replace
from unittest.mock import MagicMock
import pytest
from superset.commands.deletion_retention.purge_policy import (
ALL_REASON_CODES,
DependencyClassification,
DependencyPolicy,
get_purge_policy,
purge_policy_registry,
PurgeBlockedError,
PurgeEntityPolicy,
REASON_CASCADE_INTEGRITY_FAILURE,
REASON_REPORT_SCHEDULE,
REASON_USER_ATTRIBUTE,
validate_deletion_allowed,
)
def test_reason_code_literals_are_frozen() -> None:
"""The persisted code values are frozen identifiers.
Audit history and the suppression predicate compare these exact strings;
a physical table rename or constant refactor must not re-mint them. If
this test fails, the fix is to restore the literal, never to update the
expectation.
"""
assert REASON_REPORT_SCHEDULE == "report_schedule"
assert REASON_USER_ATTRIBUTE == "user_attribute"
assert REASON_CASCADE_INTEGRITY_FAILURE == "cascade_integrity_failure"
assert ALL_REASON_CODES == {
"report_schedule",
"user_attribute",
"cascade_integrity_failure",
}
def test_reason_codes_are_distinct_and_column_sized() -> None:
"""Codes are mutually distinct and fit the String(64) audit column."""
codes: list[str] = [
REASON_REPORT_SCHEDULE,
REASON_USER_ATTRIBUTE,
REASON_CASCADE_INTEGRITY_FAILURE,
]
assert len(set(codes)) == len(codes)
assert all(0 < len(code) <= 64 for code in ALL_REASON_CODES)
def test_every_declared_blocker_code_is_in_the_closed_set() -> None:
"""Each blocker declared in the registry carries a code from ALL_REASON_CODES."""
blocker_codes: set[str] = set()
for policy in purge_policy_registry().values():
for dependency in policy.dependencies:
if dependency.classification is DependencyClassification.BLOCK:
assert dependency.blocker is not None, (
f"blocker {dependency.key.describe()} has no reason code"
)
blocker_codes.add(dependency.blocker.code)
assert blocker_codes <= ALL_REASON_CODES
assert blocker_codes == {REASON_REPORT_SCHEDULE, REASON_USER_ATTRIBUTE}
def test_cascade_integrity_failure_code_is_reserved_for_the_cascade() -> None:
"""No declared policy blocker may claim the cascade-failure code."""
for policy in purge_policy_registry().values():
for dependency in policy.dependencies:
assert (
dependency.blocker is None
or dependency.blocker.code != REASON_CASCADE_INTEGRITY_FAILURE
)
def _session_matching_blockers(*matches: bool) -> MagicMock:
"""A mock session whose Nth blocker query reports a match iff matches[N].
Deliberately positional: which blocker matches first is the audit
contract under test, so these cases are coupled to the order (and the
count) of the queries ``validate_deletion_allowed`` issues.
"""
session: MagicMock = MagicMock()
session.execute.side_effect = [
MagicMock(first=MagicMock(return_value=(1,) if match else None))
for match in matches
]
return session
def test_report_block_raises_with_the_report_schedule_code() -> None:
"""A chart blocked by a report reference carries REASON_REPORT_SCHEDULE."""
# avoid app-init regression: superset.models.* evaluates
# encrypted_field_factory at class-definition time, which fails
# in a partial-collection unit run with no Flask app active.
from superset.models.slice import Slice
info: pytest.ExceptionInfo[PurgeBlockedError]
with pytest.raises(PurgeBlockedError) as info:
validate_deletion_allowed(
_session_matching_blockers(True), get_purge_policy(Slice), 1
)
assert info.value.reason_code == REASON_REPORT_SCHEDULE
assert str(info.value) == "associated alerts or reports exist"
def test_welcome_dashboard_block_raises_with_the_user_attribute_code() -> None:
"""A welcome-page block carries a code distinct from the report code."""
# avoid app-init regression: superset.models.* evaluates
# encrypted_field_factory at class-definition time, which fails
# in a partial-collection unit run with no Flask app active.
from superset.models.dashboard import Dashboard
info: pytest.ExceptionInfo[PurgeBlockedError]
with pytest.raises(PurgeBlockedError) as info:
validate_deletion_allowed(
_session_matching_blockers(False, True), get_purge_policy(Dashboard), 1
)
assert info.value.reason_code == REASON_USER_ATTRIBUTE
def test_reason_code_survives_a_related_table_rename() -> None:
"""A renamed table keeps the blocker's declared code.
The code is declared on the blocker, never derived from the physical
table name, so a schema rename changes only which table the blocker
looks at persisted audit history and the suppression predicate keep
comparing the same literal.
"""
# avoid app-init regression: superset.models.* evaluates
# encrypted_field_factory at class-definition time, which fails
# in a partial-collection unit run with no Flask app active.
from superset.models.slice import Slice
policy: PurgeEntityPolicy = get_purge_policy(Slice)
renamed: tuple[DependencyPolicy, ...] = tuple(
replace(dependency, key=replace(dependency.key, related_table="reports_v2"))
if dependency.classification is DependencyClassification.BLOCK
else dependency
for dependency in policy.dependencies
)
blocker: DependencyPolicy = next(
dependency
for dependency in renamed
if dependency.classification is DependencyClassification.BLOCK
)
assert blocker.key.related_table == "reports_v2"
assert blocker.blocker is not None
assert blocker.blocker.code == REASON_REPORT_SCHEDULE
def test_first_declared_blocker_wins_when_several_match() -> None:
"""A dashboard matching both blockers records the first-declared code."""
# avoid app-init regression: superset.models.* evaluates
# encrypted_field_factory at class-definition time, which fails
# in a partial-collection unit run with no Flask app active.
from superset.models.dashboard import Dashboard
info: pytest.ExceptionInfo[PurgeBlockedError]
with pytest.raises(PurgeBlockedError) as info:
validate_deletion_allowed(
_session_matching_blockers(True, True), get_purge_policy(Dashboard), 1
)
assert info.value.reason_code == REASON_REPORT_SCHEDULE
@@ -68,10 +68,11 @@ def test_dashboard_import_with_overwrite_replaces_charts(
}
ImportDashboardsCommand._import(initial_configs, overwrite=True)
# Commit between imports, as production does: ``run()`` carries
# ``@transaction()``, so two imports are two transactions. Without this the
# add and the remove of one association share a Continuum transaction and
# collide on ``dashboard_slices_version``'s composite key — an artifact of
# the test's shortcut, not a reachable production state.
# ``@transaction()``, so two imports are two transactions. Calling the
# private ``_import`` twice without committing puts both in one Continuum
# transaction, where adding and removing the same association collides on
# ``dashboard_slices_version``'s (dashboard_id, slice_id, transaction_id)
# key — an artifact of the test's shortcut, not a reachable state.
db.session.commit()
# Verify initial state: 2 charts associated with the dashboard
@@ -557,44 +557,6 @@ def test_extra_validator_accepts_catalog_cache_timeout() -> None:
assert extra["metadata_cache_timeout"]["catalog_cache_timeout"] == 600
def test_extra_validator_interpolates_invalid_metadata_params_key() -> None:
"""
The message names the offending key. It is built with a lazy translated
string, so a malformed placeholder would only fail once the message is
rendered; asserting on the rendered text pins the interpolation.
"""
from superset.databases.schemas import DatabasePostSchema
schema = DatabasePostSchema()
payload = {
"database_name": "test_db",
"extra": json.dumps({"metadata_params": {"not_a_metadata_arg": 1}}),
}
with pytest.raises(ValidationError) as exc_info:
schema.load(payload)
message = str(exc_info.value)
assert "not_a_metadata_arg" in message
assert "%(" not in message
def test_extra_validator_interpolates_json_decode_error() -> None:
"""
As above, for the message raised when ``extra`` is not decodable JSON.
"""
from superset.databases.schemas import DatabasePostSchema
schema = DatabasePostSchema()
payload = {"database_name": "test_db", "extra": "{not json"}
with pytest.raises(ValidationError) as exc_info:
schema.load(payload)
message = str(exc_info.value)
# Assert on the interpolated value rather than the surrounding wording. The
# value comes from json.JSONDecodeError, which is not translated, so this
# stays valid under any locale.
assert "line 1 column" in message
assert "%(" not in message
def test_cache_timeout_rejects_values_below_minus_one() -> None:
"""
Test that cache_timeout rejects values less than -1.
@@ -229,12 +229,7 @@ def _generate_gis_type_sanitization_test_cases() -> list[
if not ocient_is_installed():
return []
from pyocient import TypeCodes
from pyocient.api import (
STLinestring as _STLinestring,
STPoint as _STPoint,
STPolygon as _STPolygon,
)
from pyocient import _STLinestring, _STPoint, _STPolygon, TypeCodes
return [
(
@@ -301,7 +296,7 @@ def _generate_gis_type_sanitization_test_cases() -> list[
(
"empty_polygon",
TypeCodes.ST_POLYGON,
_STPolygon(exterior=[], holes=[], fullFlag=False),
_STPolygon(exterior=[], holes=[]),
{
"geometry": None,
"properties": {},
@@ -316,7 +311,6 @@ def _generate_gis_type_sanitization_test_cases() -> list[
_STPoint(long=t[0], lat=t[1]) for t in [(1, 0), (1, 1), (1, 0)]
],
holes=[],
fullFlag=False,
),
{
"geometry": {
@@ -338,7 +332,6 @@ def _generate_gis_type_sanitization_test_cases() -> list[
[_STPoint(long=t[0], lat=t[1]) for t in [(2, 0), (2, 1), (2, 0)]],
[_STPoint(long=t[0], lat=t[1]) for t in [(3, 0), (3, 1), (3, 0)]],
],
fullFlag=False,
),
{
"geometry": {
@@ -359,7 +352,6 @@ def _generate_gis_type_sanitization_test_cases() -> list[
_STPolygon(
exterior=[_STPoint(long=t[0], lat=t[1]) for t in [(1, 0)]],
holes=[],
fullFlag=False,
),
{
"geometry": {
@@ -376,7 +368,6 @@ def _generate_gis_type_sanitization_test_cases() -> list[
_STPolygon(
exterior=[_STPoint(long=t[0], lat=t[1]) for t in [(1, 0), (0, 1)]],
holes=[],
fullFlag=False,
),
{
"geometry": {
@@ -409,7 +400,7 @@ def test_gis_type_sanitization(
@pytest.mark.skipif(not ocient_is_installed(), reason="requires ocient dependencies")
def test_point_list_to_wkt() -> None:
from pyocient.api import STPoint as _STPoint
from pyocient import _STPoint
wkt = _point_list_to_wkt(
[_STPoint(long=t[0], lat=t[1]) for t in [(2, 0), (2, 1), (2, 0)]]
@@ -479,25 +479,6 @@ class TestMapTableConfig:
assert result["row_limit"] == 500
def test_map_table_config_supports_null_filter(self) -> None:
config = TableChartConfig(
chart_type="table",
columns=[ColumnRef(name="optional_value")],
filters=[FilterConfig(column="optional_value", op="IS NOT NULL")],
)
result = map_table_config(config)
assert result["adhoc_filters"] == [
{
"clause": "WHERE",
"expressionType": "SIMPLE",
"subject": "optional_value",
"operator": "IS NOT NULL",
"comparator": None,
}
]
def test_map_table_config_default_row_limit(self) -> None:
"""Test that default row_limit is mapped to form_data."""
config = TableChartConfig(
@@ -705,23 +686,6 @@ class TestMapXYConfig:
assert result["show_legend"] is False
assert result["legendOrientation"] == "top"
def test_map_xy_config_with_legend_orientation(self) -> None:
config = XYChartConfig.model_validate(
{
"chart_type": "xy",
"x": {"name": "date"},
"y": [{"name": "revenue", "aggregate": "SUM"}],
"show_legend": True,
"legend_orientation": "bottom",
}
)
result = map_xy_config(config)
assert config.legend is not None
assert config.legend.show is True
assert result["legendOrientation"] == "bottom"
def test_map_xy_config_with_color_scheme(self) -> None:
"""color_scheme propagates to form_data when set."""
config = XYChartConfig(
@@ -188,13 +188,6 @@ class TestGenerateChart:
for i, f in enumerate(filters):
assert f.op == operators[i]
null_filter = FilterConfig(column="optional_value", op="IS NOT NULL")
assert null_filter.value is None
with pytest.raises(ValueError, match="must not have 'value'"):
FilterConfig(column="optional_value", op="IS NULL", value="unexpected")
with pytest.raises(ValueError, match="requires 'value'"):
FilterConfig(column="optional_value", op="=")
@pytest.mark.asyncio
async def test_generate_chart_response_structure(self):
"""Test the expected response structure for chart generation."""
@@ -313,10 +306,6 @@ class TestGenerateChart:
assert col2.aggregate == "SUM"
assert col2.label == "Total Sales"
aliased = ColumnRef.model_validate({"column": "sales", "aggregate": "AVG"})
assert aliased.name == "sales"
assert aliased.aggregate == "AVG"
# All supported aggregations
aggs = ["SUM", "AVG", "COUNT", "MIN", "MAX", "COUNT_DISTINCT"]
for agg in aggs:
@@ -1,74 +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 pytest
from superset.mcp_service.chart.schemas import ColumnRef
from superset.mcp_service.chart.validation.dataset_validator import DatasetValidator
from superset.mcp_service.common.error_schemas import (
ChartGenerationError,
DatasetContext,
)
def _validate_sum(sql_type: str) -> list[ChartGenerationError]:
context = DatasetContext(
id=69,
table_name="virtual_metrics",
schema=None,
database_name="database",
available_columns=[
{"name": "computed_total", "type": sql_type, "is_numeric": False}
],
available_metrics=[],
)
return DatasetValidator._validate_aggregations(
[ColumnRef(name="computed_total", aggregate="SUM")], context
)
@pytest.mark.parametrize(
"sql_type",
[
"BIGINT",
"SMALLINT",
"TINYINT",
"REAL",
"NUMBER",
"DOUBLE PRECISION",
"INT8",
"FLOAT8",
"DECIMAL(10, 2)",
"MONEY",
"SMALLMONEY",
],
)
def test_numeric_type_spelling_is_accepted(sql_type: str) -> None:
assert _validate_sum(sql_type) == []
@pytest.mark.parametrize("sql_type", ["", "UNKNOWN"])
def test_unknown_type_is_deferred_to_compile_check(sql_type: str) -> None:
assert _validate_sum(sql_type) == []
@pytest.mark.parametrize("sql_type", ["VARCHAR", "INTERVAL", "POINT"])
def test_non_numeric_type_is_rejected_for_numeric_aggregation(
sql_type: str,
) -> None:
assert _validate_sum(sql_type)[0].error_type == "invalid_aggregation"