Compare commits

...
Author SHA1 Message Date
1b46983127 fix(sql): detect disallowed functions via AST (#42072)
Co-authored-by: lawrence.luo <lawrence.luo@idreamsky.com>
Co-authored-by: Evan Rusackas <evan@preset.io>
2026-09-09 17:58:32 -07:00
b02c7afa00 feat(resample): Add "Fill the entire time range" option to Resample controls (#42587) (#42609)
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Evan Rusackas <evan@preset.io>
2026-09-09 17:57:22 -07:00
Evan RusackasandClaude Sonnet 5 bdae45fee3 docs(mcp): document delete_chart and delete_dashboard tools (#43898)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 17:53:16 -07:00
Evan RusackasandClaude af2563f9c3 docs(i18n): document versioned language pack script delivery (#43904)
Co-authored-by: Claude <noreply@anthropic.com>
2026-09-09 17:53:03 -07:00
Evan Rusackas 731a97dbe6 fix(security): suppress zizmor self-repository false positive for postgres celery worker step (#44026) 2026-09-09 17:52:41 -07:00
c915e63aa4 fix(security): suppress zizmor self-repository false positive for playwright mobile step (#44045)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: Đỗ Trọng Hải <41283691+hainenber@users.noreply.github.com>
2026-09-09 17:52:29 -07:00
Evan RusackasandClaude Code 4edf7b1005 fix(deps): cap mcp to <2.0 to match fastmcp 3.x's own constraint (#44039)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-09-09 17:51:51 -07:00
Evan RusackasandClaude Sonnet 5 2030adb5ce fix(security): suppress zizmor self-repository false positive for mysql celery worker step (#44041)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 17:51:23 -07:00
Evan RusackasandClaude Sonnet 5 09ae6fa3c3 fix(security): suppress zizmor self-repository false positive for sqlite celery worker step (#43996)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 17:50:57 -07:00
Evan Rusackas 638ad7980c fix(security): suppress zizmor self-repository false positive for celery worker step (#43986) 2026-09-09 17:50:50 -07:00
Evan RusackasandClaude Sonnet 5 37c6037452 fix(security): use self-repository syntax for setup-backend in postgres job (#44040)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 17:49:45 -07:00
Evan RusackasandClaude Sonnet 5 71e6c5f3cd fix(security): suppress zizmor self-repository false positive for postgres setup step (#44038)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 17:49:37 -07:00
Evan RusackasandClaude Sonnet 5 4e4cdf421c fix(security): suppress zizmor self-repository false positive for hive setup-postgres step (#44037)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 17:49:28 -07:00
Evan RusackasandClaude Sonnet 5 bc057813ae feat(ci): restore container image vulnerability scanning with Grype (#44035)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 17:48:48 -07:00
Evan RusackasandClaude Code 3f780c503e fix(ci): add per-PR concurrency to the Showtime trigger workflow (#44120)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-09-09 15:06:47 -07:00
Elizabeth ThompsonandClaude 62fa7a371b fix(charts/data): return 400 instead of 500 on reversed date range (SC-118279) (#43517)
Co-authored-by: Claude <noreply@anthropic.com>
2026-09-09 15:03:04 -07:00
Elizabeth ThompsonandClaude Opus 5 e4440b614c fix(database): reject non-dict extra field in extra_validator (#44092)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 15:02:55 -07:00
dependabot[bot]andhainenber a9424afb4e chore(deps-dev): remove unused globals from /superset-websocket (#44047)
Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: hainenber <dotronghai96@gmail.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: hainenber <dotronghai96@gmail.com>
2026-09-09 14:55:05 -07:00
b75f629286 chore(deps-dev): bump oxlint from 1.80.0 to 1.81.0 in /superset-frontend (#44057)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: rusackas <evan@rusackas.com>
Co-authored-by: Evan Rusackas <evan@preset.io>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 14:55:01 -07:00
35 changed files with 1115 additions and 164 deletions
+39
View File
@@ -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
+13
View File
@@ -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
+7 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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.
+1
View File
@@ -61,6 +61,7 @@ PATTERNS = {
"docker": [
r"^Dockerfile$",
r"^docker.*",
r"^\.github/workflows/docker\.yml$",
],
"docs": [
r"^docs/",
+4 -4
View File
@@ -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')));
}
+81 -81
View File
@@ -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",
+1 -1
View File
@@ -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",
@@ -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),
},
};
}
@@ -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,
);
},
},
},
],
],
};
@@ -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 =
@@ -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
];
-14
View File
@@ -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",
-1
View File
@@ -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",
+2
View File
@@ -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
+43 -1
View File
@@ -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
+6 -1
View File
@@ -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:
+6 -8
View File
@@ -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
+188 -27
View File
@@ -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: