mirror of
https://github.com/apache/superset.git
synced 2026-09-10 01:04:25 +00:00
Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b46983127 | ||
|
|
b02c7afa00 | ||
|
|
bdae45fee3 | ||
|
|
af2563f9c3 | ||
|
|
731a97dbe6 | ||
|
|
c915e63aa4 | ||
|
|
4edf7b1005 | ||
|
|
2030adb5ce | ||
|
|
09ae6fa3c3 | ||
|
|
638ad7980c | ||
|
|
37c6037452 | ||
|
|
71e6c5f3cd | ||
|
|
4e4cdf421c | ||
|
|
bc057813ae | ||
|
|
3f780c503e | ||
|
|
62fa7a371b | ||
|
|
e4440b614c | ||
|
|
a9424afb4e | ||
|
|
b75f629286 |
@@ -107,6 +107,12 @@ jobs:
|
||||
needs.changes.outputs.docker == 'true'
|
||||
runs-on: ubuntu-26.04
|
||||
timeout-minutes: 60
|
||||
permissions:
|
||||
contents: read
|
||||
# Required for the vulnerability scan below to upload its SARIF
|
||||
# results to GitHub code scanning (advanced-security is enabled by
|
||||
# default), matching the same grant in github-action-validator.yml.
|
||||
security-events: write
|
||||
strategy:
|
||||
matrix:
|
||||
build_preset: ${{fromJson(needs.setup_matrix.outputs.matrix_config)}}
|
||||
@@ -213,6 +219,39 @@ jobs:
|
||||
docker images $IMAGE_TAG
|
||||
docker history $IMAGE_TAG
|
||||
|
||||
# Scan the built image for known vulnerabilities and publish results
|
||||
# to the Security tab. Trivy did this until #38780 removed it: both
|
||||
# aquasecurity/trivy-action and the trivy binary itself were
|
||||
# compromised (twice) to steal GitHub Secrets from CI runs. Grype is a
|
||||
# different tool from a different maintainer with no shared supply
|
||||
# chain, and is already on the ASF Infra GitHub Actions allowlist.
|
||||
- name: Scan built image for vulnerabilities
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/master' && matrix.build_preset == 'lean'
|
||||
id: grype-scan
|
||||
# This step's own failure (e.g. a transient issue pulling the Grype
|
||||
# vulnerability DB) must not fail docker-build, matching the
|
||||
# informational fail-build: false below -- one is findings, the
|
||||
# other is the scan itself not completing.
|
||||
continue-on-error: true
|
||||
uses: anchore/scan-action@27805bf3b4e84b4a5c980df22ed233c00390a439 # v7.4.2
|
||||
with:
|
||||
image: ${{ env.IMAGE_TAG }}
|
||||
output-format: sarif
|
||||
severity-cutoff: high
|
||||
only-fixed: true
|
||||
# Informational only, matching the prior Trivy setup: this
|
||||
# workflow does not gate merges on scan findings.
|
||||
fail-build: false
|
||||
|
||||
- name: Upload vulnerability scan results to GitHub Security tab
|
||||
if: >-
|
||||
github.event_name == 'push' && github.ref == 'refs/heads/master' &&
|
||||
matrix.build_preset == 'lean' && steps.grype-scan.outputs.sarif != ''
|
||||
continue-on-error: true
|
||||
uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
|
||||
with:
|
||||
sarif_file: ${{ steps.grype-scan.outputs.sarif }}
|
||||
|
||||
- name: WebSocket server smoke test
|
||||
if: contains(fromJson('["lean", "dev"]'), matrix.build_preset)
|
||||
shell: bash
|
||||
|
||||
@@ -19,6 +19,19 @@ on:
|
||||
required: false
|
||||
type: string
|
||||
|
||||
# Triggers on labeled/unlabeled/synchronize/closed -- far more events per PR than
|
||||
# typical CI's synchronize-only trigger. Without this, every event queues its own
|
||||
# run and nothing ever supersedes a stale one, so runs pile up indefinitely on
|
||||
# active PRs (observed: 6 simultaneously queued runs for a single PR) and end up
|
||||
# starved competing for the same runner pool as everything else. cancel-in-progress
|
||||
# is correct here, not just a queue-relief hack: this job's whole purpose is
|
||||
# reconciling to the *current* desired state, so a run still working toward an
|
||||
# already-superseded state is wasted work regardless of whether it's queued or
|
||||
# mid-sync.
|
||||
concurrency:
|
||||
group: showtime-${{ github.event.pull_request.number || github.event.inputs.pr_number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
# Common environment variables for all jobs (non-sensitive only)
|
||||
env:
|
||||
AWS_REGION: us-west-2
|
||||
|
||||
@@ -178,7 +178,13 @@ jobs:
|
||||
with:
|
||||
run: playwright-run "${{ matrix.app_root }}" experimental/
|
||||
- name: Run Playwright (Mobile Tests)
|
||||
uses: ./.github/actions/cached-dependencies
|
||||
# cached-dependencies is a submodule (not a plain directory), and
|
||||
# the $/ self-repository syntax resolves action files directly from
|
||||
# the repository without performing a real (submodule-aware)
|
||||
# checkout, so it can't see into a submodule's link. Keep this one
|
||||
# on the workspace-relative ./ form, consistent with every other
|
||||
# workflow in the repo that references this action.
|
||||
uses: ./.github/actions/cached-dependencies # zizmor: ignore[self-repository] - $/ cannot resolve an action that lives in a submodule; ./ is required here
|
||||
env:
|
||||
NODE_OPTIONS: "--max-old-space-size=4096"
|
||||
# Scoped to this step: setting feature flags at the job level would
|
||||
|
||||
@@ -87,7 +87,13 @@ jobs:
|
||||
with:
|
||||
run: setup-mysql
|
||||
- name: Start Celery worker
|
||||
uses: ./.github/actions/cached-dependencies
|
||||
# cached-dependencies is a git submodule (not a plain directory), and
|
||||
# the $/ self-repository syntax resolves action files directly from
|
||||
# the repository without performing a real (submodule-aware)
|
||||
# checkout, so it can't see into a submodule's gitlink. Keep this one
|
||||
# on the workspace-relative ./ form, consistent with every other
|
||||
# workflow in the repo that references this action.
|
||||
uses: ./.github/actions/cached-dependencies # zizmor: ignore[self-repository] - $/ cannot resolve an action that lives in a submodule; ./ is required here
|
||||
with:
|
||||
run: celery-worker
|
||||
- name: Python integration tests (MySQL)
|
||||
@@ -166,16 +172,28 @@ jobs:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
- name: Setup Python
|
||||
uses: ./.github/actions/setup-backend/
|
||||
uses: $/.github/actions/setup-backend/
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- name: Setup Postgres
|
||||
uses: ./.github/actions/cached-dependencies
|
||||
# cached-dependencies is a git submodule (not a plain directory), and
|
||||
# the $/ self-repository syntax resolves action files directly from
|
||||
# the repository without performing a real (submodule-aware)
|
||||
# checkout, so it can't see into a submodule's gitlink. Keep this one
|
||||
# on the workspace-relative ./ form, consistent with every other
|
||||
# workflow in the repo that references this action.
|
||||
uses: ./.github/actions/cached-dependencies # zizmor: ignore[self-repository] - $/ cannot resolve an action that lives in a submodule; ./ is required here
|
||||
with:
|
||||
run: |
|
||||
setup-postgres
|
||||
- name: Start Celery worker
|
||||
uses: ./.github/actions/cached-dependencies
|
||||
# cached-dependencies is a git submodule (not a plain directory), and
|
||||
# the $/ self-repository syntax resolves action files directly from
|
||||
# the repository without performing a real (submodule-aware)
|
||||
# checkout, so it can't see into a submodule's gitlink. Keep this one
|
||||
# on the workspace-relative ./ form, consistent with every other
|
||||
# workflow in the repo that references this action.
|
||||
uses: ./.github/actions/cached-dependencies # zizmor: ignore[self-repository] - $/ cannot resolve an action that lives in a submodule; ./ is required here
|
||||
with:
|
||||
run: celery-worker
|
||||
- name: Python integration tests (PostgreSQL)
|
||||
@@ -230,7 +248,13 @@ jobs:
|
||||
# sqlite needs this working directory
|
||||
mkdir ${{ github.workspace }}/.temp
|
||||
- name: Start Celery worker
|
||||
uses: ./.github/actions/cached-dependencies
|
||||
# cached-dependencies is a git submodule (not a plain directory), and
|
||||
# the $/ self-repository syntax resolves action files directly from
|
||||
# the repository without performing a real (submodule-aware)
|
||||
# checkout, so it can't see into a submodule's gitlink. Keep this one
|
||||
# on the workspace-relative ./ form, consistent with every other
|
||||
# workflow in the repo that references this action.
|
||||
uses: ./.github/actions/cached-dependencies # zizmor: ignore[self-repository] - $/ cannot resolve an action that lives in a submodule; ./ is required here
|
||||
with:
|
||||
run: celery-worker
|
||||
- name: Python integration tests (SQLite)
|
||||
|
||||
@@ -92,7 +92,13 @@ jobs:
|
||||
with:
|
||||
run: setup-postgres
|
||||
- name: Start Celery worker
|
||||
uses: ./.github/actions/cached-dependencies
|
||||
# cached-dependencies is a submodule (not a plain directory), and
|
||||
# the $/ self-repository syntax resolves action files directly from
|
||||
# the repository without performing a real (submodule-aware)
|
||||
# checkout, so it can't see into a submodule's link. Keep this one
|
||||
# on the workspace-relative ./ form, consistent with every other
|
||||
# workflow in the repo that references this action.
|
||||
uses: ./.github/actions/cached-dependencies # zizmor: ignore[self-repository] - $/ cannot resolve an action that lives in a submodule; ./ is required here
|
||||
with:
|
||||
run: celery-worker
|
||||
- name: Python unit tests (PostgreSQL)
|
||||
@@ -149,7 +155,13 @@ jobs:
|
||||
- name: Setup Python
|
||||
uses: $/.github/actions/setup-backend/
|
||||
- name: Setup Postgres
|
||||
uses: ./.github/actions/cached-dependencies
|
||||
# cached-dependencies is a submodule (not a plain directory), and
|
||||
# the $/ self-repository syntax resolves action files directly from
|
||||
# the repository without performing a real (submodule-aware)
|
||||
# checkout, so it can't see into a submodule's link. Keep this one
|
||||
# on the workspace-relative ./ form, consistent with every other
|
||||
# workflow in the repo that references this action.
|
||||
uses: ./.github/actions/cached-dependencies # zizmor: ignore[self-repository] - $/ cannot resolve an action that lives in a submodule; ./ is required here
|
||||
with:
|
||||
run: setup-postgres
|
||||
- name: Start Celery worker
|
||||
|
||||
+12
@@ -24,6 +24,18 @@ assists people when migrating to a new version.
|
||||
|
||||
## Next
|
||||
|
||||
### Resample "Fill the entire time range"
|
||||
|
||||
Charts with Resample can enable **Fill the entire time range** so gap-filling
|
||||
covers the full queried window (`from_dttm` / `to_dttm`), not only between the
|
||||
first and last returned data points. Existing charts are unchanged until the
|
||||
control is turned on.
|
||||
|
||||
Resample projections remain capped by `MAX_RESAMPLE_ROWS` (default
|
||||
`1_000_000`). That cap now also covers calendar frequencies (month, quarter,
|
||||
year, …) that previously skipped the check because they have no fixed
|
||||
`Timedelta`.
|
||||
|
||||
### Tagging is on by default
|
||||
|
||||
`TAGGING_SYSTEM` now ships **on**. The Tags menu entry, the tag columns and
|
||||
|
||||
@@ -145,6 +145,46 @@ D3_TIME_FORMAT = {
|
||||
Restart Superset after changing `superset_config.py` so the frontend receives
|
||||
the updated formatter configuration.
|
||||
|
||||
## Serving translated language packs
|
||||
|
||||
Non-English page loads need the frontend translation catalog (the "language
|
||||
pack") available before the entry bundle runs, so translations are in place
|
||||
for the very first render instead of racing a later fetch. Superset delivers
|
||||
that pack as a separate, cacheable script rather than inlining it into the
|
||||
HTML:
|
||||
|
||||
```html
|
||||
<script src="/language_pack/pt_BR/1a2b3c4d5e6f/script.js"></script>
|
||||
```
|
||||
|
||||
`spa.html` emits this tag, pointing at the `language_pack_script` view, before
|
||||
loading the entry bundle whenever the request's locale isn't English. The
|
||||
`<version>` segment is a short hash of the pack's contents, so the URL is
|
||||
content-addressed:
|
||||
|
||||
- When the version in the URL matches the server's current pack, the response
|
||||
carries `Cache-Control: public, max-age=31536000, immutable` — the browser
|
||||
fetches that language's pack once and reuses it across sessions.
|
||||
- If a cached HTML page references a version that's since changed (e.g. after
|
||||
a translation update or upgrade), the endpoint still serves the current
|
||||
pack, but with `Cache-Control: no-cache` so any copy stored under the
|
||||
now-stale URL must be revalidated with the server before it's reused.
|
||||
- English pages emit no script tag; there's no pack to load.
|
||||
|
||||
Each worker caches a locale's pack and version hash in memory for its
|
||||
lifetime, so a translation file changed on disk isn't picked up, and doesn't
|
||||
produce a new version hash, until the worker restarts. Restart (or roll)
|
||||
Superset after deploying a translation update so clients get the new pack.
|
||||
|
||||
This endpoint is intentionally unauthenticated. Translation catalogs are
|
||||
static, public content shipped in the Superset repo, and the login page and
|
||||
embedded dashboards need them to load before a user session exists.
|
||||
|
||||
If you already override the language pack via `COMMON_BOOTSTRAP_OVERRIDES_FUNC`
|
||||
(a `common.language_pack` value, historically used to work around translation
|
||||
race conditions), that override still takes precedence: `spa.html` skips the
|
||||
script tag and uses your supplied pack instead.
|
||||
|
||||
## Chart-data query timing
|
||||
|
||||
Set `CHART_DATA_INCLUDE_TIMING = True` to add an optional versioned timing object
|
||||
|
||||
@@ -342,6 +342,12 @@ the median value within the seven daily data points. For more information on the
|
||||
various options in this section, refer to the
|
||||
[Pandas documentation](https://pandas.pydata.org/pandas-docs/stable/reference/api/pandas.DataFrame.resample.html).
|
||||
|
||||
By default, resampling only fills the periods between the first and the last data point returned by
|
||||
the query. If your data starts after the beginning of the selected time range, or stops before its
|
||||
end, enable **Fill the entire time range** to pad the series so it spans the whole range. This is
|
||||
what you want when, for example, you chart hourly counts with Zero imputation and expect empty hours
|
||||
to be drawn as zero across the entire day rather than only around the hours that have data.
|
||||
|
||||
Lastly, save your chart as Tutorial Resample and add it to the Tutorial Dashboard. Go to the
|
||||
tutorial dashboard to see the four charts side by side and compare the different outputs.
|
||||
|
||||
|
||||
@@ -269,26 +269,28 @@ Ask your admin for the MCP server URL and any authentication tokens you need.
|
||||
|
||||
### Charts
|
||||
|
||||
| Tool | Description |
|
||||
| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `list_charts` | List charts with filtering and search |
|
||||
| `get_chart_info` | Get chart metadata and configuration |
|
||||
| `get_chart_data` | Retrieve chart data (JSON, CSV, or Excel) |
|
||||
| `get_chart_preview` | Generate a chart preview (URL, ASCII, table, or Vega-Lite) |
|
||||
| `get_chart_type_schema` | Get the configuration schema for a chart type |
|
||||
| `generate_chart` | Create a new chart from a specification (defaults to preview mode — review before saving) |
|
||||
| `update_chart` | Modify an existing chart's configuration (pass `generate_preview=False` to persist immediately instead of returning a preview URL) |
|
||||
| `update_chart_preview` | Update a cached chart preview without saving |
|
||||
| `generate_explore_link` | Generate an Explore URL for interactive visualization |
|
||||
| Tool | Description |
|
||||
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `list_charts` | List charts with filtering and search |
|
||||
| `get_chart_info` | Get chart metadata and configuration |
|
||||
| `get_chart_data` | Retrieve chart data (JSON, CSV, or Excel) |
|
||||
| `get_chart_preview` | Generate a chart preview (URL, ASCII, table, or Vega-Lite) |
|
||||
| `get_chart_type_schema` | Get the configuration schema for a chart type |
|
||||
| `generate_chart` | Create a new chart from a specification (defaults to preview mode — review before saving) |
|
||||
| `update_chart` | Modify an existing chart's configuration (pass `generate_preview=False` to persist immediately instead of returning a preview URL) |
|
||||
| `update_chart_preview` | Update a cached chart preview without saving |
|
||||
| `generate_explore_link` | Generate an Explore URL for interactive visualization |
|
||||
| `delete_chart` | Delete a chart by ID or UUID (soft-deletes to trash when `SOFT_DELETE` is enabled; fails if alerts/reports are still attached, checked before editorship; otherwise returns `permission_denied` if the caller isn't an editor of the chart — owners, Admins, and explicitly granted editors qualify) |
|
||||
|
||||
### Dashboards
|
||||
|
||||
| Tool | Description |
|
||||
| --------------------------------- | -------------------------------------------- |
|
||||
| `list_dashboards` | List dashboards with filtering and search |
|
||||
| `get_dashboard_info` | Get dashboard metadata and layout |
|
||||
| `generate_dashboard` | Create a new dashboard with specified charts |
|
||||
| `add_chart_to_existing_dashboard` | Add a chart to an existing dashboard |
|
||||
| Tool | Description |
|
||||
| --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `list_dashboards` | List dashboards with filtering and search |
|
||||
| `get_dashboard_info` | Get dashboard metadata and layout |
|
||||
| `generate_dashboard` | Create a new dashboard with specified charts |
|
||||
| `add_chart_to_existing_dashboard` | Add a chart to an existing dashboard |
|
||||
| `delete_dashboard` | Delete a dashboard by ID, UUID, or slug; leaves its charts intact (soft-deletes to trash when `SOFT_DELETE` is enabled; fails if alerts/reports are still attached, checked before editorship; otherwise returns `permission_denied` if the caller isn't an editor of the dashboard — owners, Admins, and explicitly granted editors qualify) |
|
||||
|
||||
### SQL
|
||||
|
||||
|
||||
+5
-1
@@ -198,7 +198,11 @@ excel = ["xlrd>=2.0.2, <2.1"]
|
||||
excel-export = ["boto3"]
|
||||
fastmcp = [
|
||||
"fastmcp>=3.4.7,<4.0",
|
||||
"mcp>=1.29.1,<3.0",
|
||||
# fastmcp 3.x itself requires mcp<2.0; the upper bound here was looser
|
||||
# than that until Dependabot proposed mcp 2.1.1 and the resolver caught
|
||||
# the conflict (#43876). Capped to match what fastmcp 3.x actually
|
||||
# supports until this extra moves to fastmcp 4.x, which accepts mcp 2.x.
|
||||
"mcp>=1.29.1,<2.0",
|
||||
# tiktoken backs the response-size-guard token estimator. Without
|
||||
# it, the middleware falls back to a coarser character-based
|
||||
# heuristic that under-counts JSON-heavy MCP responses.
|
||||
|
||||
@@ -61,6 +61,7 @@ PATTERNS = {
|
||||
"docker": [
|
||||
r"^Dockerfile$",
|
||||
r"^docker.*",
|
||||
r"^\.github/workflows/docker\.yml$",
|
||||
],
|
||||
"docs": [
|
||||
r"^docs/",
|
||||
|
||||
@@ -83,6 +83,10 @@ const disableDevModeInRules = rules =>
|
||||
};
|
||||
});
|
||||
|
||||
function getAbsolutePath(value) {
|
||||
return path.dirname(require.resolve(path.join(value, 'package.json')));
|
||||
}
|
||||
|
||||
export default {
|
||||
stories: [
|
||||
'../src/**/*.stories.tsx',
|
||||
@@ -129,7 +133,3 @@ export default {
|
||||
options: {},
|
||||
}
|
||||
};
|
||||
|
||||
function getAbsolutePath(value) {
|
||||
return path.dirname(require.resolve(path.join(value, 'package.json')));
|
||||
}
|
||||
|
||||
Generated
+81
-81
@@ -255,7 +255,7 @@
|
||||
"minimizer-webpack-plugin": "^5.8.0",
|
||||
"open-cli": "^9.0.0",
|
||||
"oxfmt": "^0.66.0",
|
||||
"oxlint": "^1.80.0",
|
||||
"oxlint": "^1.81.0",
|
||||
"po2json": "^0.4.5",
|
||||
"postcss-styled-syntax": "^0.7.2",
|
||||
"process": "^0.11.10",
|
||||
@@ -10276,9 +10276,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-android-arm-eabi": {
|
||||
"version": "1.80.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.80.0.tgz",
|
||||
"integrity": "sha512-RM3Plj+biQpxa5d1GOOX6ciDlcUROmm4OZ/pLTpitkQt2mJv4jhtY4cbgaetOm5UKWZe05/TGQ6o1Vl8EOHkrA==",
|
||||
"version": "1.81.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.81.0.tgz",
|
||||
"integrity": "sha512-IcCRsXiedJoJopY6mpZUBEeVFsUrutmrG7dZ87zMuKJlhg70Ora9bBl1WcCxZQtyI10YpnVdEso5oCg7YcfSHw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -10293,9 +10293,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-android-arm64": {
|
||||
"version": "1.80.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.80.0.tgz",
|
||||
"integrity": "sha512-YlO5JEf0Yr2bUUlu8O8daVcUxtcGGbcSmyV7E7nSbJbfAdxTE0PFPwgnIlw7wXJaTYjb+qs5hI5q3jxUkI7cAw==",
|
||||
"version": "1.81.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.81.0.tgz",
|
||||
"integrity": "sha512-GRrIPyTGVhx3L3h+0T5xT2A0jFAcdPv4+IfuXpGDLIdl6XeYhgg/zw72A5ILZoUgRqZuM8F1y+V/gfDriXSxzQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -10310,9 +10310,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-darwin-arm64": {
|
||||
"version": "1.80.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.80.0.tgz",
|
||||
"integrity": "sha512-BULDOyO3AhsmdWfQeIUCykDt3dd7XZBGLhp1eIh56skRv01O+cNjNPwXMIbeW1x4+pxcln5if72wcRgViVo7PA==",
|
||||
"version": "1.81.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.81.0.tgz",
|
||||
"integrity": "sha512-qNQ9tXRgLuKbqSV1S2h9h4KPHjbovO7RRR2/enUOtHzTkFZ7B9X5zqqHJua8dRyc7dBy7Aoyq5pqTSLFVcAzGQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -10327,9 +10327,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-darwin-x64": {
|
||||
"version": "1.80.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.80.0.tgz",
|
||||
"integrity": "sha512-YJ4JzLw7N5TDSQFlA0hAQGHvnDZgyypm1yunObVWcWiF9KM7eGCJKYKLgTC2Fi/57OdnBhbj4OkzPGdFQJ6HyA==",
|
||||
"version": "1.81.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.81.0.tgz",
|
||||
"integrity": "sha512-q0QTm32jWga2Gv4j7IaVZN0jYMi9UV73sWVgFtDA4iIfqwMCLLZ3ve+9KwfYtsaKZSgQhmPaogeZWqDZpcY1Pw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -10344,9 +10344,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-freebsd-x64": {
|
||||
"version": "1.80.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.80.0.tgz",
|
||||
"integrity": "sha512-AYUIk5QnL0s8oWAYsREZwkRYy1SupJTXALo93J1TgzHywxQtdM99FecRMQ87MXEdPQ0j1TmEpeeq3fGNkpvMqg==",
|
||||
"version": "1.81.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.81.0.tgz",
|
||||
"integrity": "sha512-/+8wVWDXEC7wHVAhOc59Fw/SkMc1arLkFD8iQCaSsmzenK1X4doFqquL9H1wrtGUzaiycVqkf/sSpcILK6W1UA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -10361,9 +10361,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-linux-arm-gnueabihf": {
|
||||
"version": "1.80.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.80.0.tgz",
|
||||
"integrity": "sha512-9hBZVANupQ89W9dXyE0n8doCyaW5pDyGn3y6XlIMPZ+rIKuyqkr3SNUXmVJIhuvUq0NBU3RBiSXXE69l4XI6KA==",
|
||||
"version": "1.81.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.81.0.tgz",
|
||||
"integrity": "sha512-4xt422FEgioRq9hAL4Tq7fujGUWnc8z1BJ+Oi8RN8vB8axaP+sdK6a2xdlcQCCYnJg9QMuMFS0AucuIFx/EacA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -10378,9 +10378,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-linux-arm-musleabihf": {
|
||||
"version": "1.80.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.80.0.tgz",
|
||||
"integrity": "sha512-SvS2uKqzY+pbfuvAHzH4338R6Zwo805GAwrIMVvK1KxoOWCIjZUdfzTCvilD7z6JK91v011+zYMryabhDo2AsQ==",
|
||||
"version": "1.81.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.81.0.tgz",
|
||||
"integrity": "sha512-u3vna8KdGplH4DRCW9K54D68fcMo7IxVrkCJWwXnIhwtBdnDnYrmzOUA/XjmBlPpcLsgw9Z5BNdY4za9+Dj+MQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -10395,9 +10395,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-linux-arm64-gnu": {
|
||||
"version": "1.80.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.80.0.tgz",
|
||||
"integrity": "sha512-tCLadyqRVL3pQTRPNg7cjXKvcvS4fbyXeQHhKk5BTJ1oftQln5/yIIWbu/Xom/DX41zv2P9QGt6+D/TtQVtY3A==",
|
||||
"version": "1.81.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.81.0.tgz",
|
||||
"integrity": "sha512-3j9k+gsYsE7nv71GWotXsqsa2l9/aJenD7dVHNt/CBvsb0SgRjSMnHFeP59IXUAl1wvVFhqGl2wJNMwWU3UBlA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -10415,9 +10415,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-linux-arm64-musl": {
|
||||
"version": "1.80.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.80.0.tgz",
|
||||
"integrity": "sha512-XfpCNRlOPcLlJl4Bn/FUhjqlR6BVavEykERBf/MV7YA9VZDa5g5znVqYhyviMafcxS9Pe/i/kPvHNO0U6svEHQ==",
|
||||
"version": "1.81.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.81.0.tgz",
|
||||
"integrity": "sha512-k5iAp3dNxW0/uDCBY+WSm8jKB2szu7SkEQZdgRRpDXvuDd69vvDcqhB3A/pWCfCwXyenjNjFn9Td1fVoyAc+Yg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -10435,9 +10435,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-linux-ppc64-gnu": {
|
||||
"version": "1.80.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.80.0.tgz",
|
||||
"integrity": "sha512-3I4yMwcFG9NeO8ioY6JBBuKsIm5GL/x7MATt1S4tVWaxPu5HcJ+XnLUbcVBTxG8q2Wu56HSj+NmXQiVYb1lp6A==",
|
||||
"version": "1.81.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.81.0.tgz",
|
||||
"integrity": "sha512-TFqLja3uYmVSte6nof9GWrex9Z8WgdZrNiLC6Te5rXGDqXB2y4j/26iFhwosXiAFqDhE9JJVuuCkDKLwptTn1g==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -10455,9 +10455,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-linux-riscv64-gnu": {
|
||||
"version": "1.80.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.80.0.tgz",
|
||||
"integrity": "sha512-E1wAKymkpe1/E8helzBKdm81OBOF+ezxRyXRMEuik3ZpWDER5CPOKZwF66RsdwW98uwZv8UTFremUQtC1CzdJA==",
|
||||
"version": "1.81.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.81.0.tgz",
|
||||
"integrity": "sha512-UEcySvGS0NOVo7h7n7CYyJL9+6gFAh7Zc/ToDXVScFvzHSTIxtzkMVU30rmQ6+nQ1LF+UdiRDdJajpDu+OylLg==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -10475,9 +10475,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-linux-riscv64-musl": {
|
||||
"version": "1.80.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.80.0.tgz",
|
||||
"integrity": "sha512-+gLRGD4sIo3+VA++iham5UxD9tKSoJ/VOrROCEXIcknrYtQg6iIQgvjN0cpiRF7N6UYC7pJbvHJlDnMge5LRpQ==",
|
||||
"version": "1.81.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.81.0.tgz",
|
||||
"integrity": "sha512-H+diDbhD00+wI1IRP8Kz88x/lat+DgtoBJzoTthS16xkTJGNaEkfb8gzmd1rzc/2uDQQMl7GNl+JFUacVeWxIA==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -10495,9 +10495,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-linux-s390x-gnu": {
|
||||
"version": "1.80.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.80.0.tgz",
|
||||
"integrity": "sha512-aR0PrzHj9leW3NmzBAAP4EzdoBNoJcs9sjnIQPIwyRnBGYrRbXUIpEB5Q39AqK3PLY5JK5uEhDQDiUa1QSAstw==",
|
||||
"version": "1.81.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.81.0.tgz",
|
||||
"integrity": "sha512-8znJ/5TekjOKg1j1Acho4PJMdiAHLtlcXuWEiipOhAMV6rQcXdmDdXCbheyDczN6TjBwiNfjcP81k4AthrKRzw==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -10515,9 +10515,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-linux-x64-gnu": {
|
||||
"version": "1.80.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.80.0.tgz",
|
||||
"integrity": "sha512-vSVh5cSo3Xxs6ghBCcFJlpbkbENzDog1qXtoXLa/HC3aCrR4XO76GZbXmQoCPHnu99nQpdCeC3H9tdNICfDh7A==",
|
||||
"version": "1.81.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.81.0.tgz",
|
||||
"integrity": "sha512-Q2Wj70yFsvn5QjlmifFzbj4H+kJy53bwqc41o1fzoM7MpLV1NIbhg/LpWXRfC6KOkSAdUx1Wd8VJsdPmhp/HRA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -10535,9 +10535,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-linux-x64-musl": {
|
||||
"version": "1.80.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.80.0.tgz",
|
||||
"integrity": "sha512-FfzBXpNQ8u7/ZI/p8bl73MeZ508Ax3hxWp3SiJpEFiC+BB9XcXy5FAZHTLKDPSzrUpxQZSZJAVdDmuJp/+HDBQ==",
|
||||
"version": "1.81.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.81.0.tgz",
|
||||
"integrity": "sha512-cPInHp/ddEe5qkyK2IiyQ8Q3Mp2oLLEhhsGgTK2oZx4L6+llGam1H1yBvJZ7qHfOXj8N3hxBS8sj4tO+gtFlIg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -10555,9 +10555,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-openharmony-arm64": {
|
||||
"version": "1.80.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.80.0.tgz",
|
||||
"integrity": "sha512-zMzbkumtmprCgRwoYNzcB3iC39fXdJIMLMU33KdCjEGLlJGOEt1+LwQ4LF8ndLzAEKVz4BR0y3V6Xrkk3Nm3yA==",
|
||||
"version": "1.81.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.81.0.tgz",
|
||||
"integrity": "sha512-0CQxSX4ajqm07AHBf5U33qQzXKdd7wtq/oTL/7vpY6RNNuxrRi8W4bqUV1Jyu/vj+9KmxQyDhxfeVX1nQL6kfg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -10572,9 +10572,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-win32-arm64-msvc": {
|
||||
"version": "1.80.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.80.0.tgz",
|
||||
"integrity": "sha512-ib6iRcrXsk4t1fm3iKcwksyWh1ZkZXC/2mEzakl0ai2+6HZunf1WWMZ/xP9EJAvw9g9K4UVTC3NF/+G2qLrbTQ==",
|
||||
"version": "1.81.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.81.0.tgz",
|
||||
"integrity": "sha512-l0hbeISm9673hVrrQU8j/p2M7YH9Ouoj7p7E/QM55NTrKVLP+P3PF8hLu+OY+x0VtGRW+ggiQKZqmdYps9H+TA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -10589,9 +10589,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-win32-ia32-msvc": {
|
||||
"version": "1.80.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.80.0.tgz",
|
||||
"integrity": "sha512-xhRWBMpLxZvgKAH6+DJZmpP+W8Y8UdQOSU1JfxSWNXsaBaRGW77j+1hCuNHlzj7OH4SPN8fYd1q0o2qrDtoVyw==",
|
||||
"version": "1.81.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.81.0.tgz",
|
||||
"integrity": "sha512-ksqPP5jbFXcYreEQ7zdJh06rJQBymCTyGRCdaXjfcf2aG4f8KxUWY5wcgYHmaTK+FJ4bPG5sUAdOX+6trnH1JA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -10606,9 +10606,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxlint/binding-win32-x64-msvc": {
|
||||
"version": "1.80.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.80.0.tgz",
|
||||
"integrity": "sha512-yAnO7lwBYQnz2pcfBPIGQQZWIX5zd5R/1aAKIF3oE+TVj7IhoHcROjOkz3sRDngzqhfPKfFaXqug5j5rE5dn6Q==",
|
||||
"version": "1.81.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.81.0.tgz",
|
||||
"integrity": "sha512-IZuUCwGw9emG5JtCp+fYGB+Z4OWEoeEcM8R5BA1pYw63/ieYFVdcU2ylxTpHbVHSenZnsYE+ZZ20uHAJszQ4cA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -35963,9 +35963,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/oxlint": {
|
||||
"version": "1.80.0",
|
||||
"resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.80.0.tgz",
|
||||
"integrity": "sha512-5nTiSps4qdbCWLbxzuO00alHkEO2exR9YMN/ig6QXWrLsYSG0KaObOAM+l6oU2LcKPWoSAGYbkZIGEu1ViiWKA==",
|
||||
"version": "1.81.0",
|
||||
"resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.81.0.tgz",
|
||||
"integrity": "sha512-HyrJYqeoOCL0iqaLEzGewGT48ZX99P3hxYh8udAF9RGGIghSamkXE4ClUyBpEDNqasamThgmlPbuMOe7SAZmHg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
@@ -35975,28 +35975,28 @@
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/Boshen"
|
||||
"url": "https://github.com/sponsors/oxc-project"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@oxlint/binding-android-arm-eabi": "1.80.0",
|
||||
"@oxlint/binding-android-arm64": "1.80.0",
|
||||
"@oxlint/binding-darwin-arm64": "1.80.0",
|
||||
"@oxlint/binding-darwin-x64": "1.80.0",
|
||||
"@oxlint/binding-freebsd-x64": "1.80.0",
|
||||
"@oxlint/binding-linux-arm-gnueabihf": "1.80.0",
|
||||
"@oxlint/binding-linux-arm-musleabihf": "1.80.0",
|
||||
"@oxlint/binding-linux-arm64-gnu": "1.80.0",
|
||||
"@oxlint/binding-linux-arm64-musl": "1.80.0",
|
||||
"@oxlint/binding-linux-ppc64-gnu": "1.80.0",
|
||||
"@oxlint/binding-linux-riscv64-gnu": "1.80.0",
|
||||
"@oxlint/binding-linux-riscv64-musl": "1.80.0",
|
||||
"@oxlint/binding-linux-s390x-gnu": "1.80.0",
|
||||
"@oxlint/binding-linux-x64-gnu": "1.80.0",
|
||||
"@oxlint/binding-linux-x64-musl": "1.80.0",
|
||||
"@oxlint/binding-openharmony-arm64": "1.80.0",
|
||||
"@oxlint/binding-win32-arm64-msvc": "1.80.0",
|
||||
"@oxlint/binding-win32-ia32-msvc": "1.80.0",
|
||||
"@oxlint/binding-win32-x64-msvc": "1.80.0"
|
||||
"@oxlint/binding-android-arm-eabi": "1.81.0",
|
||||
"@oxlint/binding-android-arm64": "1.81.0",
|
||||
"@oxlint/binding-darwin-arm64": "1.81.0",
|
||||
"@oxlint/binding-darwin-x64": "1.81.0",
|
||||
"@oxlint/binding-freebsd-x64": "1.81.0",
|
||||
"@oxlint/binding-linux-arm-gnueabihf": "1.81.0",
|
||||
"@oxlint/binding-linux-arm-musleabihf": "1.81.0",
|
||||
"@oxlint/binding-linux-arm64-gnu": "1.81.0",
|
||||
"@oxlint/binding-linux-arm64-musl": "1.81.0",
|
||||
"@oxlint/binding-linux-ppc64-gnu": "1.81.0",
|
||||
"@oxlint/binding-linux-riscv64-gnu": "1.81.0",
|
||||
"@oxlint/binding-linux-riscv64-musl": "1.81.0",
|
||||
"@oxlint/binding-linux-s390x-gnu": "1.81.0",
|
||||
"@oxlint/binding-linux-x64-gnu": "1.81.0",
|
||||
"@oxlint/binding-linux-x64-musl": "1.81.0",
|
||||
"@oxlint/binding-openharmony-arm64": "1.81.0",
|
||||
"@oxlint/binding-win32-arm64-msvc": "1.81.0",
|
||||
"@oxlint/binding-win32-ia32-msvc": "1.81.0",
|
||||
"@oxlint/binding-win32-x64-msvc": "1.81.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"oxlint-tsgolint": ">=7.0.2001",
|
||||
|
||||
@@ -332,7 +332,7 @@
|
||||
"minimizer-webpack-plugin": "^5.8.0",
|
||||
"open-cli": "^9.0.0",
|
||||
"oxfmt": "^0.66.0",
|
||||
"oxlint": "^1.80.0",
|
||||
"oxlint": "^1.81.0",
|
||||
"po2json": "^0.4.5",
|
||||
"postcss-styled-syntax": "^0.7.2",
|
||||
"process": "^0.11.10",
|
||||
|
||||
+3
@@ -35,6 +35,9 @@ export const resampleOperator: PostProcessingFactory<PostProcessingResample> = (
|
||||
method: resampleMethod,
|
||||
rule: resampleRule,
|
||||
fill_value: resampleZeroFill ? 0 : null,
|
||||
...(formData.resample_fill_time_range
|
||||
? { fill_time_range: true }
|
||||
: undefined),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
+24
@@ -217,5 +217,29 @@ export const advancedAnalyticsControls: ControlPanelSectionConfig = {
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
name: 'resample_fill_time_range',
|
||||
config: {
|
||||
type: 'CheckboxControl',
|
||||
label: t('Fill the entire time range'),
|
||||
default: false,
|
||||
description: t(
|
||||
'Fill missing periods across the whole time range of the chart ' +
|
||||
'instead of only between the first and the last data point. ' +
|
||||
'Useful to keep a series anchored to the selected time range ' +
|
||||
'when the data starts late or ends early.',
|
||||
),
|
||||
visibility: ({ controls }, { name }) => {
|
||||
// `_b` suffixed controls refer to Query B in mixed timeseries
|
||||
const suffix = name.endsWith('_b') ? '_b' : '';
|
||||
return Boolean(
|
||||
controls[`resample_rule${suffix}`]?.value &&
|
||||
controls[`resample_method${suffix}`]?.value,
|
||||
);
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
};
|
||||
|
||||
+43
@@ -114,3 +114,46 @@ test('should do zerofill resample', () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('should request filling the entire time range', () => {
|
||||
expect(
|
||||
resampleOperator(
|
||||
{
|
||||
...formData,
|
||||
resample_method: 'zerofill',
|
||||
resample_rule: '1D',
|
||||
resample_fill_time_range: true,
|
||||
},
|
||||
queryObject,
|
||||
),
|
||||
).toEqual({
|
||||
operation: 'resample',
|
||||
options: {
|
||||
method: 'asfreq',
|
||||
rule: '1D',
|
||||
fill_value: 0,
|
||||
fill_time_range: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('should omit fill_time_range when the control is off', () => {
|
||||
expect(
|
||||
resampleOperator(
|
||||
{
|
||||
...formData,
|
||||
resample_method: 'zerofill',
|
||||
resample_rule: '1D',
|
||||
resample_fill_time_range: false,
|
||||
},
|
||||
queryObject,
|
||||
),
|
||||
).toEqual({
|
||||
operation: 'resample',
|
||||
options: {
|
||||
method: 'asfreq',
|
||||
rule: '1D',
|
||||
fill_value: 0,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -196,6 +196,13 @@ interface _PostProcessingResample {
|
||||
method: string;
|
||||
rule: string;
|
||||
fill_value?: number | null;
|
||||
/**
|
||||
* Pad the result so it covers the whole time range of the query instead of
|
||||
* only the span between the first and last data point. The boundaries are
|
||||
* resolved server side, since a time range may be expressed in natural
|
||||
* language (e.g. `Last week`).
|
||||
*/
|
||||
fill_time_range?: boolean;
|
||||
};
|
||||
}
|
||||
export type PostProcessingResample =
|
||||
|
||||
+21
@@ -344,6 +344,27 @@ const config: ControlPanelConfig = {
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
name: 'resample_fill_time_range',
|
||||
config: {
|
||||
type: 'CheckboxControl',
|
||||
label: t('Fill the entire time range'),
|
||||
default: false,
|
||||
description: t(
|
||||
'Fill missing periods across the whole time range of the chart ' +
|
||||
'instead of only between the first and the last data point. ' +
|
||||
'Useful to keep a series anchored to the selected time range ' +
|
||||
'when the data starts late or ends early.',
|
||||
),
|
||||
visibility: ({ controls }) =>
|
||||
Boolean(
|
||||
controls?.resample_rule?.value &&
|
||||
controls?.resample_method?.value,
|
||||
),
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -217,6 +217,7 @@ describe('should collect control values and create SFD', () => {
|
||||
// advanced analytics - resample
|
||||
resample_rule: '1D',
|
||||
resample_method: 'zerofill',
|
||||
resample_fill_time_range: true,
|
||||
// dashboard context
|
||||
dashboardId: 123,
|
||||
};
|
||||
|
||||
@@ -80,6 +80,7 @@ export const publicControls = [
|
||||
// advanced analytics - resample
|
||||
'resample_rule', // via sections.advancedAnalytics
|
||||
'resample_method', // via sections.advancedAnalytics
|
||||
'resample_fill_time_range', // via sections.advancedAnalytics
|
||||
// dashboard context
|
||||
'dashboardId', // preserve dashboard context when changing viz type
|
||||
];
|
||||
|
||||
Generated
-14
@@ -23,7 +23,6 @@
|
||||
"@types/node": "^26.4.1",
|
||||
"@types/ws": "^8.18.1",
|
||||
"esbuild": "^0.28.2",
|
||||
"globals": "^17.11.0",
|
||||
"oxfmt": "^0.66.0",
|
||||
"oxlint": "^1.81.0",
|
||||
"oxlint-tsgolint": "^7.0.2001",
|
||||
@@ -2406,19 +2405,6 @@
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/globals": {
|
||||
"version": "17.11.0",
|
||||
"resolved": "https://registry.npmjs.org/globals/-/globals-17.11.0.tgz",
|
||||
"integrity": "sha512-Z2I8hM+PbJDXQDq3Icgpzv+mPdwr68iZUU9d5WW4FuXfDUQfkZaZuvjMv42/5crNyw154+9+VWXbYrUgDXbxNw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/hot-shots": {
|
||||
"version": "17.1.0",
|
||||
"resolved": "https://registry.npmjs.org/hot-shots/-/hot-shots-17.1.0.tgz",
|
||||
|
||||
@@ -31,7 +31,6 @@
|
||||
"@types/node": "^26.4.1",
|
||||
"@types/ws": "^8.18.1",
|
||||
"esbuild": "^0.28.2",
|
||||
"globals": "^17.11.0",
|
||||
"oxfmt": "^0.66.0",
|
||||
"oxlint": "^1.81.0",
|
||||
"oxlint-tsgolint": "^7.0.2001",
|
||||
|
||||
@@ -734,6 +734,8 @@ class ChartDataRestApi(ChartRestApi):
|
||||
return ChartDataQueryContextSchema().load(form_data)
|
||||
except KeyError as ex:
|
||||
raise ValidationError("Request is incorrect") from ex
|
||||
except ValueError as ex:
|
||||
raise ValidationError(str(ex)) from ex
|
||||
|
||||
def _should_use_streaming(
|
||||
self, result: dict[Any, Any], form_data: dict[str, Any] | None = None
|
||||
|
||||
@@ -77,6 +77,13 @@ DEPRECATED_EXTRAS_FIELDS = (
|
||||
DeprecatedField(old_name="having", new_name="having"),
|
||||
)
|
||||
|
||||
# Post-processing options that QueryObject resolves before calling the operation.
|
||||
# They are not parameters of the pandas function and would otherwise be stripped
|
||||
# by ``_drop_unsupported_options``.
|
||||
_QUERY_OBJECT_RESOLVED_OPTIONS: dict[str, frozenset[str]] = {
|
||||
"resample": frozenset({"fill_time_range"}),
|
||||
}
|
||||
|
||||
|
||||
class QueryObject: # pylint: disable=too-many-instance-attributes
|
||||
"""
|
||||
@@ -266,6 +273,9 @@ class QueryObject: # pylint: disable=too-many-instance-attributes
|
||||
# reports it as InvalidPostProcessingError.
|
||||
return post_proc
|
||||
|
||||
# ``function`` is only resolved when ``operation`` is a known builtin name.
|
||||
assert isinstance(operation, str)
|
||||
|
||||
parameters = inspect.signature(function).parameters
|
||||
if any(
|
||||
parameter.kind is inspect.Parameter.VAR_KEYWORD
|
||||
@@ -287,6 +297,11 @@ class QueryObject: # pylint: disable=too-many-instance-attributes
|
||||
inspect.Parameter.KEYWORD_ONLY,
|
||||
)
|
||||
}
|
||||
# Options that QueryObject resolves itself before invoking the operation
|
||||
# (e.g. ``fill_time_range`` → ``time_range_start`` / ``time_range_end``).
|
||||
# They are not kwargs of the pandas function, but must survive until
|
||||
# ``exec_post_processing``.
|
||||
keyword_parameters |= _QUERY_OBJECT_RESOLVED_OPTIONS.get(operation, frozenset())
|
||||
|
||||
options = post_proc.get("options") or {}
|
||||
unsupported = {key for key in options if key not in keyword_parameters}
|
||||
@@ -665,5 +680,32 @@ class QueryObject: # pylint: disable=too-many-instance-attributes
|
||||
)
|
||||
)
|
||||
func = extra_ops[operation]
|
||||
df = func(df, **post_process.get("options", {}))
|
||||
options = post_process.get("options", {})
|
||||
if operation == "resample":
|
||||
options = self._resolve_resample_options(options)
|
||||
df = func(df, **options)
|
||||
return df
|
||||
|
||||
def _resolve_resample_options(self, options: dict[str, Any]) -> dict[str, Any]:
|
||||
"""
|
||||
Translate the `fill_time_range` flag into explicit resample boundaries.
|
||||
|
||||
Clients cannot supply the boundaries themselves because time ranges may be
|
||||
expressed in natural language (e.g. `Last week`) and are only resolved into
|
||||
concrete datetimes server side. Client-supplied ``time_range_start`` /
|
||||
``time_range_end`` are ignored in favor of the query's resolved bounds.
|
||||
|
||||
:param options: Options of the `resample` post processing operation.
|
||||
:return: Options with the boundaries of the queried time range applied.
|
||||
"""
|
||||
if not options.get("fill_time_range"):
|
||||
return options
|
||||
|
||||
resolved = {
|
||||
key: value
|
||||
for key, value in options.items()
|
||||
if key not in ("fill_time_range", "time_range_start", "time_range_end")
|
||||
}
|
||||
resolved["time_range_start"] = self.from_dttm
|
||||
resolved["time_range_end"] = self.to_dttm
|
||||
return resolved
|
||||
|
||||
@@ -261,7 +261,7 @@ def masked_encrypted_extra_validator(value: str) -> None:
|
||||
encrypted_extra_validator(value)
|
||||
|
||||
|
||||
def extra_validator(value: str) -> str:
|
||||
def extra_validator(value: str) -> str: # noqa: C901
|
||||
"""
|
||||
Validate that extra is a valid JSON string, and that metadata_params
|
||||
keys are on the call signature for SQLAlchemy Metadata
|
||||
@@ -274,6 +274,11 @@ def extra_validator(value: str) -> str:
|
||||
[_("Field cannot be decoded by JSON. %(msg)s", msg=str(ex))]
|
||||
) from ex
|
||||
|
||||
if not isinstance(extra_, dict):
|
||||
raise ValidationError(
|
||||
[_("Extra field must be a mapping from string keys to values.")]
|
||||
)
|
||||
|
||||
metadata_signature = inspect.signature(MetaData)
|
||||
for key in extra_.get("metadata_params", {}):
|
||||
if key not in metadata_signature.parameters:
|
||||
|
||||
@@ -825,14 +825,12 @@ class SQLExecutor:
|
||||
if not engine_disallowed:
|
||||
return None
|
||||
|
||||
# Check each statement for disallowed functions
|
||||
found = set()
|
||||
for statement in script.statements:
|
||||
# Use the statement's AST to check for function calls
|
||||
statement_str = str(statement).upper()
|
||||
for func in engine_disallowed:
|
||||
if func.upper() in statement_str:
|
||||
found.add(func)
|
||||
# Check the parsed AST for real function calls only. A substring check
|
||||
# would incorrectly match identifiers such as ``metric_user_count`` or
|
||||
# ``information_schema`` when functions like USER or SCHEMA are denied.
|
||||
found = {
|
||||
func for func in engine_disallowed if script.check_functions_present({func})
|
||||
}
|
||||
|
||||
return found if found else None
|
||||
|
||||
|
||||
@@ -14,10 +14,12 @@
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
from typing import Optional, Union
|
||||
from datetime import datetime, tzinfo
|
||||
from typing import Any, Optional, Union
|
||||
|
||||
import pandas as pd
|
||||
from flask_babel import gettext as _
|
||||
from pandas.tseries.frequencies import to_offset
|
||||
|
||||
from superset.exceptions import InvalidPostProcessingError
|
||||
from superset.utils.pandas_postprocessing.utils import RESAMPLE_METHOD
|
||||
@@ -28,20 +30,191 @@ from superset.utils.pandas_postprocessing.utils import RESAMPLE_METHOD
|
||||
# rows from a single request.
|
||||
MAX_RESAMPLE_ROWS = 1_000_000
|
||||
|
||||
TimeBound = Union[datetime, str]
|
||||
|
||||
def resample(
|
||||
|
||||
def _coerce_bound(
|
||||
value: Optional[TimeBound], tz: Optional[tzinfo]
|
||||
) -> Optional[pd.Timestamp]:
|
||||
"""
|
||||
Normalize a time range boundary into a ``Timestamp`` comparable with the index.
|
||||
|
||||
:param value: Boundary as a datetime or a parseable string.
|
||||
:param tz: Timezone of the DataFrame index, if any.
|
||||
:return: Timestamp aligned with the index timezone awareness, or None.
|
||||
:raises InvalidPostProcessingError: If the boundary cannot be parsed.
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
timestamp = pd.Timestamp(value)
|
||||
except (TypeError, ValueError) as ex:
|
||||
raise InvalidPostProcessingError(
|
||||
_("Invalid time range boundary for resample: %(value)s", value=value)
|
||||
) from ex
|
||||
|
||||
if timestamp.tzinfo is None:
|
||||
return timestamp if tz is None else timestamp.tz_localize(tz)
|
||||
# an index and a boundary in different timezones would append into an
|
||||
# object-dtype index that ``resample`` cannot bin
|
||||
return timestamp.tz_localize(None) if tz is None else timestamp.tz_convert(tz)
|
||||
|
||||
|
||||
def _pad_to_time_range(
|
||||
df: pd.DataFrame,
|
||||
time_range_start: Optional[pd.Timestamp],
|
||||
time_range_end: Optional[pd.Timestamp],
|
||||
) -> pd.DataFrame:
|
||||
"""
|
||||
Add empty rows at the edges of the target period.
|
||||
|
||||
``DataFrame.resample`` derives its bins from the first and last index entries,
|
||||
so a series that only covers part of the requested time range is only filled
|
||||
between its own extremes. Anchoring the index to the boundaries of the period
|
||||
makes pandas emit buckets for the whole period instead.
|
||||
|
||||
:param df: DataFrame with a DatetimeIndex.
|
||||
:param time_range_start: Inclusive lower boundary of the period.
|
||||
:param time_range_end: Exclusive upper boundary of the period.
|
||||
:return: DataFrame whose index spans the target period.
|
||||
"""
|
||||
index = df.index
|
||||
anchors = []
|
||||
|
||||
if time_range_start is not None and (index.empty or time_range_start < index.min()):
|
||||
anchors.append(time_range_start)
|
||||
|
||||
if time_range_end is not None:
|
||||
# the upper boundary of a Superset time range is exclusive, so anchor on
|
||||
# the last instant that still belongs to the period
|
||||
last_instant = time_range_end - pd.Timedelta(1, unit="ns")
|
||||
if index.empty or last_instant > index.max():
|
||||
anchors.append(last_instant)
|
||||
|
||||
if not anchors:
|
||||
return df
|
||||
|
||||
# `copy` detaches the empty slice from the index engine of `df`, which would
|
||||
# otherwise refuse to reindex whenever `df` holds duplicate timestamps
|
||||
padding = df.iloc[:0].copy().reindex(pd.DatetimeIndex(anchors, name=index.name))
|
||||
return pd.concat([df, padding]).sort_index(kind="stable")
|
||||
|
||||
|
||||
def _period_freq_for_offset(offset: Any) -> str:
|
||||
"""
|
||||
Map a DatetimeIndex/resample offset to a Period frequency string.
|
||||
|
||||
Resample uses anchors like ``MS`` / ``QE`` / ``YE``, but ``Timestamp.to_period``
|
||||
only accepts the Period forms ``M`` / ``Q`` / ``Y`` (and similarly for week).
|
||||
Leading multipliers (``2MS``, ``3QE``) are stripped here; ``offset.n`` is applied
|
||||
when converting the Period delta into a bin count.
|
||||
"""
|
||||
# ``2QE-DEC``, ``2W-SUN``, ``QS-JAN`` → optional digits + unit [+ anchor]
|
||||
head, _, tail = offset.freqstr.partition("-")
|
||||
unit = head.lstrip("0123456789") or head
|
||||
alias = {
|
||||
"MS": "M",
|
||||
"ME": "M",
|
||||
"QS": "Q",
|
||||
"QE": "Q",
|
||||
"YS": "Y",
|
||||
"YE": "Y",
|
||||
"AS": "Y",
|
||||
"A": "Y",
|
||||
}.get(unit)
|
||||
if alias is not None:
|
||||
return alias
|
||||
return f"{unit}-{tail}" if tail else unit
|
||||
|
||||
|
||||
def _estimate_projected_rows(start: pd.Timestamp, end: pd.Timestamp, rule: str) -> int:
|
||||
"""
|
||||
Estimate how many bins ``resample(rule)`` would produce between two bounds.
|
||||
|
||||
Fixed-duration rules use Timedelta arithmetic plus a +2 alignment margin
|
||||
(pandas may snap bins outside the observed span). Calendar frequencies
|
||||
(month, quarter, year, …) have no fixed Timedelta; those are estimated via
|
||||
Period arithmetic (or a day-span upper bound) so the DoS cap still applies
|
||||
without materializing a DatetimeIndex.
|
||||
"""
|
||||
if end < start:
|
||||
return 0
|
||||
offset = to_offset(rule)
|
||||
try:
|
||||
nanos = offset.nanos
|
||||
except ValueError:
|
||||
# Non-fixed frequencies: never build a ``date_range`` just to count bins.
|
||||
try:
|
||||
period_freq = _period_freq_for_offset(offset)
|
||||
delta = end.to_period(period_freq) - start.to_period(period_freq)
|
||||
# Modern pandas returns an offset (``MonthEnd(n=…)``); older versions
|
||||
# returned a plain int. ``.n`` is the shared bin count either way.
|
||||
count = int(getattr(delta, "n", delta))
|
||||
step = max(int(getattr(offset, "n", 1) or 1), 1)
|
||||
return count // step + 1
|
||||
except (TypeError, ValueError):
|
||||
# Remaining non-fixed freqs (e.g. some business calendars): a day
|
||||
# count is a safe upper bound for day-or-coarser bins and stays O(1).
|
||||
return max((end - start).days, 0) + 1
|
||||
if nanos <= 0:
|
||||
return 0
|
||||
# pandas snaps the first resample bin to the nearest frequency multiple at
|
||||
# or before the observed span (and may extend the last bin similarly), so
|
||||
# the actual bin count can exceed a naive span/step projection by one. Add
|
||||
# a margin so the check cannot under-count due to that alignment.
|
||||
return int((end - start) / pd.Timedelta(nanoseconds=nanos)) + 2
|
||||
|
||||
|
||||
def _validate_projected_rows(start: pd.Timestamp, end: pd.Timestamp, rule: str) -> None:
|
||||
try:
|
||||
projected_rows = _estimate_projected_rows(start, end, rule)
|
||||
except (TypeError, ValueError) as ex:
|
||||
raise InvalidPostProcessingError(
|
||||
_("Invalid resample rule: %(rule)s", rule=rule)
|
||||
) from ex
|
||||
if projected_rows > MAX_RESAMPLE_ROWS:
|
||||
raise InvalidPostProcessingError(
|
||||
_(
|
||||
"Resample rule would project %(rows)s rows, "
|
||||
"exceeding the limit of %(max)s rows",
|
||||
rows=projected_rows,
|
||||
max=MAX_RESAMPLE_ROWS,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def resample( # pylint: disable=too-many-arguments
|
||||
df: pd.DataFrame,
|
||||
rule: str,
|
||||
method: str,
|
||||
fill_value: Optional[Union[float, int]] = None,
|
||||
time_range_start: Optional[TimeBound] = None,
|
||||
time_range_end: Optional[TimeBound] = None,
|
||||
) -> pd.DataFrame:
|
||||
"""
|
||||
support upsampling in resample
|
||||
|
||||
Note: If a query returns 0 rows, Superset's primary execution path in
|
||||
``helpers.py`` skips ``exec_post_processing`` entirely. While ``resample``
|
||||
is fully equipped to expand empty DataFrames when given explicit bounds,
|
||||
zero-row query results will be returned as empty frames by upstream engine
|
||||
behavior.
|
||||
|
||||
Projected row count is capped by ``MAX_RESAMPLE_ROWS`` (including calendar
|
||||
frequencies such as month/quarter/year).
|
||||
|
||||
:param df: DataFrame to resample.
|
||||
:param rule: The offset string representing target conversion.
|
||||
:param method: How to fill the NaN value after resample.
|
||||
:param fill_value: What values do fill missing.
|
||||
:param time_range_start: Inclusive start of the period to cover. When set, the
|
||||
result is padded so it starts at the beginning of the
|
||||
period even if the data starts later. An empty
|
||||
DataFrame with a DatetimeIndex is expanded into
|
||||
zero-filled buckets across the period.
|
||||
:param time_range_end: Exclusive end of the period to cover. When set, the
|
||||
result is padded so it ends at the end of the period
|
||||
even if the data ends earlier.
|
||||
:return: DataFrame after resample
|
||||
:raises InvalidPostProcessingError: If the request in incorrect
|
||||
"""
|
||||
@@ -52,31 +225,19 @@ def resample(
|
||||
_("Resample method should be in ") + ", ".join(RESAMPLE_METHOD) + "."
|
||||
)
|
||||
|
||||
if len(df):
|
||||
try:
|
||||
step = pd.Timedelta(pd.tseries.frequencies.to_offset(rule))
|
||||
except ValueError:
|
||||
# Non-fixed frequencies (month, quarter, year) have no fixed
|
||||
# Timedelta; their projected row count is bounded by the span in
|
||||
# days and needs no cap. Invalid rules fail in ``df.resample``.
|
||||
step = None
|
||||
if step is not None and step.value > 0:
|
||||
span = df.index.max() - df.index.min()
|
||||
# pandas snaps the first resample bin to the nearest frequency
|
||||
# multiple at or before the observed span (and may extend the
|
||||
# last bin similarly), so the actual bin count can exceed a
|
||||
# naive span/step projection by one. Add a margin so the check
|
||||
# cannot under-count due to that alignment.
|
||||
projected_rows = span.value // step.value + 2
|
||||
if projected_rows > MAX_RESAMPLE_ROWS:
|
||||
raise InvalidPostProcessingError(
|
||||
_(
|
||||
"Resample rule would project %(rows)s rows, "
|
||||
"exceeding the limit of %(max)s rows",
|
||||
rows=projected_rows,
|
||||
max=MAX_RESAMPLE_ROWS,
|
||||
)
|
||||
)
|
||||
tz = df.index.tz
|
||||
df = _pad_to_time_range(
|
||||
df,
|
||||
_coerce_bound(time_range_start, tz),
|
||||
_coerce_bound(time_range_end, tz),
|
||||
)
|
||||
# An empty frame with no time-range anchors has nothing to bin. Returning
|
||||
# early keeps the DatetimeIndex intact; ``resample`` on a zero-length index
|
||||
# would otherwise degrade it to an object Index.
|
||||
if df.empty:
|
||||
return df
|
||||
|
||||
_validate_projected_rows(df.index.min(), df.index.max(), rule)
|
||||
|
||||
if method == "asfreq" and fill_value is not None:
|
||||
_df = df.resample(rule).asfreq(fill_value=fill_value)
|
||||
|
||||
@@ -383,6 +383,15 @@ class TestPostChartDataApi(BaseTestChartDataApi):
|
||||
rv = self.post_assert_metric(CHART_DATA_URI, self.query_context_payload, "data")
|
||||
assert rv.status_code == 400
|
||||
|
||||
def test_with_reversed_time_range__400(self):
|
||||
# A reversed custom date range (since > until) makes get_since_until
|
||||
# raise a ValueError; the API must convert it to a 400, not a 500.
|
||||
self.query_context_payload["queries"][0]["time_range"] = (
|
||||
"2024-01-01T00:00:00 : 2020-01-01T00:00:00"
|
||||
)
|
||||
rv = self.post_assert_metric(CHART_DATA_URI, self.query_context_payload, "data")
|
||||
assert rv.status_code == 400
|
||||
|
||||
@pytest.mark.usefixtures("load_birth_names_dashboard_with_slices")
|
||||
def test_with_invalid_payload__400(self):
|
||||
invalid_query_context = {"form_data": "NOT VALID JSON"}
|
||||
|
||||
@@ -1337,3 +1337,23 @@ def test_get_data_route_passes_loaded_chart_to_data_response(
|
||||
get_data(api, 1)
|
||||
|
||||
assert mock_response.call_args.kwargs["slice_"] is chart
|
||||
|
||||
|
||||
def test_create_query_context_from_form_converts_value_error_to_400() -> None:
|
||||
"""
|
||||
A ValueError raised while loading the query context (e.g. a reversed date
|
||||
range where since > until) is re-raised as a marshmallow ValidationError so
|
||||
the API returns a 400 instead of an unhandled 500.
|
||||
"""
|
||||
from marshmallow import ValidationError
|
||||
|
||||
api = ChartDataRestApi()
|
||||
message = "From date cannot be larger than to date"
|
||||
with patch(
|
||||
"superset.charts.data.api.ChartDataQueryContextSchema.load",
|
||||
side_effect=ValueError(message),
|
||||
):
|
||||
with pytest.raises(ValidationError) as excinfo:
|
||||
api._create_query_context_from_form({})
|
||||
|
||||
assert message in str(excinfo.value)
|
||||
|
||||
@@ -595,6 +595,25 @@ def test_extra_validator_interpolates_json_decode_error() -> None:
|
||||
assert "%(" not in message
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", [123, None, [1, 2], True, "abc"])
|
||||
def test_extra_validator_rejects_non_dict_top_level_value(value: Any) -> None:
|
||||
"""
|
||||
Test that extra_validator rejects a top-level extra value that is valid
|
||||
JSON but not a mapping (int, null, list, bool, string), instead of
|
||||
letting AttributeError propagate from extra_.get("metadata_params").
|
||||
"""
|
||||
from superset.databases.schemas import DatabasePostSchema
|
||||
|
||||
schema = DatabasePostSchema()
|
||||
payload = {
|
||||
"database_name": "test_db",
|
||||
"extra": json.dumps(value),
|
||||
}
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
schema.load(payload)
|
||||
assert "must be a mapping" in str(exc_info.value)
|
||||
|
||||
|
||||
def test_cache_timeout_rejects_values_below_minus_one() -> None:
|
||||
"""
|
||||
Test that cache_timeout rejects values less than -1.
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
from datetime import datetime
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import pytest
|
||||
@@ -244,6 +246,278 @@ def test_resample_allows_legitimate_upsampling_within_limit():
|
||||
assert len(post_df) == 604801
|
||||
|
||||
|
||||
def test_estimate_projected_rows_calendar_month_start_is_cheap():
|
||||
"""
|
||||
Calendar freqs must be counted via Period arithmetic (or a day-span bound),
|
||||
never by materializing ``pd.date_range`` for the whole span.
|
||||
"""
|
||||
import importlib
|
||||
|
||||
resample_mod = importlib.import_module(
|
||||
"superset.utils.pandas_postprocessing.resample"
|
||||
)
|
||||
estimated = resample_mod._estimate_projected_rows(
|
||||
pd.Timestamp("2010-01-01"),
|
||||
pd.Timestamp("2020-01-01"),
|
||||
"MS",
|
||||
)
|
||||
assert estimated == 121
|
||||
|
||||
|
||||
def test_estimate_projected_rows_handles_multiplied_calendar_freqs():
|
||||
"""``2MS`` / ``2W`` must use offset.n, not fall through to a day-span bound."""
|
||||
import importlib
|
||||
|
||||
resample_mod = importlib.import_module(
|
||||
"superset.utils.pandas_postprocessing.resample"
|
||||
)
|
||||
start = pd.Timestamp("2010-01-01")
|
||||
end = pd.Timestamp("2020-01-01")
|
||||
assert resample_mod._estimate_projected_rows(start, end, "2MS") == 61
|
||||
assert resample_mod._estimate_projected_rows(start, end, "2W-SUN") == 262
|
||||
|
||||
|
||||
def test_estimate_projected_rows_calendar_freqs_do_not_call_date_range(monkeypatch):
|
||||
import importlib
|
||||
|
||||
resample_mod = importlib.import_module(
|
||||
"superset.utils.pandas_postprocessing.resample"
|
||||
)
|
||||
|
||||
def _boom(*_args, **_kwargs):
|
||||
raise AssertionError("date_range must not be used to estimate calendar bins")
|
||||
|
||||
monkeypatch.setattr(resample_mod.pd, "date_range", _boom)
|
||||
for rule in ("MS", "QE", "YE", "W-SUN"):
|
||||
assert (
|
||||
resample_mod._estimate_projected_rows(
|
||||
pd.Timestamp("2010-01-01"),
|
||||
pd.Timestamp("2020-01-01"),
|
||||
rule,
|
||||
)
|
||||
> 0
|
||||
)
|
||||
|
||||
|
||||
def test_resample_rejects_calendar_frequency_over_row_limit(monkeypatch):
|
||||
"""
|
||||
Calendar frequencies (month/quarter/year) have no fixed Timedelta. Master's
|
||||
Timedelta-only check skipped them; Period arithmetic still enforces the cap.
|
||||
"""
|
||||
# Patch via the function globals: the package ``__init__`` rebinds
|
||||
# ``pandas_postprocessing.resample`` to the function, so module attribute
|
||||
# paths are unreliable under pytest monkeypatch.
|
||||
monkeypatch.setitem(pp.resample.__globals__, "MAX_RESAMPLE_ROWS", 10)
|
||||
df = pd.DataFrame(
|
||||
index=to_datetime(["2010-01-01", "2020-01-01"]),
|
||||
data={"y": [1.0, 2.0]},
|
||||
)
|
||||
with pytest.raises(InvalidPostProcessingError, match="exceeding the limit"):
|
||||
pp.resample(df=df, rule="MS", method="ffill")
|
||||
|
||||
|
||||
def test_resample_pads_to_time_range():
|
||||
post_df = pp.resample(
|
||||
df=timeseries_df,
|
||||
rule="1D",
|
||||
method="asfreq",
|
||||
fill_value=0,
|
||||
time_range_start=datetime(2018, 12, 30),
|
||||
time_range_end=datetime(2019, 1, 10),
|
||||
)
|
||||
assert post_df.index.equals(
|
||||
pd.date_range("2018-12-30", "2019-01-09", freq="1D"),
|
||||
)
|
||||
assert post_df["y"].tolist() == [0, 0, 1.0, 2.0, 0, 0, 3.0, 0, 4.0, 0, 0]
|
||||
|
||||
|
||||
def test_resample_pads_a_single_data_point_to_the_whole_period():
|
||||
"""
|
||||
A single data point should still produce a bucket for every period of the
|
||||
queried time range, rather than collapsing into one wide bucket.
|
||||
"""
|
||||
post_df = pp.resample(
|
||||
df=pd.DataFrame(index=to_datetime(["2026-07-01 09:00:00"]), data={"y": [5.0]}),
|
||||
rule="1h",
|
||||
method="asfreq",
|
||||
fill_value=0,
|
||||
time_range_start="2026-07-01 00:00:00",
|
||||
time_range_end="2026-07-02 00:00:00",
|
||||
)
|
||||
assert len(post_df) == 24
|
||||
assert post_df.index[0] == pd.Timestamp("2026-07-01 00:00:00")
|
||||
assert post_df.index[-1] == pd.Timestamp("2026-07-01 23:00:00")
|
||||
assert post_df["y"].sum() == 5.0
|
||||
|
||||
|
||||
def test_resample_time_range_end_is_exclusive():
|
||||
"""
|
||||
The upper bound of a Superset time range is exclusive, so a boundary that
|
||||
falls exactly on a bucket edge should not add a trailing bucket.
|
||||
"""
|
||||
post_df = pp.resample(
|
||||
df=timeseries_df,
|
||||
rule="1D",
|
||||
method="asfreq",
|
||||
fill_value=0,
|
||||
time_range_start=datetime(2019, 1, 1),
|
||||
time_range_end=datetime(2019, 1, 8),
|
||||
)
|
||||
assert post_df.index[-1] == pd.Timestamp("2019-01-07")
|
||||
|
||||
|
||||
def test_resample_does_not_shrink_to_time_range():
|
||||
"""
|
||||
Boundaries only pad the result; data outside of them is left untouched.
|
||||
"""
|
||||
post_df = pp.resample(
|
||||
df=timeseries_df,
|
||||
rule="1D",
|
||||
method="asfreq",
|
||||
fill_value=0,
|
||||
time_range_start=datetime(2019, 1, 3),
|
||||
time_range_end=datetime(2019, 1, 5),
|
||||
)
|
||||
assert post_df.index[0] == pd.Timestamp("2019-01-01")
|
||||
assert post_df.index[-1] == pd.Timestamp("2019-01-07")
|
||||
|
||||
|
||||
def test_resample_pads_timezone_aware_index_with_naive_bounds():
|
||||
df = timeseries_df.tz_localize("UTC")
|
||||
post_df = pp.resample(
|
||||
df=df,
|
||||
rule="1D",
|
||||
method="asfreq",
|
||||
fill_value=0,
|
||||
time_range_start=datetime(2018, 12, 31),
|
||||
time_range_end=datetime(2019, 1, 9),
|
||||
)
|
||||
assert post_df.index[0] == pd.Timestamp("2018-12-31", tz="UTC")
|
||||
assert post_df.index[-1] == pd.Timestamp("2019-01-08", tz="UTC")
|
||||
|
||||
|
||||
def test_resample_pads_with_bounds_in_a_different_timezone():
|
||||
"""
|
||||
Boundaries are converted to the timezone of the index; mixing timezones
|
||||
would otherwise produce an object index that cannot be resampled.
|
||||
"""
|
||||
post_df = pp.resample(
|
||||
df=timeseries_df.tz_localize("UTC"),
|
||||
rule="1D",
|
||||
method="asfreq",
|
||||
fill_value=0,
|
||||
time_range_start=pd.Timestamp("2018-12-30 19:00", tz="America/New_York"),
|
||||
)
|
||||
assert isinstance(post_df.index, pd.DatetimeIndex)
|
||||
assert post_df.index[0] == pd.Timestamp("2018-12-31", tz="UTC")
|
||||
|
||||
|
||||
def test_resample_pads_naive_index_with_aware_bounds():
|
||||
post_df = pp.resample(
|
||||
df=timeseries_df,
|
||||
rule="1D",
|
||||
method="asfreq",
|
||||
fill_value=0,
|
||||
time_range_start=pd.Timestamp("2018-12-30", tz="UTC"),
|
||||
)
|
||||
assert isinstance(post_df.index, pd.DatetimeIndex)
|
||||
assert post_df.index[0] == pd.Timestamp("2018-12-30")
|
||||
|
||||
|
||||
def test_resample_pads_a_frame_with_duplicate_timestamps():
|
||||
"""
|
||||
Charts pivot before resampling, but the API may be called with a frame that
|
||||
repeats timestamps; padding must not break the aggregation.
|
||||
"""
|
||||
df = pd.DataFrame(
|
||||
index=to_datetime(["2019-01-02", "2019-01-02"]),
|
||||
data={"y": [1.0, 2.0]},
|
||||
)
|
||||
post_df = pp.resample(
|
||||
df=df,
|
||||
rule="1D",
|
||||
method="sum",
|
||||
time_range_start=datetime(2018, 12, 31),
|
||||
time_range_end=datetime(2019, 1, 4),
|
||||
)
|
||||
assert post_df.index.equals(pd.date_range("2018-12-31", "2019-01-03", freq="1D"))
|
||||
assert post_df["y"].tolist() == [0.0, 0.0, 3.0, 0.0]
|
||||
|
||||
|
||||
def test_resample_pads_every_series_of_a_pivoted_frame():
|
||||
df = pd.DataFrame(
|
||||
data={
|
||||
"__timestamp": to_datetime(["2019-01-02", "2019-01-04"]),
|
||||
"country": ["FR", "DE"],
|
||||
"val": [1.0, 2.0],
|
||||
}
|
||||
)
|
||||
pivot_df = pp.pivot(
|
||||
df=df,
|
||||
index=["__timestamp"],
|
||||
columns=["country"],
|
||||
aggregates={"val": {"operator": "sum"}},
|
||||
)
|
||||
post_df = pp.resample(
|
||||
df=pivot_df,
|
||||
rule="1D",
|
||||
method="asfreq",
|
||||
fill_value=0,
|
||||
time_range_start=datetime(2019, 1, 1),
|
||||
time_range_end=datetime(2019, 1, 6),
|
||||
)
|
||||
assert not post_df.isna().any().any()
|
||||
assert pp.flatten(post_df).to_dict(orient="list") == {
|
||||
"__timestamp": list(pd.date_range("2019-01-01", "2019-01-05", freq="1D")),
|
||||
"val, DE": [0.0, 0.0, 0.0, 2.0, 0.0],
|
||||
"val, FR": [0.0, 1.0, 0.0, 0.0, 0.0],
|
||||
}
|
||||
|
||||
|
||||
def test_resample_without_time_range_is_unchanged():
|
||||
assert pp.resample(df=timeseries_df, rule="1D", method="ffill").equals(
|
||||
pp.resample(
|
||||
df=timeseries_df,
|
||||
rule="1D",
|
||||
method="ffill",
|
||||
time_range_start=None,
|
||||
time_range_end=None,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_resample_expands_empty_frame_across_time_range():
|
||||
"""
|
||||
An empty result set with a DatetimeIndex should still produce zero-filled
|
||||
buckets for every period of the queried time range.
|
||||
"""
|
||||
empty_df = pd.DataFrame(
|
||||
{"y": pd.Series(dtype="float64")},
|
||||
index=pd.DatetimeIndex([], name="__timestamp"),
|
||||
)
|
||||
post_df = pp.resample(
|
||||
df=empty_df,
|
||||
rule="1D",
|
||||
method="asfreq",
|
||||
fill_value=0,
|
||||
time_range_start=datetime(2019, 1, 1),
|
||||
time_range_end=datetime(2019, 1, 5),
|
||||
)
|
||||
assert post_df.index.equals(pd.date_range("2019-01-01", "2019-01-04", freq="1D"))
|
||||
assert post_df.index.name == "__timestamp"
|
||||
assert post_df["y"].tolist() == [0.0, 0.0, 0.0, 0.0]
|
||||
|
||||
|
||||
def test_resample_empty_frame_without_time_range_stays_empty():
|
||||
empty_df = pd.DataFrame(
|
||||
{"y": pd.Series(dtype="float64")},
|
||||
index=pd.DatetimeIndex([]),
|
||||
)
|
||||
post_df = pp.resample(df=empty_df, rule="1D", method="asfreq", fill_value=0)
|
||||
assert post_df.empty
|
||||
assert isinstance(post_df.index, pd.DatetimeIndex)
|
||||
|
||||
|
||||
def test_resample_should_raise_ex():
|
||||
with pytest.raises(InvalidPostProcessingError):
|
||||
pp.resample(
|
||||
@@ -259,6 +533,14 @@ def test_resample_should_raise_ex():
|
||||
method="foobar",
|
||||
)
|
||||
|
||||
with pytest.raises(InvalidPostProcessingError):
|
||||
pp.resample(
|
||||
df=timeseries_df,
|
||||
rule="1D",
|
||||
method="asfreq",
|
||||
time_range_start="not a date",
|
||||
)
|
||||
|
||||
|
||||
def test_resample_linear():
|
||||
df = pd.DataFrame(
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime
|
||||
from unittest.mock import call, patch
|
||||
|
||||
import pandas as pd
|
||||
@@ -89,6 +90,121 @@ def test_default_query_object_to_dict():
|
||||
}
|
||||
|
||||
|
||||
def test_exec_post_processing_rejects_unsupported_operation():
|
||||
"""
|
||||
An unknown operation reports itself in the error message rather than failing
|
||||
to interpolate it.
|
||||
"""
|
||||
query_object = QueryObject(
|
||||
row_limit=1,
|
||||
post_processing=[{"operation": "no_such_operation"}],
|
||||
)
|
||||
|
||||
with pytest.raises(InvalidPostProcessingError) as excinfo:
|
||||
query_object.exec_post_processing(pd.DataFrame({"y": [1.0]}))
|
||||
|
||||
assert "no_such_operation" in excinfo.value.message
|
||||
|
||||
|
||||
def test_exec_post_processing_requires_an_operation():
|
||||
query_object = QueryObject(row_limit=1, post_processing=[{"options": {}}])
|
||||
|
||||
with pytest.raises(InvalidPostProcessingError):
|
||||
query_object.exec_post_processing(pd.DataFrame({"y": [1.0]}))
|
||||
|
||||
|
||||
def test_exec_post_processing_resample_fills_time_range():
|
||||
"""
|
||||
`fill_time_range` is resolved into the boundaries of the queried time range
|
||||
so the resampled series covers the whole period.
|
||||
"""
|
||||
query_object = QueryObject(
|
||||
row_limit=1,
|
||||
post_processing=[
|
||||
{
|
||||
"operation": "resample",
|
||||
"options": {
|
||||
"method": "asfreq",
|
||||
"rule": "1D",
|
||||
"fill_value": 0,
|
||||
"fill_time_range": True,
|
||||
},
|
||||
}
|
||||
],
|
||||
from_dttm=datetime(2019, 1, 1),
|
||||
to_dttm=datetime(2019, 1, 5),
|
||||
)
|
||||
# ``fill_time_range`` is not a ``resample()`` kwarg; it must survive
|
||||
# ``_drop_unsupported_options`` so ``exec_post_processing`` can resolve it.
|
||||
assert query_object.post_processing[0]["options"].get("fill_time_range") is True
|
||||
|
||||
df = pd.DataFrame(
|
||||
index=pd.to_datetime(["2019-01-03"]),
|
||||
data={"y": [1.0]},
|
||||
)
|
||||
|
||||
assert query_object.exec_post_processing(df).index.equals(
|
||||
pd.date_range("2019-01-01", "2019-01-04", freq="1D")
|
||||
)
|
||||
|
||||
|
||||
def test_exec_post_processing_resample_ignores_client_time_bounds():
|
||||
"""
|
||||
Client-supplied bounds must not override the resolved query window.
|
||||
"""
|
||||
query_object = QueryObject(
|
||||
row_limit=1,
|
||||
post_processing=[
|
||||
{
|
||||
"operation": "resample",
|
||||
"options": {
|
||||
"method": "asfreq",
|
||||
"rule": "1D",
|
||||
"fill_value": 0,
|
||||
"fill_time_range": True,
|
||||
"time_range_start": datetime(2010, 1, 1),
|
||||
"time_range_end": datetime(2030, 1, 1),
|
||||
},
|
||||
}
|
||||
],
|
||||
from_dttm=datetime(2019, 1, 1),
|
||||
to_dttm=datetime(2019, 1, 5),
|
||||
)
|
||||
df = pd.DataFrame(
|
||||
index=pd.to_datetime(["2019-01-03"]),
|
||||
data={"y": [1.0]},
|
||||
)
|
||||
|
||||
assert query_object.exec_post_processing(df).index.equals(
|
||||
pd.date_range("2019-01-01", "2019-01-04", freq="1D")
|
||||
)
|
||||
|
||||
|
||||
def test_exec_post_processing_resample_without_fill_time_range():
|
||||
"""
|
||||
Without the flag the result stays bound to the extremes of the data.
|
||||
"""
|
||||
query_object = QueryObject(
|
||||
row_limit=1,
|
||||
post_processing=[
|
||||
{
|
||||
"operation": "resample",
|
||||
"options": {"method": "asfreq", "rule": "1D", "fill_value": 0},
|
||||
}
|
||||
],
|
||||
from_dttm=datetime(2019, 1, 1),
|
||||
to_dttm=datetime(2019, 1, 5),
|
||||
)
|
||||
df = pd.DataFrame(
|
||||
index=pd.to_datetime(["2019-01-03"]),
|
||||
data={"y": [1.0]},
|
||||
)
|
||||
|
||||
assert query_object.exec_post_processing(df).index.equals(
|
||||
pd.to_datetime(["2019-01-03"])
|
||||
)
|
||||
|
||||
|
||||
def test_cache_key_consistent_for_query_object():
|
||||
"""
|
||||
When the same query is object is used, the
|
||||
|
||||
@@ -164,3 +164,15 @@ def test_image_tag_compose_changes_trigger_python_tests() -> None:
|
||||
["docker-compose-image-tag.yml"],
|
||||
change_detector.PATTERNS["python"],
|
||||
)
|
||||
|
||||
|
||||
def test_docker_workflow_changes_trigger_docker_build() -> None:
|
||||
"""A change to only the docker-build workflow file itself (no
|
||||
Dockerfile/docker-compose/app-code changes) must still be classified as
|
||||
"docker", or docker-build's job-level `if:` skips the job entirely and a
|
||||
broken workflow edit -- including to the vulnerability scan step it
|
||||
runs -- is never actually exercised on merge."""
|
||||
assert change_detector.detect_changes(
|
||||
[".github/workflows/docker.yml"],
|
||||
change_detector.PATTERNS["docker"],
|
||||
)
|
||||
|
||||
@@ -354,6 +354,47 @@ def test_execute_allowed_functions(
|
||||
assert result.status == QueryStatus.SUCCESS
|
||||
|
||||
|
||||
def test_execute_disallowed_function_names_in_identifiers_are_allowed(
|
||||
mocker: MockerFixture, database: Database, app_context: None
|
||||
) -> None:
|
||||
"""
|
||||
Test that disallowed function names are not matched inside identifiers.
|
||||
|
||||
The denylist should block actual function calls such as USER() or SCHEMA(),
|
||||
but not column names, table names, or aliases that merely contain those
|
||||
words.
|
||||
"""
|
||||
mock_query_execution(
|
||||
mocker,
|
||||
database,
|
||||
return_data=[(10, "example_schema")],
|
||||
column_names=["total_users", "table_schema"],
|
||||
)
|
||||
mocker.patch.dict(
|
||||
current_app.config,
|
||||
{
|
||||
"SQL_QUERY_MUTATOR": None,
|
||||
"SQLLAB_TIMEOUT": 30,
|
||||
"SQL_MAX_ROW": None,
|
||||
"DISALLOWED_SQL_FUNCTIONS": {"sqlite": {"USER", "SCHEMA"}},
|
||||
"QUERY_LOGGER": None,
|
||||
},
|
||||
)
|
||||
|
||||
result = database.execute(
|
||||
"""
|
||||
SELECT
|
||||
SUM(metric_user_count) AS total_users,
|
||||
table_schema
|
||||
FROM information_schema_tables
|
||||
GROUP BY table_schema
|
||||
LIMIT 5
|
||||
""",
|
||||
)
|
||||
|
||||
assert result.status == QueryStatus.SUCCESS
|
||||
|
||||
|
||||
def test_execute_disallowed_tables(
|
||||
mocker: MockerFixture, database: Database, app_context: None
|
||||
) -> None:
|
||||
|
||||
Reference in New Issue
Block a user