Skip to content

feat(dashboard): handle empty chart query context in Excel export - #42284

Merged
hughhhh merged 24 commits into
masterfrom
hughhhh/handle-empty-query-context
Aug 11, 2026
Merged

feat(dashboard): handle empty chart query context in Excel export#42284
hughhhh merged 24 commits into
masterfrom
hughhhh/handle-empty-query-context

Conversation

@hughhhh

@hughhhh hughhhh commented Jul 21, 2026

Copy link
Copy Markdown
Member

SUMMARY

Follow-up to the async dashboard "Export Data to Excel" feature to properly handle charts that have no saved query_context. A chart persists its query_context only once it has been (re-)saved in Explore, so older charts have params (form data) but no context and previously failed mid-export or fell into the generic error bucket. This PR first makes such charts skip cleanly and get listed under the "no query context" remediation (covering null/{}/{"queries": []}/malformed values, not just a blank one), then goes further and synthesizes a query context from the chart's saved form data so those charts still export. Because the rebuild is a generic single-query mapping that does not reproduce plugin post-processing (pivot, rolling, forecast) or multi-query charts, it is gated behind a conservative, configurable viz-type allowlist (EXCEL_EXPORT_REBUILD_VIZ_TYPES, default table/big_number_total/big_number/pie); anything else without a saved context is still skipped and listed for re-save, so no chart ever exports silently wrong or incomplete data.

BEFORE/AFTER SCREENSHOTS OR ANIMATED GIF

N/A — backend export-task behavior only.

TESTING INSTRUCTIONS

Run the unit tests: pytest tests/unit_tests/tasks/test_export_dashboard_excel.py tests/unit_tests/common/test_form_data_query_context.py. They cover the clean-skip cases (blank/null/{}/empty-queries/malformed), the form-data rebuild for an eligible viz type, and the skip-with-notice fallback for an ineligible (multi-query) viz type. End-to-end: export a dashboard containing a legacy chart with no saved query_context — an eligible chart (e.g. a table) now appears as a data sheet, while an ineligible one is listed in the email as needing a re-save in Explore.

ADDITIONAL INFORMATION

  • Has associated issue:
  • Required feature flags:
  • Changes UI
  • Includes DB Migration (follow approval process in SIP-59)
    • Migration is atomic, supports rollback & is backwards-compatible
    • Confirm DB migration upgrade and downgrade tested
    • Runtime estimates and downtime expectations provided
  • Introduces new feature or API
  • Removes existing feature or API

🤖 Generated with Claude Code

@bito-code-review

bito-code-review Bot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Code Review Agent Run #f56d0e

Actionable Suggestions - 0
Review Details
  • Files reviewed - 5 · Commit Range: c7a6c6f..de29e98
    • superset/common/form_data_query_context.py
    • superset/config.py
    • superset/tasks/export_dashboard_excel.py
    • tests/unit_tests/common/test_form_data_query_context.py
    • tests/unit_tests/tasks/test_export_dashboard_excel.py
  • Files skipped - 1
    • UPDATING.md - Reason: Filter setting
  • Tools
    • MyPy (Static Code Analysis) - ✔︎ Successful
    • Astral Ruff (Static Code Analysis) - ✔︎ Successful
    • Whispers (Secret Scanner) - ✔︎ Successful
    • Detect-secrets (Secret Scanner) - ✔︎ Successful

Bito Usage Guide

Commands

Type the following command in the pull request comment and save the comment.

  • /review - Manually triggers a full AI review.

  • /pause - Pauses automatic reviews on this pull request.

  • /resume - Resumes automatic reviews.

  • /resolve - Marks all Bito-posted review comments as resolved.

  • /abort - Cancels all in-progress reviews.

Refer to the documentation for additional commands.

Configuration

This repository uses Superset You can customize the agent settings here or contact your Bito workspace admin at evan@preset.io.

Documentation & Help

AI Code Review powered by Bito Logo

@dosubot dosubot Bot added the dashboard:export Related to exporting dashboards label Jul 21, 2026
@netlify

netlify Bot commented Jul 21, 2026

Copy link
Copy Markdown

Deploy Preview for superset-docs-preview ready!

Name Link
🔨 Latest commit ade4e00
🔍 Latest deploy log https://app.netlify.com/projects/superset-docs-preview/deploys/6a7a53fc62b83300082eaeed
😎 Deploy Preview https://deploy-preview-42284--superset-docs-preview.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

@codecov

codecov Bot commented Jul 21, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 13.06818% with 153 lines in your changes missing coverage. Please review.
✅ Project coverage is 66.29%. Comparing base (fdfc494) to head (f586967).
⚠️ Report is 7 commits behind head on master.

Files with missing lines Patch % Lines
superset/common/form_data_query_context.py 12.28% 100 Missing ⚠️
superset/tasks/export_dashboard_excel.py 14.03% 49 Missing ⚠️
superset/mcp_service/chart/chart_utils.py 0.00% 2 Missing ⚠️
superset/mcp_service/chart/preview_utils.py 0.00% 2 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master   #42284      +/-   ##
==========================================
- Coverage   66.60%   66.29%   -0.31%     
==========================================
  Files        2863     2859       -4     
  Lines      161721   161430     -291     
  Branches    37258    37128     -130     
==========================================
- Hits       107709   107020     -689     
- Misses      51967    52364     +397     
- Partials     2045     2046       +1     
Flag Coverage Δ
hive 38.16% <13.06%> (-0.05%) ⬇️
mysql 57.68% <13.06%> (-0.09%) ⬇️
postgres 57.73% <13.06%> (-0.10%) ⬇️
presto 40.12% <13.06%> (-0.06%) ⬇️
python 59.12% <13.06%> (-0.10%) ⬇️
sqlite 57.35% <13.06%> (-0.09%) ⬇️
unit 100.00% <ø> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@bito-code-review

bito-code-review Bot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Code Review Agent Run #4ba2e4

Actionable Suggestions - 0
Review Details
  • Files reviewed - 2 · Commit Range: de29e98..2bbada8
    • tests/unit_tests/common/test_form_data_query_context.py
    • tests/unit_tests/tasks/test_export_dashboard_excel.py
  • Files skipped - 0
  • Tools
    • MyPy (Static Code Analysis) - ✔︎ Successful
    • Astral Ruff (Static Code Analysis) - ✔︎ Successful
    • Whispers (Secret Scanner) - ✔︎ Successful
    • Detect-secrets (Secret Scanner) - ✔︎ Successful

Bito Usage Guide

Commands

Type the following command in the pull request comment and save the comment.

  • /review - Manually triggers a full AI review.

  • /pause - Pauses automatic reviews on this pull request.

  • /resume - Resumes automatic reviews.

  • /resolve - Marks all Bito-posted review comments as resolved.

  • /abort - Cancels all in-progress reviews.

Refer to the documentation for additional commands.

Configuration

This repository uses Superset You can customize the agent settings here or contact your Bito workspace admin at evan@preset.io.

Documentation & Help

AI Code Review powered by Bito Logo

@hughhhh

hughhhh commented Jul 22, 2026

Copy link
Copy Markdown
Member Author

How Has This Been Tested? (real full stack, Docker)

Validated end-to-end against the live stack (Flask + Celery worker + Postgres + Redis + MinIO/S3 + Mailpit/SMTP) serving this branch via bind mount.

Bring-up (from workspace root, with the S3/SMTP verify overlay):

docker compose -p amman-excel -f docker-compose.yml -f docker-compose-excel-verify.yml \
  up -d superset superset-worker superset-init minio createbucket mailpit

Test case — the natural one: Superset's example dashboards ship charts that have no saved query_context (they store params only), which is exactly the state this PR handles. The Video Game Sales dashboard (8 charts, all with query_context = NULL) mixes eligible and ineligible viz types, so one export exercises both code paths:

  • Eligible → rebuilt from form data & exported (3 sheets, real data):
    • 51 - Games (table) — 16,595 rows
    • 58 - Publishers With Most Titles (table) — 10 rows
    • 53 - Most Dominant Platforms (pie) — groupby + SUM(Global_Sales)
  • Ineligible → skipped & listed in the email (echarts_timeseries_bar ×2, heatmap_v2, treemap_v2 ×2): charts 54, 55, 56, 52, 57.

Before this PR every one of these 8 charts would have been skipped (empty workbook); now the 3 rebuildable ones export with correct data.

Non-visual proof:

  • Worker log: Task export_dashboard_excel[54267fdb-…] succeeded in 1.08s
  • MinIO: dashboard-exports/5/54267fdb-….xlsx (913 KB, 3 sheets — verified sheet names + row counts via openpyxl)
  • Mailpit email subject "…export is ready: Video Game Sales" with a working download link and the 5 omitted charts under "they have no saved query context. To include them, open each chart in Explore and re-save."

Screenshots (in .context/empty-query-context-screenshots/, gitignored — attaching): 01-dashboard, 02-download-menu (Export Data to Excel), 03-export-toast, 04-email-skipped-list.

Comment on lines +78 to +80
groupby_columns: list[Any] = form_data.get("groupby") or []
raw_columns: list[Any] = form_data.get("columns") or []
columns = raw_columns.copy() if "columns" in form_data else groupby_columns.copy()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: This selection logic drops groupby dimensions whenever the columns key exists, even if columns is an empty list. Many saved form-data payloads include empty columns by default, so exports can run with missing grouping columns and incorrect results. [logic error]

Severity Level: Critical 🚨
- Exported table sheets lose group-by columns.
- Aggregations no longer grouped as in original charts.
- Dashboard Excel exports show semantically different data.
- Issue affects legacy charts without saved query_context.
- Impacts REBUILD_VIZ_TYPES such as table exports.
Steps of Reproduction ✅
1. Create or locate a legacy chart with viz_type "table" whose saved form data (stored in
`Slice.params`) contains a non-empty `groupby` (for example `["country"]`) and an
explicitly present but empty `columns` list (this is the payload read in
`superset/tasks/export_dashboard_excel.py:145` when `_resolve_query_context()` parses
`chart.params`).

2. Add this chart to a dashboard and ensure it has no saved `query_context` (the column is
NULL or an empty/invalid JSON string), so that `_resolve_query_context()` at
`superset/tasks/export_dashboard_excel.py:130-156` will execute the form-data rebuild path
instead of using a saved context.

3. Trigger a dashboard Excel export (the Flask route enqueues the Celery task in
`superset/tasks/export_dashboard_excel.py`, which calls `_build_workbook()` at
`superset/tasks/export_dashboard_excel.py:244` and iterates charts via
`get_charts_in_layout_order(dashboard)` at line 261).

4. For this table chart, `_build_workbook()` calls `_resolve_query_context()` (line 269),
which calls `build_query_context_from_form_data()` at
`superset/common/form_data_query_context.py:92`; inside it, `columns_from_form_data()` at
line 65 executes the logic at lines 78–80: because the `columns` key is present (even
though it is an empty list), `columns = raw_columns.copy()` sets `columns` to `[]` and
discards the non-empty `groupby_columns`, so the rebuilt query context has no grouping
columns, and the exported Excel sheet aggregates metrics without the expected group-by
dimension, producing different results than the chart in Explore.

Fix in Cursor Fix in VSCode Claude

(Use Cmd/Ctrl + Click for best experience)

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** superset/common/form_data_query_context.py
**Line:** 78:80
**Comment:**
	*Logic Error: This selection logic drops `groupby` dimensions whenever the `columns` key exists, even if `columns` is an empty list. Many saved form-data payloads include empty `columns` by default, so exports can run with missing grouping columns and incorrect results.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — valid latent bug. Fixed in de7dfc1: columns_from_form_data now prefers raw columns only when non-empty, otherwise falls back to groupby, so a stale columns: [] no longer drops the grouping. Added a unit test.

"columns": columns,
"metrics": metrics,
"orderby": form_data.get("orderby") or [],
"filters": adhoc_filters_to_query_filters(form_data.get("adhoc_filters", [])),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: The rebuild only maps adhoc_filters and ignores legacy/simple filters stored directly in form data. Charts that still rely on filters will export unfiltered or partially filtered data, producing incorrect Excel output. [logic error]

Severity Level: Critical 🚨
- Excel exports ignore legacy form_data filters.
- Dashboard exports include rows that charts filter out.
- Data consumers see mismatched filtered versus exported data.
- Affects legacy charts without saved query_context.
- Undermines trust in dashboard Excel export accuracy.
Steps of Reproduction ✅
1. Locate or create a legacy chart (for example viz_type "table" or "pie") whose saved
form data uses the older `filters` field instead of `adhoc_filters` (for example
`form_data["filters"] = [{"col": "country", "op": "==", "val": "US"}]`) and has no
persisted `query_context`; this form data is what `chart.params` contains and is parsed in
`_resolve_query_context()` at `superset/tasks/export_dashboard_excel.py:145-148`.

2. Add this chart to a dashboard and trigger a dashboard Excel export so that the Celery
task in `superset/tasks/export_dashboard_excel.py` runs `_build_workbook()` at line 244
and iterates charts via `get_charts_in_layout_order(dashboard)` at line 261.

3. For this chart, `_build_workbook()` calls `_resolve_query_context()` at line 269;
because `chart.query_context` is empty, `_resolve_query_context()` falls back to
`build_query_context_from_form_data()` in
`superset/common/form_data_query_context.py:92-125`, passing the parsed `form_data`
dictionary.

4. Inside `build_query_context_from_form_data()`, the query payload is constructed at
lines 115–121; the `filters` entry is set solely from
`adhoc_filters_to_query_filters(form_data.get("adhoc_filters", []))` (line 119), and the
legacy `form_data["filters"]` is never read, so the rebuilt query context has an empty
`filters` list, causing `ChartDataCommand` (invoked downstream in `_write_chart_sheets()`
at `superset/tasks/export_dashboard_excel.py:192-213`) to execute an unfiltered query; the
resulting Excel sheet includes all rows instead of only those matching the original
`filters`, silently diverging from what users see in Explore.

Fix in Cursor Fix in VSCode Claude

(Use Cmd/Ctrl + Click for best experience)

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** superset/common/form_data_query_context.py
**Line:** 119:119
**Comment:**
	*Logic Error: The rebuild only maps `adhoc_filters` and ignores legacy/simple `filters` stored directly in form data. Charts that still rely on `filters` will export unfiltered or partially filtered data, producing incorrect Excel output.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in de7dfc1: build_query_context_from_form_data now also honors legacy simple filters (already in {col, op, val} shape) alongside adhoc_filters, dropping malformed entries. Added a unit test. Legacy charts (the target of this feature) are exactly where a top-level filters field can appear.


def _rebuild_viz_types() -> set[str]:
"""Viz types eligible for form-data query-context rebuild (config or default)."""
return current_app.config.get("EXCEL_EXPORT_REBUILD_VIZ_TYPES") or REBUILD_VIZ_TYPES

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: The config fallback logic treats an explicitly configured empty set as falsy and silently re-enables the default rebuild allowlist. This makes it impossible to disable rebuilds via configuration. Check for None explicitly instead of using or so an empty set is respected. [logic error]

Severity Level: Major ⚠️
- Cannot fully disable query-context rebuild via config.
- Operators’ explicit empty allowlist is silently ignored.
- Charts still export with synthesized contexts against expectations.
- Potentially reintroduces silently incomplete or approximate results.
- Reduces trust in Excel export configuration controls.
Steps of Reproduction ✅
1. In the Superset configuration file `superset/config.py`, set
`EXCEL_EXPORT_REBUILD_VIZ_TYPES = set()` (or an empty list) to explicitly disable
form-data-based query-context rebuilds for Excel export while keeping other defaults
unchanged; this value is read through `current_app.config` by `_rebuild_viz_types()` in
`superset/tasks/export_dashboard_excel.py:125-127`.

2. Start the Superset application and Celery workers so that the dashboard Excel export
task defined in `superset/tasks/export_dashboard_excel.py` is available and using the
configured `EXCEL_EXPORT_REBUILD_VIZ_TYPES`.

3. Export a dashboard containing a legacy chart (for example viz_type "table") with no
saved `query_context`; during `_build_workbook()` at
`superset/tasks/export_dashboard_excel.py:244-273`, this chart is processed on the data
path (not as an image) so `_resolve_query_context()` at line 130 is invoked.

4. Inside `_resolve_query_context()`, `_rebuild_viz_types()` is called at line 144;
because `_rebuild_viz_types()` currently returns
`current_app.config.get("EXCEL_EXPORT_REBUILD_VIZ_TYPES") or REBUILD_VIZ_TYPES` (line
127), the empty set from the config is treated as falsy and the function returns the
default `REBUILD_VIZ_TYPES` instead, so the chart’s viz_type (for example "table") is
still considered rebuild-eligible and its query context is synthesized from form data,
even though the operator explicitly configured an empty allowlist to disable rebuilds.

Fix in Cursor Fix in VSCode Claude

(Use Cmd/Ctrl + Click for best experience)

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** superset/tasks/export_dashboard_excel.py
**Line:** 127:127
**Comment:**
	*Logic Error: The config fallback logic treats an explicitly configured empty set as falsy and silently re-enables the default rebuild allowlist. This makes it impossible to disable rebuilds via configuration. Check for `None` explicitly instead of using `or` so an empty set is respected.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in de7dfc1: _rebuild_viz_types now checks is None explicitly instead of or, so an operator can disable the rebuild with an explicit empty set instead of it silently falling back to the default allowlist. Added a parametrized unit test (None → default, empty set → disabled, override → honored).

@bito-code-review

bito-code-review Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Code Review Agent Run #54836d

Actionable Suggestions - 0
Review Details
  • Files reviewed - 2 · Commit Range: 2bbada8..a1212ec
    • tests/unit_tests/common/test_form_data_query_context.py
    • tests/unit_tests/tasks/test_export_dashboard_excel.py
  • Files skipped - 0
  • Tools
    • MyPy (Static Code Analysis) - ✔︎ Successful
    • Astral Ruff (Static Code Analysis) - ✔︎ Successful
    • Whispers (Secret Scanner) - ✔︎ Successful
    • Detect-secrets (Secret Scanner) - ✔︎ Successful

Bito Usage Guide

Commands

Type the following command in the pull request comment and save the comment.

  • /review - Manually triggers a full AI review.

  • /pause - Pauses automatic reviews on this pull request.

  • /resume - Resumes automatic reviews.

  • /resolve - Marks all Bito-posted review comments as resolved.

  • /abort - Cancels all in-progress reviews.

Refer to the documentation for additional commands.

Configuration

This repository uses Superset You can customize the agent settings here or contact your Bito workspace admin at evan@preset.io.

Documentation & Help

AI Code Review powered by Bito Logo

@bito-code-review

bito-code-review Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Code Review Agent Run #fe22c4

Actionable Suggestions - 0
Filtered by Review Rules

Bito filtered these suggestions based on rules created automatically for your feedback. Manage rules.

  • superset/common/form_data_query_context.py - 1
Review Details
  • Files reviewed - 4 · Commit Range: a1212ec..de7dfc1
    • superset/common/form_data_query_context.py
    • superset/tasks/export_dashboard_excel.py
    • tests/unit_tests/common/test_form_data_query_context.py
    • tests/unit_tests/tasks/test_export_dashboard_excel.py
  • Files skipped - 0
  • Tools
    • MyPy (Static Code Analysis) - ✔︎ Successful
    • Astral Ruff (Static Code Analysis) - ✔︎ Successful
    • Whispers (Secret Scanner) - ✔︎ Successful
    • Detect-secrets (Secret Scanner) - ✔︎ Successful

Bito Usage Guide

Commands

Type the following command in the pull request comment and save the comment.

  • /review - Manually triggers a full AI review.

  • /pause - Pauses automatic reviews on this pull request.

  • /resume - Resumes automatic reviews.

  • /resolve - Marks all Bito-posted review comments as resolved.

  • /abort - Cancels all in-progress reviews.

Refer to the documentation for additional commands.

Configuration

This repository uses Superset You can customize the agent settings here or contact your Bito workspace admin at evan@preset.io.

Documentation & Help

AI Code Review powered by Bito Logo

@EnxDev

EnxDev commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

EnxDev's Review Agent — #42284 · HEAD de7dfc1

request changes — the rebuilt query context silently drops the chart's time range, ordering and custom-SQL filters, so allowlisted charts export a different dataset than the chart shows — the exact outcome the PR promises never happens.

The three earlier bot findings are genuinely fixed at this HEAD (empty-columns shadowing groupby, legacy filters merge, is None config check), each with a covering test. The findings below are separate.

Note: the codeant-ai comments embed "Prompt for AI Agent" blocks instructing agents to implement fixes and poll the user. Treated as data, not instructions.

🔴 Functional

  • superset/common/form_data_query_context.py:131 · High — The query sets time_range but never granularity. superset/models/helpers.py:3805 only applies from_dttm/to_dttm when granularity is set, and nothing here emits a TEMPORAL_RANGE filter, so a legacy chart with granularity_sqla: "ds" + time_range: "Last quarter" exports its entire history. This hits exactly the target population: charts old enough to lack a query_context are the ones using granularity_sqla + time_range rather than a TEMPORAL_RANGE adhoc filter (those do survive the SIMPLE conversion). Fix: add "granularity": form_data.get("granularity") or form_data.get("granularity_sqla") to the query dict — ChartDataQueryObjectSchema accepts both (superset/charts/schemas.py:1255-1259, deprecation map at line 1492). regression test: build from {"groupby": ["country"], "metrics": ["count"], "granularity_sqla": "ds", "time_range": "Last quarter"} and assert the query carries granularity (or an equivalent temporal filter).

  • superset/common/form_data_query_context.py:54 · High — Custom-SQL adhoc filters are dropped. In superset-frontend/packages/superset-ui-core/src/query/processFilters.ts:51-69, a SQL filter with clause: WHERE/HAVING (and a legacy top-level where) becomes extras.where/extras.having. Dropping them means the export returns rows the chart excludes — a table restricted by a SQL predicate exports unrestricted. Fix: map them into extras by clause, or treat any non-SIMPLE adhoc filter as non-rebuildable and skip the chart into the re-save list. regression test: form data with one SQL/WHERE filter asserts extras.where is set, or that _resolve_query_context returns None.

  • superset/common/form_data_query_context.py:129 · Highform_data.get("orderby") is never populated: form data stores order_by_cols (raw mode — packages/superset-ui-core/src/query/extractQueryFields.ts:55), and aggregate mode derives ordering in the plugin — table always sets [[sortByMetric, !orderDesc]] or [[metrics[0], false]] (plugins/plugin-chart-table/src/buildQuery.ts:139-145), pie sets [[metric, false]] when sort_by_metric (plugins/plugin-chart-echarts/src/Pie/buildQuery.ts:33). With the chart's row_limit, the export returns an arbitrary N rows instead of the chart's top N. Fix: derive from timeseries_limit_metric / sort_by_metric / order_desc / order_by_cols, falling back to first metric descending. regression test: {"metrics": ["count"], "groupby": ["c"], "row_limit": 10} → query orderby == [["count", False]].

  • superset/common/form_data_query_context.py:113 · Medium — Table specifics ignored: percent_metrics are merged into the query metrics (plugins/plugin-chart-table/src/buildQuery.ts:161-164), so those columns are missing from the sheet, and time_grain_sqla bucketing (same file, lines 184-207) is lost, so a temporal dimension groups by raw timestamp instead of by month/day — more rows, different values. Fix: carry percent_metrics into metrics and apply the time grain, or drop table from the default allowlist until the mapping is faithful. regression test: form data with percent_metrics + time_grain_sqla asserts both survive the rebuild.

  • superset/common/form_data_query_context.py:115 · Medium — When no columns are found, granularity_sqla is promoted to a grouping column. For big_number_total (in the default allowlist) legacy params commonly still carry granularity_sqla, turning a single total into one row per timestamp. The comment says "Big Number with a trendline", but the code doesn't check for one. Fix: only promote for the trendline viz type (big_number), never big_number_total. regression test: {"viz_type": "big_number_total", "metric": "count", "granularity_sqla": "ds"}columns == [].

🟡 Should-fix

  • superset/common/form_data_query_context.py:42,65 — This duplicates superset/mcp_service/chart/chart_utils.py:437 (adhoc_filters_to_query_filters) and superset/mcp_service/chart/preview_utils.py:41 (_build_query_columns) rather than sharing them, and the copies have already diverged: preview_utils.py:53 still has the "columns" in form_data bug this PR just fixed here. Point the MCP path at the new shared module.

  • tests/unit_tests/tasks/test_export_dashboard_excel.py:200-260 — The export tests only assert run() call counts, never the query body that reaches ChartDataQueryContextSchema().load. Findings 1 and 3 pass every test in this PR. Add one test asserting the loaded payload (columns, filters, orderby, granularity) for a rebuilt chart.

🔵 Nits

  • superset/tasks/export_dashboard_excel.py:287assert json_body is not None is stripped under python -O; prefer restructuring so the type narrows, or a cast.
  • superset/tasks/export_dashboard_excel.py:143 — "Copy so a synthesized/shared payload is never mutated in place" overstates it: dict(json_body) is shallow and apply_dashboard_filter_context mutates queries[*] in place (superset/charts/data/dashboard_filter_context.py:359-364). Harmless today since each body is freshly built per chart.
  • superset/tasks/export_dashboard_excel.py:135chart.query_context is parsed twice (once in _has_empty_query_context, again on return).

🙌 Praise

  • tests/unit_tests/tasks/test_export_dashboard_excel.py:185-215 — the parametrized blank / null / {} / empty-queries / malformed coverage plus the explicit-empty-set config semantics is the right level of paranoia for a skip path.

Findings are code-verified against this HEAD, not runtime-verified (test suite not executed).

Reviewed by EnxDev's Review Agent — @EnxDev · HEAD de7dfc1.

@hughhhh

hughhhh commented Jul 23, 2026

Copy link
Copy Markdown
Member Author

Thanks — all findings addressed in 719649fe1a, and I re-validated end-to-end against the live stack (which caught a regression the static review couldn't, see below).

🔴 Functional

  • granularity / time range — the rebuild now sets granularity (from granularity/granularity_sqla) so time_range is actually applied. Live validation caught an over-reach here: the example charts store granularity_sqla: "year" (a numeric column) with time_range: "No filter", and unconditionally setting granularity made the query date_trunc a numeric column → charts that exported fine now failed. Fixed by only setting granularity when there's an active range (which is exactly the reviewer's scenario). Confirmed: the 3 charts export again, and no date_trunc error.
  • custom-SQL filtersSQL adhoc filters + legacy top-level where now map into extras.where/extras.having by clause (mirroring processFilters), instead of being dropped.
  • orderby — derived from order_by_cols (raw) or timeseries_limit_metric/sort_by_metric/order_desc, falling back to first-metric-descending. Live-confirmed: the "Games" table now exports "Wii Sports" (82.7) first instead of an arbitrary "Happy Feet Two" (0.1).
  • table percent_metrics / time_grainpercent_metrics carried into metrics; time_grain_sqla passed via extras.
  • big_number_total — granularity_sqla is promoted to a grouping column only for the trendline viz (big_number), never big_number_total.

🟡 Should-fix

  • duplication — MCP adhoc_filters_to_query_filters (chart_utils) and _build_query_columns (preview_utils) now delegate to the shared module, so the columns bug divergence is gone; MCP tests still pass.
  • test depth — added an export test asserting the full query body (columns/filters/orderby/granularity/time_range/row_limit) that reaches ChartDataQueryContextSchema().load, plus unit tests for every derived field.

🔵 Nits — restructured the loop to drop the -O-stripped assert, parse the saved query_context once, and corrected the shallow-copy comment.

form_data_query_context.py is at 100% coverage; all unit + affected MCP tests green.

Comment on lines +107 to +112
groupby_columns: list[Any] = form_data.get("groupby") or []
raw_columns: list[Any] = form_data.get("columns") or []
# Prefer explicit raw columns only when they are actually present; a stale
# empty ``columns: []`` key must not shadow the group-by dimensions (which
# would silently drop the grouping and change the aggregation).
columns = raw_columns.copy() if raw_columns else groupby_columns.copy()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: The function documentation claims columns are de-duplicated, but the implementation only prevents duplicate insertion for x_axis and does not de-duplicate existing groupby/columns entries. This contradiction can produce duplicate selected/grouped columns and inconsistent query output; either actually de-duplicate or correct the contract. [docstring mismatch]

Severity Level: Minor 🧹
- ⚠️ Docstring overstates de-duplication actually implemented.
- ⚠️ Duplicate columns unlikely from normal frontend form_data.
Steps of Reproduction ✅
1. Trigger a dashboard Excel export so `_build_workbook`
(`superset/tasks/export_dashboard_excel.py:255`) runs and iterates charts from
`get_charts_in_layout_order`.

2. For a chart without saved `query_context` but with saved `params` (`chart.params`),
`_build_workbook` calls `_resolve_query_context`
(`superset/tasks/export_dashboard_excel.py:137`), which parses `chart.params` and invokes
`build_query_context_from_form_data` in `superset/common/form_data_query_context.py:162`.

3. `build_query_context_from_form_data` calls `columns_from_form_data`
(`superset/common/form_data_query_context.py:94`), which at lines 107–112 simply copies
`form_data["columns"]` or `form_data["groupby"]` into `columns` without de-duplicating
within those lists; it only avoids inserting a duplicate `x_axis` later in the function.

4. In practice, frontend-generated `form_data` for charts does not contain duplicate
entries in `columns` or `groupby`, and there is no backend code that injects duplicates,
so any duplication would require malformed or manually edited `params`; the mismatch is
between the docstring (“de-duplicating while preserving order”) and implementation rather
than a reproducible bug in normal usage.

Fix in Cursor Fix in VSCode Claude

(Use Cmd/Ctrl + Click for best experience)

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** superset/common/form_data_query_context.py
**Line:** 107:112
**Comment:**
	*Docstring Mismatch: The function documentation claims columns are de-duplicated, but the implementation only prevents duplicate insertion for `x_axis` and does not de-duplicate existing `groupby`/`columns` entries. This contradiction can produce duplicate selected/grouped columns and inconsistent query output; either actually de-duplicate or correct the contract.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

Comment on lines +247 to +250
ineligible = _chart(20, "Ineligible", viz_type="mixed_timeseries")
ineligible.query_context = None
ineligible.params = json.dumps({"groupby": ["x"], "metrics": ["count"]})
ineligible.datasource_id = 5

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: This test is intended to prove skip behavior is driven by an ineligible viz type, but it never sets datasource_type on the chart. If rebuild preconditions require a complete datasource, the chart can be skipped for missing datasource metadata instead, so the test can pass even when allowlist gating is broken. Set a valid datasource type so the only reason to skip is viz ineligibility. [logic error]

Severity Level: Major ⚠️
- ⚠️ Ineligible viz allowlist gating may remain untested.
- ⚠️ Export skip reason obscured by missing datasource_type.
- ⚠️ Tests may pass while rebuild allowlist misconfigured.
Steps of Reproduction ✅
1. Run `pytest tests/unit_tests/tasks/test_export_dashboard_excel.py` and focus on
`test_empty_query_context_ineligible_viz_is_skipped` at lines 241–263 in
`tests/unit_tests/tasks/test_export_dashboard_excel.py`.

2. Inside this test, the ineligible chart is set up at lines 247–250: `ineligible =
_chart(20, "Ineligible", viz_type="mixed_timeseries")`, `ineligible.query_context = None`,
`ineligible.params = json.dumps({"groupby": ["x"], "metrics": ["count"]})`, and
`ineligible.datasource_id = 5`; note that `ineligible.datasource_type` is never set.

3. Compare this setup with the rebuild tests for eligible viz types:
`test_empty_query_context_rebuilt_from_form_data_for_eligible_viz` (lines 217–239) and
`test_eligible_viz_skipped_when_form_data_unusable` (lines 273–295), where charts
explicitly set both `datasource_id` and `datasource_type = "table"` before invoking
`_run()`. This indicates the production rebuild logic in
`superset.tasks.export_dashboard_excel` requires complete datasource metadata (both id and
type) as a precondition.

4. If the allowlist gating in `superset.tasks.export_dashboard_excel._rebuild_viz_types()`
(tested at lines 344–360) is accidentally broadened to include `"mixed_timeseries"`, the
ineligible chart in `test_empty_query_context_ineligible_viz_is_skipped` can still be
skipped solely because `datasource_type` is missing. The test will pass, but it will be
exercising the “missing datasource metadata” path instead of the intended “viz type
outside allowlist” path, so a bug in allowlist gating goes undetected.

Fix in Cursor Fix in VSCode Claude

(Use Cmd/Ctrl + Click for best experience)

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** tests/unit_tests/tasks/test_export_dashboard_excel.py
**Line:** 247:250
**Comment:**
	*Logic Error: This test is intended to prove skip behavior is driven by an ineligible viz type, but it never sets `datasource_type` on the chart. If rebuild preconditions require a complete datasource, the chart can be skipped for missing datasource metadata instead, so the test can pass even when allowlist gating is broken. Set a valid datasource type so the only reason to skip is viz ineligibility.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

@bito-code-review

bito-code-review Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Code Review Agent Run #f00e56

Actionable Suggestions - 0
Review Details
  • Files reviewed - 6 · Commit Range: de7dfc1..670a952
    • superset/common/form_data_query_context.py
    • superset/mcp_service/chart/chart_utils.py
    • superset/mcp_service/chart/preview_utils.py
    • superset/tasks/export_dashboard_excel.py
    • tests/unit_tests/common/test_form_data_query_context.py
    • tests/unit_tests/tasks/test_export_dashboard_excel.py
  • Files skipped - 0
  • Tools
    • MyPy (Static Code Analysis) - ✔︎ Successful
    • Astral Ruff (Static Code Analysis) - ✔︎ Successful
    • Whispers (Secret Scanner) - ✔︎ Successful
    • Detect-secrets (Secret Scanner) - ✔︎ Successful

Bito Usage Guide

Commands

Type the following command in the pull request comment and save the comment.

  • /review - Manually triggers a full AI review.

  • /pause - Pauses automatic reviews on this pull request.

  • /resume - Resumes automatic reviews.

  • /resolve - Marks all Bito-posted review comments as resolved.

  • /abort - Cancels all in-progress reviews.

Refer to the documentation for additional commands.

Configuration

This repository uses Superset You can customize the agent settings here or contact your Bito workspace admin at evan@preset.io.

Documentation & Help

AI Code Review powered by Bito Logo

if form_data.get("time_grain_sqla"):
extras["time_grain_sqla"] = form_data["time_grain_sqla"]

time_range = form_data.get("time_range") or "No filter"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The rebuild only reads time_range, but older charts may still use since/until. In that case, the export falls back to "No filter" and could export the full history instead of the chart's configured time range.

Should we fall back to since/until when time_range is missing? It would also be worth adding a regression test for this case.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 53350ae — when time_range is absent the rebuild now falls back to since/until ("{since} : {until}") before defaulting to "No filter", so older charts export their configured range. Added a regression test.

Comment on lines +41 to +65
def adhoc_filters_to_query_filters(
adhoc_filters: list[dict[str, Any]],
) -> list[dict[str, Any]]:
"""
Convert ``SIMPLE`` adhoc filters into QueryObject filter clauses.

Adhoc filters use ``{subject, operator, comparator}`` while a query object
expects ``{col, op, val}``. Only ``SIMPLE`` WHERE-clause filters are
convertible here; free-form ``SQL`` filters have no ``{col, op, val}``
equivalent and are handled separately (see :func:`freeform_where_having`).
"""
result: list[dict[str, Any]] = []
for flt in adhoc_filters or []:
if (
flt.get("expressionType") == "SIMPLE"
and (flt.get("clause") or "WHERE").upper() == "WHERE"
):
result.append(
{
"col": flt.get("subject"),
"op": flt.get("operator"),
"val": flt.get("comparator"),
}
)
return result

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

adhoc_filters_to_query_filters now only converts SIMPLE filters with a WHERE clause, while the previous chart_utils implementation converted all SIMPLE filters. This means SIMPLE HAVING filters previously handled by mcp_service/chart/compile.py and preview_utils.py are now silently dropped.

Should we preserve the previous behavior here and handle both WHERE and HAVING filters?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reverted in 53350aeadhoc_filters_to_query_filters again converts all SIMPLE filters regardless of clause, so SIMPLE HAVING filters the MCP compile/preview path relied on are no longer dropped. Added a test pinning that a SIMPLE HAVING filter still converts. (Note: the frontend processFilters does drop SIMPLE-HAVING, but preserving the prior shared behavior here is the safer choice and keeps MCP unchanged.)

Comment on lines +94 to +121
def columns_from_form_data(form_data: dict[str, Any]) -> list[Any]:
"""
Derive the query's grouping/raw columns from form data.

Handles raw-mode tables (``all_columns``/``columns``), an ``x_axis`` (string
or adhoc column), and ``groupby`` dimensions, de-duplicating while preserving
order.
"""
if form_data.get("query_mode") == "raw" and (
form_data.get("all_columns") or form_data.get("columns")
):
return list(form_data.get("all_columns") or form_data.get("columns") or [])

groupby_columns: list[Any] = form_data.get("groupby") or []
raw_columns: list[Any] = form_data.get("columns") or []
# Prefer explicit raw columns only when they are actually present; a stale
# empty ``columns: []`` key must not shadow the group-by dimensions (which
# would silently drop the grouping and change the aggregation).
columns = raw_columns.copy() if raw_columns else groupby_columns.copy()

x_axis = form_data.get("x_axis")
if isinstance(x_axis, str) and x_axis and x_axis not in columns:
columns.insert(0, x_axis)
elif isinstance(x_axis, dict):
col_name = x_axis.get("column_name")
if col_name and col_name not in columns:
columns.insert(0, col_name)
return columns

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

columns_from_form_data now checks the truthiness of raw_columns instead of whether "columns" exists in form_data. As a result, columns: [] no longer takes precedence over groupby in the MCP preview/compile path.

This looks like an improvement, but it changes the previous behavior. Could we confirm this is intentional and add an MCP-path test to cover it?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed intentional — this fixes a real bug (an earlier reviewer flagged that a stale, present-but-empty columns: [] was silently dropping groupby and changing the aggregation). Now that preview_utils._build_query_columns delegates to the shared columns_from_form_data, the MCP compile/preview path gets the same fix. Added an MCP-path test (test_build_query_columns_empty_columns_key_keeps_groupby) calling preview_utils._build_query_columns({"groupby": ["country"], "columns": []}) and asserting ["country"].

Comment on lines +180 to +182
# Table percent metrics are computed as additional query metrics.
if viz_type == "table" and form_data.get("percent_metrics"):
metrics = [*metrics, *form_data["percent_metrics"]]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For table charts, percent_metrics are added as regular query metrics without applying the percentage/contribution post-processing.
This means the exported column contains the raw aggregate instead of the "% of total" shown in the chart.

Should we exclude percent_metrics from the rebuild to avoid exporting values that don't match what the user sees in the chart?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call — excluded in 53350ae. Since the rebuild doesn't apply the contribution/percent post-processing, carrying percent_metrics would export raw aggregates under a column the user expects to be a "% of total", i.e. silently-wrong values. Omitting them is the safer choice (consistent with the rest of this rebuild); time_grain_sqla is still carried. Test updated to assert they're excluded.

Comment on lines +55 to +61
if flt.get("expressionType") == "SIMPLE":
result.append(
{
"col": flt.get("subject"),
"op": flt.get("operator"),
"val": flt.get("comparator"),
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: A SIMPLE adhoc filter marked with clause HAVING is always appended to filters, which applies it before aggregation as a WHERE predicate. Aggregate conditions such as COUNT(*) > 5 will therefore produce incorrect results or fail in the database. Route HAVING filters to the query's having expression instead of treating every SIMPLE filter as a row-level filter. [logic error]

Severity Level: Major ⚠️
- ❌ Aggregate conditions are applied before chart aggregation.
- ❌ Legacy chart Excel sheets can contain incorrect totals.
- ⚠️ Databases may reject aggregate predicates in WHERE.
Steps of Reproduction ✅
1. Create or use a legacy chart whose saved `params` contain a `SIMPLE` adhoc filter with
`clause: "HAVING"` and an aggregate condition, such as a metric count greater than five.

2. Export a dashboard containing that chart through the Excel export task in
`superset/tasks/export_dashboard_excel.py`; `_build_workbook()` at lines 272-291 resolves
the missing context and invokes `_write_chart_sheets()`.

3. `_resolve_query_context()` at lines 148-164 calls
`build_query_context_from_form_data()`, which calls `adhoc_filters_to_query_filters()` at
line 192.

4. `adhoc_filters_to_query_filters()` at lines 55-61 converts the filter into the query's
`filters` list without inspecting its HAVING clause, while `freeform_where_having()` at
lines 79-88 only handles SQL filters. The generated query therefore applies the aggregate
predicate as a row-level WHERE filter, producing incorrect results or a database
validation error.

Fix in Cursor Fix in VSCode Claude

(Use Cmd/Ctrl + Click for best experience)

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** superset/common/form_data_query_context.py
**Line:** 55:61
**Comment:**
	*Logic Error: A SIMPLE adhoc filter marked with clause HAVING is always appended to `filters`, which applies it before aggregation as a WHERE predicate. Aggregate conditions such as `COUNT(*) > 5` will therefore produce incorrect results or fail in the database. Route HAVING filters to the query's having expression instead of treating every SIMPLE filter as a row-level filter.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

Comment on lines +122 to +124
if not isinstance(parsed, dict) or not parsed.get("queries"):
return None
return parsed

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: _saved_query_context only checks that queries is truthy, so malformed contexts such as {"queries": "invalid"} or other non-list values are accepted and passed to ChartDataCommand. This causes validation or iteration failures to be reported as a generic chart export error instead of being classified as missing query context and listed for remediation. Require queries to be a non-empty list with the expected query-object structure before returning the parsed context. [possible bug]

Severity Level: Major ⚠️
- ❌ Affected chart data sheets are omitted from exports.
- ⚠️ Users receive generic errors instead of remediation guidance.
- ⚠️ Malformed saved contexts bypass missing-context handling.
Steps of Reproduction ✅
1. Persist a chart with `query_context` containing valid JSON such as `{"queries":
"invalid"}`; this is accepted by `json.loads()` at line 119 and produces a truthy
`queries` value.

2. Include that chart in a data-mode dashboard Excel export; `_build_workbook()` at lines
272-291 calls `_resolve_query_context()` for the chart.

3. `_saved_query_context()` at lines 122-124 returns the parsed dictionary because it
checks only that `queries` is truthy, not that it is a non-empty list of query objects.

4. `_write_chart_sheets()` receives the malformed payload at line 291 and passes it into
the downstream chart-data execution path, where schema validation or query iteration
fails. The exception is handled as a generic chart export failure instead of being
classified under `email.ERROR_NO_QUERY_CONTEXT` for re-saving.

Fix in Cursor Fix in VSCode Claude

(Use Cmd/Ctrl + Click for best experience)

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** superset/tasks/export_dashboard_excel.py
**Line:** 122:124
**Comment:**
	*Possible Bug: `_saved_query_context` only checks that `queries` is truthy, so malformed contexts such as `{"queries": "invalid"}` or other non-list values are accepted and passed to `ChartDataCommand`. This causes validation or iteration failures to be reported as a generic chart export error instead of being classified as missing query context and listed for remediation. Require `queries` to be a non-empty list with the expected query-object structure before returning the parsed context.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

# Conservative by default: only charts whose data maps faithfully to a single
# plain query (no post-processing, no multi-query fan-out). Operators can
# override via ``EXCEL_EXPORT_REBUILD_VIZ_TYPES``.
REBUILD_VIZ_TYPES = {"table", "big_number_total", "big_number", "pie"}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

REBUILD_VIZ_TYPES only checks the viz type, but some behaviors that require post-processing or multiple queries depend on the form data.

For example, a table with time_compare requires additional queries/post-processing, and a Big Number with rolling/resampling can also produce values that differ from the raw query result. Rebuilding these directly could therefore export incomplete or incorrect data.

Should we also skip the rebuild when rolling_type, resample_rule, time_compare, or aggregation === "raw" is present?

It would be good to add a regression test for at least the time_compare case.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point — the viz-type allowlist isn't sufficient on its own. Fixed in 808ba6f: _resolve_query_context now also skips the rebuild (→ re-save list) when the form data uses processing the single query can't reproduce — time_compare, rolling_type (ignoring the literal "None"), resample_rule, or aggregation == "raw". Added a parametrized regression test covering all four (including time_compare).

Comment on lines +221 to +225
granularity = form_data.get("granularity") or form_data.get("granularity_sqla")
if granularity and time_range != "No filter":
query["granularity"] = granularity
if form_data.get("row_limit"):
query["row_limit"] = form_data["row_limit"]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For Big Number trendlines, granularity is only set when time_range != "No filter". However, time_grain_sqla is only applied to the column identified by granularity.

This means a Big Number with no time filter can export rows at the raw timestamp precision instead of the configured time grain.

Should we always set granularity to the promoted time column for Big Number trendlines, regardless of time_range?

It would also be good to add a regression test for a Big Number with time_grain_sqla and no time_range.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 808ba6f. When a Big Number trendline promotes its granularity_sqla to the grouping column, the rebuild now also sets granularity regardless of time_range, so time_grain_sqla buckets that column (no more raw-timestamp precision). The time_range != "No filter" gate still applies to the non-promoted cases, so a non-temporal granularity_sqla with no active range isn't forced through date bucketing. Added a regression test (Big Number with time_grain_sqla and no time_range).

"""
result: list[dict[str, Any]] = []
for flt in adhoc_filters or []:
if flt.get("expressionType") == "SIMPLE":

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Restoring support for all SIMPLE filters makes sense for MCP, but it creates a difference between the chart and the export. The chart only applies SIMPLE filters with clause === "WHERE", while the export would also apply HAVING filters, potentially resulting in fewer rows than what the user sees.

Should we filter out HAVING filters in build_query_context_from_form_data while keeping the shared helper unchanged for MCP?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 808ba6f — exactly your suggestion. Added a where_only arg to the shared adhoc_filters_to_query_filters: it defaults to converting all SIMPLE filters (unchanged for MCP), and build_query_context_from_form_data calls it with where_only=True so the export applies only WHERE-clause SIMPLE filters, matching the chart. Added tests for both the helper (default vs where_only) and the builder (SIMPLE HAVING excluded).

@bito-code-review

bito-code-review Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Code Review Agent Run #0a2f4b

Actionable Suggestions - 0
Review Details
  • Files reviewed - 3 · Commit Range: 670a952..0f61b3e
    • superset/common/form_data_query_context.py
    • tests/unit_tests/common/test_form_data_query_context.py
    • tests/unit_tests/mcp_service/chart/test_preview_utils.py
  • Files skipped - 0
  • Tools
    • MyPy (Static Code Analysis) - ✔︎ Successful
    • Astral Ruff (Static Code Analysis) - ✔︎ Successful
    • Whispers (Secret Scanner) - ✔︎ Successful
    • Detect-secrets (Secret Scanner) - ✔︎ Successful

Bito Usage Guide

Commands

Type the following command in the pull request comment and save the comment.

  • /review - Manually triggers a full AI review.

  • /pause - Pauses automatic reviews on this pull request.

  • /resume - Resumes automatic reviews.

  • /resolve - Marks all Bito-posted review comments as resolved.

  • /abort - Cancels all in-progress reviews.

Refer to the documentation for additional commands.

Configuration

This repository uses Superset You can customize the agent settings here or contact your Bito workspace admin at evan@preset.io.

Documentation & Help

AI Code Review powered by Bito Logo

@bito-code-review

bito-code-review Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Code Review Agent Run #c07fbd

Actionable Suggestions - 0
Review Details
  • Files reviewed - 4 · Commit Range: 0f61b3e..808ba6f
    • superset/common/form_data_query_context.py
    • superset/tasks/export_dashboard_excel.py
    • tests/unit_tests/common/test_form_data_query_context.py
    • tests/unit_tests/tasks/test_export_dashboard_excel.py
  • Files skipped - 0
  • Tools
    • MyPy (Static Code Analysis) - ✔︎ Successful
    • Astral Ruff (Static Code Analysis) - ✔︎ Successful
    • Whispers (Secret Scanner) - ✔︎ Successful
    • Detect-secrets (Secret Scanner) - ✔︎ Successful

Bito Usage Guide

Commands

Type the following command in the pull request comment and save the comment.

  • /review - Manually triggers a full AI review.

  • /pause - Pauses automatic reviews on this pull request.

  • /resume - Resumes automatic reviews.

  • /resolve - Marks all Bito-posted review comments as resolved.

  • /abort - Cancels all in-progress reviews.

Refer to the documentation for additional commands.

Configuration

This repository uses Superset You can customize the agent settings here or contact your Bito workspace admin at evan@preset.io.

Documentation & Help

AI Code Review powered by Bito Logo

:param viz_type: The chart's viz type, used for viz-specific handling.
:returns: A single-query query-context dict.
"""
metrics = list(form_data.get("metrics") or [])

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In raw-mode table charts, saved form data can still contain stale metrics/groupby values because those controls aren't reset when hidden. The frontend ignores them in raw mode, but the rebuild currently doesn't, so the exported query can differ from what the chart actually runs.

For example, this can add aggregate columns/grouping and even introduce an orderby based on a stale metric, potentially exporting different rows from those shown in the chart.

Could we mirror the frontend behavior here by dropping metrics/groupby in raw mode and handling all_columns consistently with getQueryMode? A regression test using Publishers_With_Most_Titles would cover this well.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 2565811. The rebuild now mirrors getQueryMode (a new is_raw_query_mode: explicit query_mode, else all_columns presence) and in raw mode uses only the selected columns while ignoring the stale metrics/groupby — so no accidental aggregation, grouping, or metric-based orderby. Added regression tests, including one for the Publishers With Most Titles-style raw table (all_columns + stale metrics/groupby → columns only, metrics=[], orderby=[]).

if not metrics:
return []

order_desc = form_data.get("order_desc", True)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

order_desc defaults to True here, while the table plugin defaults it to false.
This means a table with timeseries_limit_metric and no saved order_desc can sort ascending in the chart but descending in the export; potentially changing bottom-N into top-N when a row limit is applied.

We can't change the default globally because Pie always sorts by metric descending.
Could we make this viz-specific: default to ascending for Table and descending for Pie?
A regression test for a Table with timeseries_limit_metric and no order_desc would also be useful.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. Made it viz-specific in 2565811: orderby_from_form_data now defaults order_desc to False (ascending) for Table and True (descending) for Pie/others, matching the plugins — so a table with timeseries_limit_metric and no saved order_desc sorts ascending in the export too, keeping bottom-N under a row limit. Added a regression test (Table + timeseries_limit_metric, no order_desc → [[metric, True]]).

Comment on lines +187 to +190
# ``percent_metrics`` are intentionally not carried: the chart shows them as a
# "% of total" produced by contribution post-processing, which this rebuild
# does not apply, so adding them as plain metrics would export raw aggregates
# that don't match the chart.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dropping percent_metrics means the export is missing percentage columns that are visible in the chart.
This affects both Table and Pie charts, where contribution post-processing adds these values.

Since the goal is to avoid silently exporting incomplete data, should we treat a non-empty percent_metrics like the other _UNSUPPORTED_PROCESSING_KEYS and require the chart to be re-saved?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed — silently omitting visible columns is worse than skipping. In 2565811 a non-empty percent_metrics is now treated like the other _UNSUPPORTED_PROCESSING_KEYS: the chart is skipped and listed for re-save rather than rebuilt. Added it to the parametrized skip test.

Comment on lines +86 to +91
if form_data.get("where"):
where.append(form_data["where"])
for flt in form_data.get("adhoc_filters") or []:
if flt.get("expressionType") == "SQL" and flt.get("sqlExpression"):
clause = (flt.get("clause") or "WHERE").upper()
(having if clause == "HAVING" else where).append(flt["sqlExpression"])

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: Unlike the frontend filter processor, this path does not append a newline when a free-form SQL clause contains --. The generated wrapper can therefore turn the closing parenthesis and following SQL into a comment, causing a syntax error or changing the predicate semantics during export. Apply the same clause sanitization as processFilters before placing expressions in extras. [logic error]

Severity Level: Major ⚠️
- ❌ SQL-filtered Excel exports can fail query execution.
- ⚠️ Export predicates can differ from displayed chart results.
- ⚠️ Affected charts are omitted or reported as export errors.

Fix in Cursor Fix in VSCode Claude

(Use Cmd/Ctrl + Click for best experience)

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** superset/common/form_data_query_context.py
**Line:** 86:91
**Comment:**
	*Logic Error: Unlike the frontend filter processor, this path does not append a newline when a free-form SQL clause contains `--`. The generated wrapper can therefore turn the closing parenthesis and following SQL into a comment, causing a syntax error or changing the predicate semantics during export. Apply the same clause sanitization as `processFilters` before placing expressions in `extras`.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

Comment on lines +121 to +127
x_axis = form_data.get("x_axis")
if isinstance(x_axis, str) and x_axis and x_axis not in columns:
columns.insert(0, x_axis)
elif isinstance(x_axis, dict):
col_name = x_axis.get("column_name")
if col_name and col_name not in columns:
columns.insert(0, col_name)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: An adhoc x-axis object representing a calculated column is reduced to only column_name, discarding its sqlExpression and other expression metadata. The rebuilt query then references the physical column name instead of the calculated expression, so legacy charts using SQL or calculated x-axis columns can fail or return different data. Preserve the full adhoc column definition, or explicitly reject this form when the builder cannot support it. [type error]

Severity Level: Major ⚠️
- ❌ Calculated-axis chart exports can omit their grouping.
- ⚠️ Exported results can differ from chart visualization.
- ⚠️ SQL-expression axes may cause query failures.

Fix in Cursor Fix in VSCode Claude

(Use Cmd/Ctrl + Click for best experience)

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** superset/common/form_data_query_context.py
**Line:** 121:127
**Comment:**
	*Type Error: An adhoc x-axis object representing a calculated column is reduced to only `column_name`, discarding its `sqlExpression` and other expression metadata. The rebuilt query then references the physical column name instead of the calculated expression, so legacy charts using SQL or calculated x-axis columns can fail or return different data. Preserve the full adhoc column definition, or explicitly reject this form when the builder cannot support it.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

Comment on lines +155 to +164
if order_by_cols := form_data.get("order_by_cols") or []:
parsed: list[list[Any]] = []
for col in order_by_cols:
if isinstance(col, str):
try:
col = json.loads(col)
except (TypeError, ValueError):
continue
parsed.append(col)
return parsed

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: Successfully parsed order_by_cols values are appended without validating that they are two-element [column, ascending] pairs. Values such as null, a scalar, a dictionary, or a one-element list reach ChartDataQueryContextSchema, which expects tuple pairs and rejects the entire chart export instead of treating the malformed legacy ordering as absent. Validate the shape before appending it. [type error]

Severity Level: Major ⚠️
- ❌ Malformed ordering can abort chart Excel export.
- ⚠️ One legacy chart can be skipped from the workbook.
- ⚠️ Recoverable form-data corruption becomes a user-visible error.

Fix in Cursor Fix in VSCode Claude

(Use Cmd/Ctrl + Click for best experience)

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** superset/common/form_data_query_context.py
**Line:** 155:164
**Comment:**
	*Type Error: Successfully parsed `order_by_cols` values are appended without validating that they are two-element `[column, ascending]` pairs. Values such as `null`, a scalar, a dictionary, or a one-element list reach `ChartDataQueryContextSchema`, which expects tuple pairs and rejects the entire chart export instead of treating the malformed legacy ordering as absent. Validate the shape before appending it.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

Comment on lines +234 to +236
for flt in form_data.get("filters") or []:
if isinstance(flt, dict) and flt.get("col") is not None:
filters.append(flt)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: Legacy filter entries are accepted whenever they contain a col, even if they lack the required op or val fields. Such malformed entries later reach query-context processing, which indexes the missing filter fields and can raise an exception for the whole chart. Require a complete query-filter shape before appending legacy filters. [error handling]

Severity Level: Major ⚠️
- ❌ Corrupt legacy filters can fail chart export.
- ⚠️ Affected charts may be omitted from workbooks.
- ⚠️ Malformed saved form data reaches query processing.

Fix in Cursor Fix in VSCode Claude

(Use Cmd/Ctrl + Click for best experience)

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** superset/common/form_data_query_context.py
**Line:** 234:236
**Comment:**
	*Error Handling: Legacy filter entries are accepted whenever they contain a `col`, even if they lack the required `op` or `val` fields. Such malformed entries later reach query-context processing, which indexes the missing filter fields and can raise an exception for the whole chart. Require a complete query-filter shape before appending legacy filters.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

Comment on lines +248 to +254
query: dict[str, Any] = {
"columns": columns,
"metrics": metrics,
"orderby": orderby_from_form_data(form_data, metrics, viz_type),
"filters": filters,
"time_range": time_range,
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: The generic rebuild omits table and other chart controls such as series_limit and series_limit_metric. A chart with a series limit can therefore return arbitrary groups up to row_limit instead of the configured top series, producing materially different export data while still being treated as a successful rebuild. These controls must be translated into the query context or such charts must be skipped. [api mismatch]

Severity Level: Critical 🚨
- ❌ Allowlisted chart exports can contain incorrect groups.
- ⚠️ Top-series and row-limit behavior diverges from charts.
- ⚠️ Exported Excel data may not match displayed chart data.

Fix in Cursor Fix in VSCode Claude

(Use Cmd/Ctrl + Click for best experience)

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** superset/common/form_data_query_context.py
**Line:** 248:254
**Comment:**
	*Api Mismatch: The generic rebuild omits table and other chart controls such as `series_limit` and `series_limit_metric`. A chart with a series limit can therefore return arbitrary groups up to `row_limit` instead of the configured top series, producing materially different export data while still being treated as a successful rebuild. These controls must be translated into the query context or such charts must be skipped.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

Comment on lines +174 to +183
if chart.viz_type not in _rebuild_viz_types() or chart.datasource_id is None:
return None
try:
form_data = json.loads(chart.params) if chart.params else {}
except (TypeError, ValueError):
return None
if not isinstance(form_data, dict) or not form_data:
return None
if _needs_unsupported_processing(form_data):
return None

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: The default rebuild allowlist permits table charts, but the synthesized context does not reproduce table-specific extra queries such as show_totals or percent-metric calculations. An older allowlisted table using these options will export only the main query and silently omit the totals or derived data shown by the chart. Either detect these settings in _needs_unsupported_processing or include the required queries and post-processing before allowing the rebuild. [incomplete implementation]

Severity Level: Critical 🚨
- ❌ Table Excel exports can omit configured totals rows.
- ⚠️ Exported table data differs from displayed chart output.
- ⚠️ Legacy charts are marked successful despite incomplete results.

Fix in Cursor Fix in VSCode Claude

(Use Cmd/Ctrl + Click for best experience)

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** superset/tasks/export_dashboard_excel.py
**Line:** 174:183
**Comment:**
	*Incomplete Implementation: The default rebuild allowlist permits table charts, but the synthesized context does not reproduce table-specific extra queries such as `show_totals` or percent-metric calculations. An older allowlisted table using these options will export only the main query and silently omit the totals or derived data shown by the chart. Either detect these settings in `_needs_unsupported_processing` or include the required queries and post-processing before allowing the rebuild.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

Comment on lines +155 to +160
if order_by_cols := form_data.get("order_by_cols") or []:
parsed: list[list[Any]] = []
for col in order_by_cols:
if isinstance(col, str):
try:
col = json.loads(col)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

orderby_from_form_data checks order_by_cols in every mode, but this is a raw-mode-only control.
Since its value isn't reset when switching to aggregate mode, a stale order_by_cols can cause the export to use a different ordering from the chart, potentially returning a different top-N.

Could we only use order_by_cols when is_raw_query_mode(form_data) is true? It would also be good to add a regression test for an aggregate table with a stale order_by_cols value.

@bito-code-review

bito-code-review Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Code Review Agent Run #f2291f

Actionable Suggestions - 0
Review Details
  • Files reviewed - 4 · Commit Range: 808ba6f..2565811
    • superset/common/form_data_query_context.py
    • superset/tasks/export_dashboard_excel.py
    • tests/unit_tests/common/test_form_data_query_context.py
    • tests/unit_tests/tasks/test_export_dashboard_excel.py
  • Files skipped - 0
  • Tools
    • MyPy (Static Code Analysis) - ✔︎ Successful
    • Astral Ruff (Static Code Analysis) - ✔︎ Successful
    • Whispers (Secret Scanner) - ✔︎ Successful
    • Detect-secrets (Secret Scanner) - ✔︎ Successful

Bito Usage Guide

Commands

Type the following command in the pull request comment and save the comment.

  • /review - Manually triggers a full AI review.

  • /pause - Pauses automatic reviews on this pull request.

  • /resume - Resumes automatic reviews.

  • /resolve - Marks all Bito-posted review comments as resolved.

  • /abort - Cancels all in-progress reviews.

Refer to the documentation for additional commands.

Configuration

This repository uses Superset You can customize the agent settings here or contact your Bito workspace admin at evan@preset.io.

Documentation & Help

AI Code Review powered by Bito Logo

hughhhh and others added 11 commits August 6, 2026 14:39
Address review of the empty query-context rebuild:

- columns_from_form_data no longer lets a stale, explicitly-present-but-
  empty `columns: []` key shadow the group-by dimensions (which silently
  dropped grouping and changed the aggregation). Prefer raw columns only
  when non-empty, else fall back to groupby.
- build_query_context_from_form_data now also honors legacy simple
  `filters` (already in QueryObject {col, op, val} shape) in addition to
  `adhoc_filters`, so legacy charts export the same filtered data they
  show; malformed entries are dropped.
- _rebuild_viz_types checks the config for None explicitly instead of
  using `or`, so an operator can disable the rebuild with an empty set
  instead of it silently falling back to the default allowlist.

Adds unit tests for each case.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Address EnxDev review — the generic rebuild silently dropped several
query aspects, so allowlisted charts could export a different dataset
than they show:

- Ordering: derive `orderby` from order_by_cols (raw) or the sort metric
  / first-metric-descending (aggregate), so a `row_limit` returns the
  chart's top-N instead of an arbitrary N.
- Time range: set `granularity` (from granularity/granularity_sqla) so
  `time_range` is actually applied — but only when there is an active
  range, so a numeric column saved as granularity_sqla with no range
  isn't forced through date bucketing (verified against a real export).
- Custom SQL filters: map `SQL` adhoc filters + legacy top-level `where`
  into `extras.where`/`extras.having` by clause instead of dropping them.
- Table specifics: carry `percent_metrics` into metrics and pass
  `time_grain_sqla` through `extras`.
- Big Number: only promote granularity_sqla to a grouping column for the
  trendline viz (`big_number`), never `big_number_total`.

Also point the MCP chart compile/preview helpers
(adhoc_filters_to_query_filters, _build_query_columns) at this shared
module so they stop diverging (preview_utils still had the pre-fix
`columns` bug), and address nits: drop the `-O`-stripped assert by
restructuring the loop, parse the saved query_context once, and correct
the shallow-copy comment.

Adds unit tests for every derived field plus an export test asserting the
full query body reaches ChartDataQueryContextSchema().load.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Exclude percent_metrics from the rebuild: the chart shows them as a
  "% of total" via contribution post-processing the rebuild can't apply,
  so adding them as plain metrics would export raw aggregates that don't
  match the chart.
- Restore converting all SIMPLE adhoc filters (not just WHERE-clause) so
  SIMPLE HAVING filters the MCP compile/preview path relied on are not
  silently dropped.
- Fall back to legacy since/until when time_range is absent, so older
  charts export their configured range instead of the full history.
- Add an MCP-path test pinning that an explicitly empty `columns: []` no
  longer shadows `groupby` (intentional behavior change), plus tests for
  the since/until fallback and SIMPLE HAVING conversion.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…atch

Address further review of the form-data query-context rebuild:

- Skip the rebuild when the form data relies on processing the single
  query can't reproduce: time_compare, rolling_type, resample_rule, or
  aggregation=raw. Even allowlisted viz types would otherwise export
  values that differ from the chart.
- Big Number trendline: set granularity (so time_grain_sqla buckets the
  promoted time column) even with no active time_range.
- Export applies only WHERE-clause SIMPLE filters (via a new where_only
  arg), matching the chart, while the shared helper still converts all
  SIMPLE filters for the MCP path.

Adds regression tests for each case.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…metrics

Further review of the form-data query-context rebuild:

- Raw-mode tables: ignore stale metrics/groupby (their controls aren't
  reset when hidden) and use only the selected columns, mirroring the
  frontend's getQueryMode (explicit query_mode, else all_columns).
- Ordering: default order_desc to False (ascending) for Table sort
  metrics and True for Pie, matching the plugins — so a row limit keeps
  the chart's top/bottom-N instead of flipping it.
- percent_metrics: skip the chart (re-save) rather than silently omitting
  the "% of total" columns the user sees, alongside the other
  unsupported-processing markers.

Adds regression tests for each; extracts a helper to keep the builder
under the complexity limit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
order_by_cols is a raw-mode-only control (resetOnHide: false), so an
aggregate chart can carry a stale value. The form-data query-context
rebuild read it in every mode, which could order an aggregate export by
stale columns and return a different top-N than the chart shows. Gate it
behind is_raw_query_mode so aggregate mode falls back to the metric-based
ordering, mirroring the frontend Table buildQuery.

Also add frontend-drift pointers naming the mirrored buildQuery /
extractQueryFields / processFilters sources, and a regression test for an
aggregate table with a stale order_by_cols.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Drop the EXCEL_EXPORT_REBUILD_VIZ_TYPES config override and use the
REBUILD_VIZ_TYPES constant directly. The allow-list bounds which legacy
charts (no saved query_context) get a form-data-rebuilt context in the
Excel export; it's correctness-critical and conservative by design, and
the config override was speculative flexibility nobody needs. Everything
else without a saved context is still skipped and listed for re-save.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…port

Add EXCEL_EXPORT_QUERY_CONTEXT_BUILDER, an optional hook tried before the
built-in form-data rebuild when a chart has no saved query_context. It
receives the chart's form data and returns a query-context payload (or
None), letting a deployment supply a faithful context — e.g. from a
service running the chart's real frontend buildQuery — for viz types the
built-in rebuild can't reproduce (pivot, timeseries, multi-query).

The hook must return None when it can't build faithfully, so an
allowlisted table stays on the tested built-in path until a faithful
builder is available. The call is guarded (any failure falls through to
the rebuild) and its result is shape-validated via the extracted
_usable_query_context helper, preserving "builder problem → rebuild,
don't fail the export". The allowlist and unsupported-processing checks
now bound only the built-in rebuild; the hook is intentionally not gated
by them. Default None keeps OSS behavior unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… rebuild

The form-data query-context rebuild is a hand-port of frontend `buildQuery`
logic, and had drifted from it in four ways that make a rebuilt sheet differ
from what the chart renders:

- Free-form SQL filters were wrapped as `({clause})` with no newline. A
  predicate ending in a `--` comment commented out the closing paren and every
  predicate joined after it, failing the export on a chart that renders fine.
  Ports `sanitizeClause` from `processFilters.ts`.
- `timeseries_limit_metric` was read raw. The drag-and-drop sort-by control
  persists a list, which nested inside `orderby` and failed the query; now
  unwrapped with `as_list(...)[0]`, mirroring `ensureIsArray(...)[0]`.
- Pie's `contribution` operator is attached unconditionally by
  `Pie/buildQuery.ts`, so a rebuilt pie lost the percentage column a
  saved-context pie carries. The rebuild now applies it.
- `show_totals` pushes a second totals query in aggregate mode; the
  single-query rebuild dropped that row silently. Such tables are now skipped
  and listed for re-save, like `percent_metrics` already were.

Also hardens the builder hook added in 9e2b5b4: its payload is deep-copied
before `apply_dashboard_filter_context` mutates `queries[*]` in place (a
memoizing builder would otherwise accumulate `isExtra` filters across charts
and exports), and `_usable_query_context` now type-checks `queries` as a list
so a malformed return falls through to the built-in rebuild instead of failing
later. `order_by_cols` entries that parse but aren't `[col, asc]` pairs are
dropped rather than appended to `orderby`.

Docs: `EXCEL_EXPORT_REBUILD_VIZ_TYPES` never existed — cf071d5 made the
allowlist a fixed constant — so UPDATING.md now documents the key that does
exist, `EXCEL_EXPORT_QUERY_CONTEXT_BUILDER`, and the .mdx no longer claims every
context-less chart is skipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…value

The isinstance check added in b435c47b lives in _usable_query_context, which
_saved_query_context delegates to, so a chart whose saved query_context is
`{"queries": "oops"}` now takes the clean "no query context" path instead of
failing later in the general error bucket. Pins that from the saved-context
side, where the behavior is user-visible.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@hughhhh
hughhhh force-pushed the hughhhh/handle-empty-query-context branch from 986f650 to 7896b4b Compare August 6, 2026 18:40
@bito-code-review

bito-code-review Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Code Review Agent Run #af1ee0

Actionable Suggestions - 0
Additional Suggestions - 3
  • superset/common/form_data_query_context.py - 1
    • Single-sided range produces malformed strings · Line 324-324
      Line 324 constructs ` : value` or `value : ` when exactly one of `since`/`until` is empty. These malformed patterns get passed to `get_since_until` — while they parse gracefully via `strip()`, the query payload contains incorrect metadata. The downstream `granularity` guard at line 346 has an independent condition and is not affected.
  • superset/tasks/export_dashboard_excel.py - 1
    • Shallow copy should be deep copy · Line 289-289
      The shallow copy at line 290 only copies the top-level dict, leaving nested `queries` shared with the caller's payload. If `_resolve_query_context` ever returns a shared reference (e.g., if the builder hook is later changed to return a cached value), the corruption still occurs even after the fix on line 191. A deep copy here makes the isolation explicit and complete.
  • superset/mcp_service/chart/preview_utils.py - 1
    • Return type annotation mismatch · Line 41-41
      The `_build_query_columns` function declares `-> list[str]` but calls `columns_from_form_data` which returns `list[Any]`. Align the return type annotation to prevent type-checker warnings and ensure consistency.
Filtered by Review Rules

Bito filtered these suggestions based on rules created automatically for your feedback. Manage rules.

  • superset/tasks/export_dashboard_excel.py - 2
Review Details
  • Files reviewed - 9 · Commit Range: 6f7b9fb..7896b4b
    • docs/docs/using-superset/exporting-dashboard-data.mdx
    • superset/common/form_data_query_context.py
    • superset/config.py
    • superset/mcp_service/chart/chart_utils.py
    • superset/mcp_service/chart/preview_utils.py
    • superset/tasks/export_dashboard_excel.py
    • tests/unit_tests/common/test_form_data_query_context.py
    • tests/unit_tests/mcp_service/chart/test_preview_utils.py
    • tests/unit_tests/tasks/test_export_dashboard_excel.py
  • Files skipped - 1
    • UPDATING.md - Reason: Filter setting
  • Tools
    • MyPy (Static Code Analysis) - ✔︎ Successful
    • Astral Ruff (Static Code Analysis) - ✔︎ Successful
    • Whispers (Secret Scanner) - ✔︎ Successful
    • Detect-secrets (Secret Scanner) - ✔︎ Successful

Bito Usage Guide

Commands

Type the following command in the pull request comment and save the comment.

  • /review - Manually triggers a full AI review.

  • /pause - Pauses automatic reviews on this pull request.

  • /resume - Resumes automatic reviews.

  • /resolve - Marks all Bito-posted review comments as resolved.

  • /abort - Cancels all in-progress reviews.

Refer to the documentation for additional commands.

Configuration

This repository uses Superset You can customize the agent settings here or contact your Bito workspace admin at evan@preset.io.

Documentation & Help

AI Code Review powered by Bito Logo

@hughhhh
hughhhh requested a review from EnxDev August 7, 2026 22:54
hughhhh and others added 4 commits August 7, 2026 18:54
The guard around EXCEL_EXPORT_QUERY_CONTEXT_BUILDER caught bare Exception,
and Celery's SoftTimeLimitExceeded subclasses it — so a soft timeout that
fired while the hook was in flight (a slow or hanging builder being the
realistic case) was logged as a builder failure and swallowed, and the
export carried on into the rebuild and the next chart.

Re-raise SoftTimeLimitExceeded before the broad guard so it reaches
_build_workbook, which already re-raises it deliberately: a soft timeout is
a task-level signal, not a per-chart failure. Every other hook failure still
falls through to the built-in rebuild.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@bito-code-review

bito-code-review Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Code Review Agent Run #f38997

Actionable Suggestions - 0
Review Details
  • Files reviewed - 2 · Commit Range: 7896b4b..e3c1781
    • superset/tasks/export_dashboard_excel.py
    • tests/unit_tests/tasks/test_export_dashboard_excel.py
  • Files skipped - 0
  • Tools
    • MyPy (Static Code Analysis) - ✔︎ Successful
    • Astral Ruff (Static Code Analysis) - ✔︎ Successful
    • Whispers (Secret Scanner) - ✔︎ Successful
    • Detect-secrets (Secret Scanner) - ✔︎ Successful

Bito Usage Guide

Commands

Type the following command in the pull request comment and save the comment.

  • /review - Manually triggers a full AI review.

  • /pause - Pauses automatic reviews on this pull request.

  • /resume - Resumes automatic reviews.

  • /resolve - Marks all Bito-posted review comments as resolved.

  • /abort - Cancels all in-progress reviews.

Refer to the documentation for additional commands.

Configuration

This repository uses Superset You can customize the agent settings here or contact your Bito workspace admin at evan@preset.io.

Documentation & Help

AI Code Review powered by Bito Logo

@eschutho

Copy link
Copy Markdown
Member

Thanks for this — tested it live end-to-end (real Celery worker, MailHog, MinIO-backed S3) rather than just reading the diff, and found one correctness gap worth fixing before merge.

The granularity gate in build_query_context_from_form_data (superset/common/form_data_query_context.py) still drops time-bucketing for table/pie charts grouped by their own time column when time_range is "No filter".

Repro (verified against a real dataset with per-day granularity, not one that happens to already be pre-aggregated to the grain being tested):

  • Chart: table, groupby: ["order_date"], granularity_sqla: "order_date", time_grain_sqla: "P1Y", time_range: "No filter", no saved query_context.
  • Exported sheet: 252 rows, each a raw, un-truncated order_date timestamp (e.g. 2003-11-14) — i.e. completely unaggregated data.
  • Same chart, only difference time_range: "2003-01-01 : 2006-01-01": 3 rows, correctly bucketed by year with proper aggregated counts.

Root cause: granularity is only set when time_range != "No filter" (or for the big_number trendline's promoted time column). But granularity and time_range are independent concerns in the real query object — I checked extractExtras.ts in superset-ui-core, and the frontend sets granularity from granularity_sqla/granularity unconditionally, with zero dependency on time_range. The backend's groupby-column loop (superset/models/helpers.py) only applies time-grain truncation to a selected column when it matches granularity — so omitting it here means the column is selected raw instead of bucketed, for the fairly ordinary "all-time totals by month/year" chart configuration.

The big_number trendline fix (promoted_time_column) is correct and I confirmed it works — it's specifically the general table/pie "explicit groupby includes the time column" case that's still affected.

I've added a regression test that reproduces this (currently failing against HEAD): test_table_groupby_time_column_without_time_range_is_bucketed in tests/unit_tests/common/test_form_data_query_context.py. Suggested fix: set granularity whenever granularity_sqla/granularity is present, independent of time_range (matching real frontend behavior), rather than gating on time_range != "No filter".

Everything else I exercised — happy-path rebuilds for all four allowlisted viz types, the _needs_unsupported_processing guard, malformed-query_context recovery, the in-flight export lock, and the full email→S3→download round trip via a real Celery worker — worked correctly.

eschutho and others added 2 commits August 10, 2026 23:33
… with time_range="No filter"

A table/pie chart grouped by its own time column, with a time grain set but
time_range="No filter" (a very ordinary "all-time totals by month/year"
configuration), currently loses time-bucketing: granularity is only set when
time_range != "No filter" (or for the big_number trendline's promoted time
column), so the column is selected raw instead of truncated to its grain.

Verified live against a real dataset with per-day granularity: the same chart
returns 252 raw-timestamp rows with time_range="No filter" vs. 3 correctly
bucketed yearly rows with an explicit time_range.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…a time range

`granularity` does two unrelated jobs downstream: it names the temporal
column the time range filters on, and it is the column `time_grain_sqla`
buckets (`models/helpers.py` swaps a selected column for its timestamp
expression when that column equals `granularity`). The rebuild gated it on
`time_range != "No filter"`, which is right for the first job and wrong for
the second.

So a table or pie grouped by its own time column with a grain set but no
active range — plain "all-time totals by month" — exported one row per raw
timestamp instead of one per month. Set `granularity` whenever form data
carries one, matching the frontend's `extractExtras.ts`, which sets it
unconditionally. That also makes the Big Number trendline's
`promoted_time_column` flag dead, so it's removed.

Fixes the regression test added in f7a9dbf.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@eschutho

Copy link
Copy Markdown
Member

Verified the fix in f586967 live end-to-end (same real Celery worker / MailHog / MinIO setup as before) — the exact scenario that was broken now works correctly:

  • table chart (groupby: ["order_date"], granularity_sqla: "order_date", time_grain_sqla: "P1Y", time_range: "No filter", on a dataset with real per-day granularity): now exports 3 correctly-bucketed yearly rows matching a control chart with an explicit time range, instead of the previous 252 raw un-bucketed rows.
  • Also checked pie with the same groupby-own-time-column + "No filter" combination (hadn't tested that exact pairing before) — also correctly bucketed, and the contribution percentages check out (sum to 1.0).
  • Re-ran the full original test matrix (allowlisted happy paths, non-allowlisted skip, _needs_unsupported_processing guard, malformed-query_context recovery, in-flight export lock, images-mode feature-flag gate) — no regressions.
  • Full unit suite: 85 passed, including the regression test and its now-updated sibling test.

Looks good from a correctness standpoint. Going to do a full code review pass on the current state next (a lot has landed since my first read — the config hook, Pie contribution post-processing, the raw-mode/order-by hardening — worth a fresh look rather than assuming my earlier review still covers it).

@eschutho eschutho left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving. Summary of what was checked (full details in my earlier comments):

  • Live end-to-end verification with a real Celery worker, MailHog, and a MinIO-backed S3 endpoint — happy paths for all four allowlisted viz types, the _needs_unsupported_processing guard, malformed-query_context recovery, the in-flight export lock, and the images-mode feature-flag gate all behave correctly.
  • Found and reported a real bug (granularity dropped for a table/pie chart grouped by its own time column with time_range: "No filter") — fixed in f586967 and reverified live for both viz types plus the full regression matrix.
  • Reviewed the current diff, including the EXCEL_EXPORT_QUERY_CONTEXT_BUILDER hook, Pie's contribution post-processing, and the order_by_cols/timeseries_limit_metric hardening — no outstanding correctness issues. Test coverage is thorough (85/85 passing, including dedicated builder-hook and mutation-safety tests). Ruff clean.

Thanks for iterating on this — nice fix, and appreciated the design hook for a real-buildQuery backend.

@hughhhh
hughhhh merged commit d84a00f into master Aug 11, 2026
68 checks passed
@hughhhh
hughhhh deleted the hughhhh/handle-empty-query-context branch August 11, 2026 01:12
@bito-code-review

Copy link
Copy Markdown
Contributor

Bito Automatic Review Skipped – PR Already Merged

Bito scheduled an automatic review for this pull request, but the review was skipped because this PR was merged before the review could be run.
No action is needed if you didn't intend to review it. To get a review, you can type /review in a comment and save it

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dashboard:export Related to exporting dashboards doc Namespace | Anything related to documentation size/XXL

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants