This is an automated email from the ASF dual-hosted git repository.
aminghadersohi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git
The following commit(s) were added to refs/heads/master by this push:
new c14ac29f477 fix(mcp): complete Treemap round-trip and previews (#44152)
c14ac29f477 is described below
commit c14ac29f4778ba8e27b3cdb5832c37dfba8d4bdb
Author: Amin Ghadersohi <[email protected]>
AuthorDate: Wed Sep 23 16:25:30 2026 +1000
fix(mcp): complete Treemap round-trip and previews (#44152)
---
docs/admin_docs/configuration/mcp-server.mdx | 61 +-
superset/mcp_service/chart/chart_helpers.py | 35 +
superset/mcp_service/chart/chart_utils.py | 124 ++
superset/mcp_service/chart/compile.py | 21 +-
superset/mcp_service/chart/plugins/treemap.py | 10 +-
superset/mcp_service/chart/preview_utils.py | 14 +-
superset/mcp_service/chart/query_result.py | 113 +-
superset/mcp_service/chart/schemas.py | 357 +++--
superset/mcp_service/chart/tool/get_chart_data.py | 9 +
.../mcp_service/chart/tool/get_chart_preview.py | 30 +-
.../chart/tool/get_chart_type_schema.py | 21 +-
superset/mcp_service/chart/tool/update_chart.py | 30 +-
.../mcp_service/chart/tool/update_chart_preview.py | 51 +-
superset/mcp_service/chart/treemap_preview.py | 297 +++++
.../explore/tool/generate_explore_link.py | 3 +-
.../mcp_service/chart/test_treemap_completeness.py | 1373 ++++++++++++++++++++
.../mcp_service/chart/tool/test_get_chart_data.py | 155 +++
.../chart/tool/test_get_chart_type_schema.py | 26 +
.../explore/tool/test_generate_explore_link.py | 49 +
19 files changed, 2598 insertions(+), 181 deletions(-)
diff --git a/docs/admin_docs/configuration/mcp-server.mdx
b/docs/admin_docs/configuration/mcp-server.mdx
index 319db21ccc4..086dce6b9a4 100644
--- a/docs/admin_docs/configuration/mcp-server.mdx
+++ b/docs/admin_docs/configuration/mcp-server.mdx
@@ -1048,7 +1048,7 @@ Specifying a tool name that does not exist logs a warning
at startup and is othe
## Disabling chart type plugins
-The `generate_chart` tool dispatches per chart type (`xy`, `table`, `pie`,
`gauge`, `pivot_table`, `interactive_pivot`, `mixed_timeseries`, `handlebars`,
`big_number`, `histogram`, `box_plot`, `waterfall`) to a registered chart type
plugin. Gauge requests use the public MCP discriminator `chart_type: "gauge"`;
Superset stores the corresponding native Explore visualization as `viz_type:
"gauge_chart"`. Two settings let operators enable or disable individual chart
type plugins at runtime, [...]
+The `generate_chart` tool dispatches per chart type (`xy`, `table`, `pie`,
`gauge`, `treemap_v2`, `pivot_table`, `interactive_pivot`, `mixed_timeseries`,
`handlebars`, `big_number`, `histogram`, `box_plot`, `waterfall`) to a
registered chart type plugin. Gauge requests use the public MCP discriminator
`chart_type: "gauge"`; Superset stores the corresponding native Explore
visualization as `viz_type: "gauge_chart"`. Two settings let operators enable
or disable individual chart type plugin [...]
Gauge data inspection and JSON/CSV/XLSX exports preserve source groups,
including NULL and non-finite metric values, using the same row-count and
completeness semantics as other chart types. The default XLSX exporter writes
non-finite floats as `nan`, `inf`, or `-inf` text, matching CSV and
distinguishing them from blank NULL cells. Only previews and compile checks
skip NULL, NaN, non-finite, and other nonnumeric dial values while preserving
finite groups. In preview/compile paths, a non [...]
@@ -1212,6 +1212,65 @@ The tool does not rewrite dots into SQL path separators:
quoting and nested-fiel
support depend on the dataset's database dialect, and dots can also be literal
characters in column names.
+
+### Treemap charts
+
+Saved and cached previews honor the configured Treemap row limit across ASCII,
+table, and Vega representations. SQL Decimal metrics are accepted when finite;
+booleans and nonfinite values are rejected. Categories with identical display
+names but distinct raw types retain separate geometry groups. Currency settings
+use the native `symbolPosition` property. Adding filters while omitting the
+temporal column preserves saved predicates and the existing neutral time
binding;
+explicit empty filters or a cleared temporal column remove those controls.
+
+
+Use `chart_type: "treemap_v2"` with an ordered `groupby` hierarchy and one
+`metric`. Hierarchy columns accept typed references (`{"name": "region"}`)
+or native strings. Metrics accept typed column references with an aggregate,
+saved metric names, or native SIMPLE/SQL metric objects. SQL metrics still
+require a label and pass the shared SQL-expression validation. Unknown controls
+and executable JavaScript hooks are not accepted.
+
+```json
+{
+ "dataset_id": 7,
+ "config": {
+ "chart_type": "treemap_v2",
+ "groupby": ["region", "product"],
+ "metric": {"name": "revenue", "aggregate": "SUM"},
+ "row_limit": 100,
+ "sort_by_metric": true,
+ "show_labels": true,
+ "show_upper_labels": true,
+ "label_type": "key_value",
+ "number_format": ",.2f"
+ }
+}
+```
+
+Bounded queries sort by the metric descending when requested, then by hierarchy
+columns ascending to break ties, matching the frontend query contract.
+`update_chart` and `update_chart_preview` preserve omitted same-dataset Treemap
+controls, including hierarchy, metrics, formatting, filters, temporal state and
+`template_params`. Partial configurations are accepted only for updates; a new
+chart still requires hierarchy and metric roles. Explicit empty filters clear
+filters, and explicit null clears nullable controls. Rebinding to another
dataset
+requires complete roles and inherits only supported presentation controls.
+
+ASCII and table previews show hierarchy and values. Vega-Lite previews use
+bounded slice-and-dice rectangles with metric-proportional areas, hierarchy
+paths, categorical colors, labels and tooltips. This is not the native ECharts
+layout: use the Explore URL for native formatting and interactions. The preview
+supports up to 1,000 nonnegative rows, 20 hierarchy levels, and the built-in
+`supersetColors` and `lyftColors` schemes. Unsupported color/currency formats,
+negative values, zero totals and over-limit geometry return
+`UnsupportedTreemapPreview` rather than a substitute scatter or bar chart.
+`SMART_NUMBER` is approximated with SI formatting in this preview.
+
+Queries must return every hierarchy output and the metric's exact output label.
+Malformed/error envelopes and nonnumeric or nonfinite metric outputs return
+structured errors instead of apparently successful previews, charts or exports.
+
## Dataset discovery and routing
`list_datasets` searches table name, **description**, schema, and SQL using
diff --git a/superset/mcp_service/chart/chart_helpers.py
b/superset/mcp_service/chart/chart_helpers.py
index 3233dc12c28..f191697e72c 100644
--- a/superset/mcp_service/chart/chart_helpers.py
+++ b/superset/mcp_service/chart/chart_helpers.py
@@ -534,6 +534,14 @@ def resolve_metrics_and_groupby(
viz_type = (
form_data.get("viz_type", getattr(chart, "viz_type", "") if chart else
"") or ""
)
+ if viz_type == "treemap_v2":
+ # Treemap has exactly these roles; stale controls from another plugin
+ # must not override its singular metric or ordered hierarchy.
+ metric = form_data.get("metric")
+ hierarchy = form_data.get("groupby") or []
+ return ([metric] if metric else []), (
+ [hierarchy] if isinstance(hierarchy, str) else list(hierarchy)
+ )
singular_metric_no_groupby = (
"big_number",
"big_number_total",
@@ -694,6 +702,31 @@ def resolve_sort_metric(form_data: dict[str, Any]) -> Any
| None:
return raw or None
+def _apply_treemap_query_fields(
+ qd: dict[str, Any],
+ form_data: dict[str, Any],
+ columns: list[Any],
+ effective_row_limit: int | None,
+) -> None:
+ """Apply Treemap temporal binding and bounded hierarchy ordering."""
+ # extractExtras maps the selected SQL time column to QueryObject
granularity.
+ # A normalized dashboard override takes precedence, including a clear.
+ granularity = form_data.get("granularity",
form_data.get("granularity_sqla"))
+ if granularity is not None:
+ qd["granularity"] = granularity
+ # Match Treemap buildQuery/applyOrderBy, including hierarchy tie-breakers.
+ ordering = qd.pop("orderby", [])
+ ordering.extend(
+ (column, True) for column in columns if isinstance(column, str) and
column
+ )
+ try:
+ bounded = float(effective_row_limit or 0) != 0
+ except (ValueError, TypeError):
+ bounded = True
+ if bounded and ordering:
+ qd["orderby"] = ordering
+
+
def _build_single_query_dict(
form_data: dict[str, Any],
columns: list[Any],
@@ -728,6 +761,8 @@ def _build_single_query_dict(
order_desc if order_desc is not None else
form_data.get("order_desc", True)
)
qd["orderby"] = [(sort_metric, not descending)]
+ if form_data.get("viz_type") == "treemap_v2":
+ _apply_treemap_query_fields(qd, form_data, columns,
effective_row_limit)
apply_form_data_filters_to_query(qd, form_data)
return qd
diff --git a/superset/mcp_service/chart/chart_utils.py
b/superset/mcp_service/chart/chart_utils.py
index 1551693d958..adf06de1acc 100644
--- a/superset/mcp_service/chart/chart_utils.py
+++ b/superset/mcp_service/chart/chart_utils.py
@@ -55,6 +55,7 @@ from superset.mcp_service.chart.schemas import (
SortByConfig,
TableChartConfig,
TreemapChartConfig,
+ TreemapChartUpdateConfig,
WaterfallChartConfig,
XYChartConfig,
)
@@ -857,6 +858,115 @@ def _without_generated_gauge_time_filter(
]
+def resolve_treemap_update_config(
+ config: ChartConfig | TreemapChartUpdateConfig,
+ existing: dict[str, Any],
+ *,
+ dataset_rebind: bool = False,
+) -> ChartConfig:
+ """Fill omitted required roles only from an authorized same-dataset
Treemap."""
+ if not isinstance(config, TreemapChartUpdateConfig) or isinstance(
+ config, TreemapChartConfig
+ ):
+ return config
+ values = config.model_dump(exclude_unset=True)
+ if existing.get("viz_type") == "treemap_v2" and not dataset_rebind:
+ for field in ("groupby", "metric"):
+ if field not in config.model_fields_set and field in existing:
+ values[field] = existing[field]
+ resolved = TreemapChartConfig.model_validate(values)
+ resolved.__pydantic_fields_set__ = set(config.model_fields_set)
+ return resolved
+
+
+_TREEMAP_PRESENTATION_KEYS = frozenset(
+ {
+ "color_scheme",
+ "show_labels",
+ "show_upper_labels",
+ "label_type",
+ "label_position",
+ "number_format",
+ "date_format",
+ "currency_format",
+ }
+)
+
+
+def _merge_treemap_filters(
+ existing: dict[str, Any],
+ patch: dict[str, Any],
+ config: TreemapChartConfig,
+ dataset_rebind: bool,
+) -> None:
+ """Separate explicit filter/temporal changes from mapper-generated
defaults."""
+ fields = config.model_fields_set
+ if "temporal_column" in fields and config.temporal_column is None:
+ patch["adhoc_filters"] = _without_generated_gauge_time_filter(patch)
+ patch.pop(MCP_DASHBOARD_TIME_FILTER_SUBJECT, None)
+ if dataset_rebind:
+ return
+ if "temporal_column" not in fields:
+ # Discard the mapper's default binding before removing its provenance.
+ patch["adhoc_filters"] = _without_generated_gauge_time_filter(patch)
+ patch.pop(MCP_DASHBOARD_TIME_FILTER_SUBJECT, None)
+ if "filters" in fields and config.filters:
+ if subject := existing.get(MCP_DASHBOARD_TIME_FILTER_SUBJECT):
+ patch[MCP_DASHBOARD_TIME_FILTER_SUBJECT] = subject
+ preserve_previous_adhoc_filters(patch, existing)
+ if "filters" not in fields:
+ if "temporal_column" in fields:
+ inherited = (
+ [] if dataset_rebind else
_without_generated_gauge_time_filter(existing)
+ )
+ patch["adhoc_filters"] = [*inherited, *patch.get("adhoc_filters",
[])]
+ else:
+ patch.pop("adhoc_filters", None)
+
+
+def _merge_treemap_form_data(
+ existing: dict[str, Any],
+ generated: dict[str, Any],
+ config: TreemapChartConfig,
+ dataset_rebind: bool,
+) -> dict[str, Any]:
+ """Apply only explicit controls; never inherit query roles across
datasets."""
+ fields = config.model_fields_set
+ merged = {
+ key: value
+ for key, value in existing.items()
+ if not dataset_rebind or key in _TREEMAP_PRESENTATION_KEYS
+ }
+ patch = dict(generated)
+ for field in type(config).model_fields:
+ if field not in fields and (
+ not dataset_rebind or field in _TREEMAP_PRESENTATION_KEYS
+ ):
+ patch.pop(field, None)
+ _merge_treemap_filters(existing, patch, config, dataset_rebind)
+ merged.update(patch)
+ for field in fields:
+ if getattr(config, field) is None:
+ merged.pop(field, None)
+ if "filters" in fields and not config.filters:
+ merged.pop("adhoc_filters", None)
+ merged.pop(MCP_DASHBOARD_TIME_FILTER_SUBJECT, None)
+ if "temporal_column" in fields and config.temporal_column is None:
+ merged.pop(MCP_DASHBOARD_TIME_FILTER_SUBJECT, None)
+ # These roles belong to other plugins and must not affect Treemap queries.
+ for key in (
+ "metrics",
+ "columns",
+ "all_columns",
+ "x_axis",
+ "groupby_b",
+ "metrics_b",
+ "order_by_cols",
+ ):
+ merged.pop(key, None)
+ return merged
+
+
def merge_chart_form_data( # noqa: C901
existing_form_data: dict[str, Any],
new_form_data: dict[str, Any],
@@ -872,6 +982,10 @@ def merge_chart_form_data( # noqa: C901
"""
if existing_form_data.get("viz_type") != new_form_data.get("viz_type"):
return dict(new_form_data)
+ if isinstance(config, TreemapChartConfig):
+ return _merge_treemap_form_data(
+ existing_form_data, new_form_data, config, dataset_rebind
+ )
if isinstance(config, GanttChartConfig):
merged = dict(new_form_data)
if not dataset_rebind:
@@ -1505,6 +1619,16 @@ def map_treemap_config(config: TreemapChartConfig) ->
Dict[str, Any]:
"row_limit": config.row_limit,
"color_scheme": config.color_scheme or "supersetColors",
}
+ for key in _TREEMAP_PRESENTATION_KEYS | {
+ "time_range",
+ "granularity_sqla",
+ "template_params",
+ }:
+ value = getattr(config, key)
+ if value is not None:
+ form_data[key] = (
+ value.to_form_data() if isinstance(value, CurrencyFormat) else
value
+ )
_add_adhoc_filters(form_data, config.filters)
return form_data
diff --git a/superset/mcp_service/chart/compile.py
b/superset/mcp_service/chart/compile.py
index f4f3e80728e..4befcb8f5be 100644
--- a/superset/mcp_service/chart/compile.py
+++ b/superset/mcp_service/chart/compile.py
@@ -42,7 +42,7 @@ from sqlalchemy.exc import SQLAlchemyError
from superset.commands.exceptions import CommandException
from superset.errors import SupersetErrorType
from superset.mcp_service.chart.query_result import (
- normalize_gauge_query_result,
+ normalize_chart_query_result,
query_result_failure,
)
from superset.mcp_service.chart.schemas import ChartError
@@ -136,7 +136,7 @@ def _compile_chart(
query_context = build_query_context_from_form_data(
query_form_data,
row_limit=min(10, int(form_data.get("row_limit") or 10))
- if form_data.get("viz_type") == "gauge_chart"
+ if form_data.get("viz_type") in ("gauge_chart", "treemap_v2")
else 2,
force=False,
)
@@ -156,22 +156,31 @@ def _compile_chart(
tier="compile",
error_obj=_build_compile_error(error_str),
)
- result = normalize_gauge_query_result(result, form_data)
+ result = normalize_chart_query_result(result, form_data)
if isinstance(result, ChartError):
+ is_treemap = form_data.get("viz_type") == "treemap_v2"
+ error_code = (
+ "INVALID_TREEMAP_RESULT" if is_treemap else
"INVALID_GAUGE_RESULT"
+ )
+ message = (
+ "Treemap metric query returned invalid values"
+ if is_treemap
+ else "Gauge metric query returned invalid values"
+ )
return CompileResult(
success=False,
error=result.error,
- error_code="INVALID_GAUGE_RESULT",
+ error_code=error_code,
tier="compile",
error_obj=ChartGenerationError(
error_type=result.error_type,
- message="Gauge metric query returned invalid values",
+ message=message,
details=result.error,
suggestions=[
"Use a numeric-producing metric",
"Check the metric alias and SQL expression",
],
- error_code="INVALID_GAUGE_RESULT",
+ error_code=error_code,
),
)
for query in result.get("queries", []):
diff --git a/superset/mcp_service/chart/plugins/treemap.py
b/superset/mcp_service/chart/plugins/treemap.py
index 170c7d5d476..e907495b572 100644
--- a/superset/mcp_service/chart/plugins/treemap.py
+++ b/superset/mcp_service/chart/plugins/treemap.py
@@ -81,6 +81,8 @@ class TreemapChartPlugin(BaseChartPlugin):
if not isinstance(config, TreemapChartConfig):
return []
refs: list[ColumnRef] = [*config.groupby, config.metric]
+ if config.granularity_sqla:
+ refs.append(ColumnRef(name=config.granularity_sqla))
if config.filters:
for f in config.filters:
refs.append(ColumnRef(name=f.column))
@@ -122,8 +124,14 @@ class TreemapChartPlugin(BaseChartPlugin):
config_dict["metric"]["name"], dataset_context
)
)
+ if granularity := config_dict.get("granularity_sqla"):
+ config_dict["granularity_sqla"] = (
+ DatasetValidator.get_canonical_column_name(granularity,
dataset_context)
+ )
DatasetValidator.normalize_filters(config_dict, dataset_context)
- return TreemapChartConfig.model_validate(config_dict)
+ normalized = TreemapChartConfig.model_validate(config_dict)
+ normalized.__pydantic_fields_set__ = set(config.model_fields_set)
+ return normalized
def schema_error_hint(self) -> ChartGenerationError | None:
return ChartGenerationError(
diff --git a/superset/mcp_service/chart/preview_utils.py
b/superset/mcp_service/chart/preview_utils.py
index 1c8c79ec5e7..f5217f09b8d 100644
--- a/superset/mcp_service/chart/preview_utils.py
+++ b/superset/mcp_service/chart/preview_utils.py
@@ -30,6 +30,7 @@ from typing import Any, Dict, List
from superset.mcp_service.chart.query_result import (
metric_result_label,
+ normalize_chart_query_result,
normalize_gauge_query_result,
query_result_failure,
)
@@ -39,6 +40,7 @@ from superset.mcp_service.chart.schemas import (
TablePreview,
VegaLitePreview,
)
+from superset.mcp_service.chart.treemap_preview import treemap_ascii,
treemap_vega_lite
logger = logging.getLogger(__name__)
@@ -105,8 +107,7 @@ def generate_preview_from_form_data(
if query_failure := query_result_failure(result):
return query_failure
-
- result = normalize_gauge_query_result(result, form_data)
+ result = normalize_chart_query_result(result, form_data)
if isinstance(result, ChartError):
return result
if not result or not result.get("queries"):
@@ -144,7 +145,12 @@ def _generate_ascii_preview_from_data(
viz_type = form_data.get("viz_type", "table")
# Handle different chart types
- if viz_type == "gauge_chart":
+ if viz_type == "treemap_v2":
+ content_or_error = treemap_ascii(data, form_data)
+ if isinstance(content_or_error, ChartError):
+ return content_or_error
+ content = content_or_error
+ elif viz_type == "gauge_chart":
content_or_error = generate_gauge_ascii_preview(data, form_data)
if isinstance(content_or_error, ChartError):
return content_or_error
@@ -1256,6 +1262,8 @@ def _generate_vega_lite_preview_from_data( # noqa: C901
) -> VegaLitePreview | ChartError:
"""Generate Vega-Lite preview from raw data and form_data."""
viz_type = form_data.get("viz_type", "table")
+ if viz_type == "treemap_v2":
+ return treemap_vega_lite(data, form_data)
if viz_type == "gantt_chart":
return _generate_gantt_vega_lite_preview(data, form_data)
if viz_type == "gauge_chart":
diff --git a/superset/mcp_service/chart/query_result.py
b/superset/mcp_service/chart/query_result.py
index 16c01122d16..329dee7ec4d 100644
--- a/superset/mcp_service/chart/query_result.py
+++ b/superset/mcp_service/chart/query_result.py
@@ -19,7 +19,9 @@
import math
from collections.abc import Mapping
-from typing import Any
+from decimal import Decimal
+from numbers import Real
+from typing import Any, cast
from superset.mcp_service.chart.schemas import ChartError
@@ -229,3 +231,112 @@ def validate_gauge_query_result(
"""Check Gauge results using the same finite-dial contract as rendering."""
normalized = normalize_gauge_query_result(result, form_data)
return normalized if isinstance(normalized, ChartError) else None
+
+
+def column_result_label(column: Any) -> str | None:
+ """Resolve the query-result key using frontend ``getColumnLabel`` rules.
+
+ Explore's ``DndColumnSelect`` stores adhoc Custom SQL entries as objects,
+ so ``groupby`` on a saved chart may hold either a physical column name or
+ an adhoc column whose output key is its label (or raw SQL expression).
+ """
+ if isinstance(column, str):
+ return column or None
+ if not isinstance(column, Mapping):
+ return None
+ for key in ("label", "sqlExpression"):
+ value = column.get(key)
+ if isinstance(value, str) and value:
+ return value
+ return None
+
+
+def treemap_hierarchy_labels(form_data: Mapping[str, Any]) -> list[str] | None:
+ """Map a Treemap ``groupby`` onto the result keys its rows are keyed by.
+
+ Returns ``None`` when the hierarchy is absent, empty, contains an
+ unresolvable entry, or would collapse onto duplicate output labels.
+ """
+ hierarchy = form_data.get("groupby")
+ if not isinstance(hierarchy, list) or not hierarchy:
+ return None
+ labels = [column_result_label(column) for column in hierarchy]
+ if any(label is None for label in labels):
+ return None
+ resolved = cast(list[str], labels)
+ if len(set(resolved)) != len(resolved):
+ return None
+ return resolved
+
+
+def normalize_chart_query_result(result: Any, form_data: Mapping[str, Any]) ->
Any:
+ """Validate chart-specific result contracts before consumers use rows."""
+ if form_data.get("viz_type") != "treemap_v2":
+ return normalize_gauge_query_result(result, form_data)
+ if failure := query_result_failure(result):
+ return failure
+ label = metric_result_label(form_data.get("metric"))
+ hierarchy = treemap_hierarchy_labels(form_data)
+ if not label or hierarchy is None or label in hierarchy:
+ return ChartError(
+ error=(
+ "Treemap requires unique hierarchy columns and a distinct
metric label."
+ ),
+ error_type="InvalidTreemapFormData",
+ )
+ queries = result.get("queries") if isinstance(result, Mapping) else None
+ if not isinstance(queries, list) or len(queries) != 1:
+ return ChartError(
+ error="Treemap requires exactly one query result.",
+ error_type="InvalidTreemapResult",
+ )
+ query = queries[0]
+ rows = query.get("data") if isinstance(query, Mapping) else None
+ if not isinstance(rows, list):
+ return ChartError(
+ error="Treemap query data must be an array of rows.",
+ error_type="InvalidTreemapResult",
+ )
+ if failure := _validate_treemap_rows(rows, hierarchy, label):
+ return failure
+ return result
+
+
+def _validate_treemap_rows(
+ rows: list[Any], hierarchy: list[str], label: str
+) -> ChartError | None:
+ """Require complete hierarchy outputs and finite numeric metric values."""
+ for index, row in enumerate(rows):
+ if not isinstance(row, Mapping) or any(
+ column not in row for column in [*hierarchy, label]
+ ):
+ return ChartError(
+ error=f"Treemap row {index} is missing hierarchy or metric
outputs.",
+ error_type="InvalidTreemapResult",
+ )
+ value = row[label]
+ try:
+ valid = (
+ not isinstance(value, bool)
+ and isinstance(value, (Real, Decimal))
+ and (
+ value.is_finite()
+ if isinstance(value, Decimal)
+ else math.isfinite(value)
+ )
+ )
+ except (OverflowError, ValueError):
+ valid = False
+ if not valid:
+ return ChartError(
+ error=(
+ f"Treemap row {index} metric {label!r} must be finite and
numeric."
+ ),
+ error_type="InvalidTreemapMetric",
+ )
+ if any(isinstance(row[column], (dict, list)) for column in hierarchy):
+ return ChartError(
+ error=f"Treemap row {index} hierarchy values must be scalar.",
+ error_type="InvalidTreemapResult",
+ )
+ return None
diff --git a/superset/mcp_service/chart/schemas.py
b/superset/mcp_service/chart/schemas.py
index 077bb756c9d..5cfd4820898 100644
--- a/superset/mcp_service/chart/schemas.py
+++ b/superset/mcp_service/chart/schemas.py
@@ -1147,6 +1147,137 @@ class PieChartConfig(BaseChartConfig):
return self
+def _adapt_native_single_metric_form_data(data: Any) -> Any: # noqa: C901
+ """Adapt bounded native single-metric controls before strict validation."""
+ if not isinstance(data, dict):
+ return data
+ data = dict(data)
+
+ # ``gauge`` is the public MCP discriminator; ``gauge_chart`` remains
+ # the native frontend viz_type and is accepted only as an input alias.
+ if data.get("chart_type") == "gauge_chart" or (
+ "chart_type" not in data and data.get("viz_type") == "gauge_chart"
+ ):
+ data["chart_type"] = "gauge"
+ data.pop("viz_type", None)
+
+ # These identify the Explore/chart envelope, not visualization controls.
+ for key in (
+ "datasource",
+ "datasource_id",
+ "datasource_name",
+ "datasource_type",
+ "form_data_key",
+ "slice_id",
+ "slice_name",
+ "url",
+ ):
+ data.pop(key, None)
+ data.pop("_mcp_dashboard_time_filter_subject", None)
+
+ metric = data.get("metric")
+ if isinstance(metric, str):
+ data["metric"] = {"name": metric, "saved_metric": True}
+ elif isinstance(metric, dict) and metric.get("expressionType") in {
+ "SIMPLE",
+ "SQL",
+ }:
+ expression_type = metric.get("expressionType")
+ if expression_type == "SQL":
+ data["metric"] = {
+ "sql_expression": metric.get("sqlExpression"),
+ "label": metric.get("label"),
+ }
+ else:
+ if not isinstance(metric.get("aggregate"), str):
+ raise ValueError("Native SIMPLE metrics require an aggregate")
+ column = metric.get("column")
+ column_name = (
+ column.get("column_name") or column.get("columnName")
+ if isinstance(column, dict)
+ else None
+ )
+ data["metric"] = {
+ "name": column_name,
+ "aggregate": metric.get("aggregate"),
+ "label": metric.get("label"),
+ }
+
+ groupby = data.get("groupby")
+ if isinstance(groupby, str):
+ groupby = [groupby]
+ if isinstance(groupby, list):
+ data["groupby"] = [
+ {"name": value} if isinstance(value, str) else value for value in
groupby
+ ]
+
+ if isinstance(data.get("time_range"), str):
+ data["time_range"] = validate_time_range(data["time_range"]) or None
+
+ # Supported native SIMPLE filters are represented by FilterConfig.
+ # SQL adhoc filters remain intentionally unsupported on the typed MCP
+ # surface. TEMPORAL_RANGE is represented by time_range/granularity.
+ if "adhoc_filters" in data:
+ if "filters" in data:
+ raise ValueError("Use either filters or adhoc_filters, not both")
+ native_filters = data.pop("adhoc_filters")
+ if not isinstance(native_filters, list):
+ raise ValueError("adhoc_filters must be a list")
+ filters: list[dict[str, Any]] = []
+ for index, filter_ in enumerate(native_filters):
+ if not isinstance(filter_, dict):
+ raise ValueError(f"adhoc_filters[{index}] must be an object")
+ if filter_.get("expressionType") not in (None, "SIMPLE"):
+ raise ValueError(
+ f"adhoc_filters[{index}] must use expressionType='SIMPLE'"
+ )
+ if str(filter_.get("clause", "WHERE")).upper() != "WHERE":
+ raise ValueError(f"adhoc_filters[{index}] must use
clause='WHERE'")
+ operator = filter_.get("operator") or filter_.get("op")
+ subject = filter_.get("subject") or filter_.get("col")
+ comparator = filter_.get("comparator", filter_.get("val"))
+ if operator == "TEMPORAL_RANGE":
+ if not isinstance(subject, str) or not subject:
+ raise ValueError(f"adhoc_filters[{index}] has no temporal
subject")
+ if not isinstance(comparator, str):
+ raise ValueError(
+ f"adhoc_filters[{index}] requires a temporal
comparator"
+ )
+ comparator = validate_time_range(comparator) or NO_TIME_RANGE
+ if comparator == NO_TIME_RANGE:
+ if data.get("temporal_column") not in (None, subject):
+ raise ValueError(
+ f"adhoc_filters[{index}] conflicts with another "
+ "dashboard temporal binding"
+ )
+ data["temporal_column"] = subject
+ continue
+ if (
+ data.get("granularity_sqla") not in (None, subject)
+ and data.get("time_range") != NO_TIME_RANGE
+ ) or data.get("time_range") not in (
+ None,
+ NO_TIME_RANGE,
+ comparator,
+ ):
+ raise ValueError(
+ f"adhoc_filters[{index}] conflicts with another
temporal "
+ "range; multiple distinct temporal ranges are not
supported"
+ )
+ data["granularity_sqla"] = subject
+ data["time_range"] = comparator
+ continue
+ if operator == "==":
+ operator = "="
+ if operator not in
get_args(FilterConfig.model_fields["op"].annotation):
+ raise ValueError(
+ f"adhoc_filters[{index}] uses unsupported operator
{operator!r}"
+ )
+ filters.append({"column": subject, "op": operator, "value":
comparator})
+ data["filters"] = filters
+ return data
+
+
class GaugeChartConfig(BaseChartConfig):
"""Config for gauge charts (viz_type ``gauge_chart``).
@@ -1245,136 +1376,9 @@ class GaugeChartConfig(BaseChartConfig):
@model_validator(mode="before")
@classmethod
- def adapt_native_form_data(cls, data: Any) -> Any: # noqa: C901
- """Accept the Gauge plugin's native form_data without weakening
typing."""
- if not isinstance(data, dict):
- return data
- data = dict(data)
-
- # ``gauge`` is the public MCP discriminator; ``gauge_chart`` remains
- # the native frontend viz_type and is accepted only as an input alias.
- if data.get("chart_type") == "gauge_chart" or (
- "chart_type" not in data and data.get("viz_type") == "gauge_chart"
- ):
- data["chart_type"] = "gauge"
- data.pop("viz_type", None)
-
- # These identify the Explore/chart envelope, not Gauge controls.
- for key in (
- "datasource",
- "datasource_id",
- "datasource_name",
- "datasource_type",
- "form_data_key",
- "slice_id",
- "slice_name",
- "url",
- ):
- data.pop(key, None)
- data.pop("_mcp_dashboard_time_filter_subject", None)
-
- metric = data.get("metric")
- if isinstance(metric, str):
- data["metric"] = {"name": metric, "saved_metric": True}
- elif isinstance(metric, dict) and metric.get("expressionType") in {
- "SIMPLE",
- "SQL",
- }:
- expression_type = metric.get("expressionType")
- if expression_type == "SQL":
- data["metric"] = {
- "sql_expression": metric.get("sqlExpression"),
- "label": metric.get("label"),
- }
- else:
- column = metric.get("column")
- column_name = (
- column.get("column_name") or column.get("columnName")
- if isinstance(column, dict)
- else None
- )
- data["metric"] = {
- "name": column_name,
- "aggregate": metric.get("aggregate"),
- "label": metric.get("label"),
- }
-
- groupby = data.get("groupby")
- if isinstance(groupby, str):
- groupby = [groupby]
- if isinstance(groupby, list):
- data["groupby"] = [
- {"name": value} if isinstance(value, str) else value
- for value in groupby
- ]
-
- if isinstance(data.get("time_range"), str):
- data["time_range"] = validate_time_range(data["time_range"]) or
None
-
- # Supported native SIMPLE filters are represented by FilterConfig.
- # SQL adhoc filters remain intentionally unsupported on the typed MCP
- # surface. TEMPORAL_RANGE is represented by time_range/granularity.
- if "adhoc_filters" in data:
- if "filters" in data:
- raise ValueError("Use either filters or adhoc_filters, not
both")
- native_filters = data.pop("adhoc_filters")
- if not isinstance(native_filters, list):
- raise ValueError("adhoc_filters must be a list")
- filters: list[dict[str, Any]] = []
- for index, filter_ in enumerate(native_filters):
- if not isinstance(filter_, dict):
- raise ValueError(f"adhoc_filters[{index}] must be an
object")
- if filter_.get("expressionType") not in (None, "SIMPLE"):
- raise ValueError(
- f"adhoc_filters[{index}] must use
expressionType='SIMPLE'"
- )
- if str(filter_.get("clause", "WHERE")).upper() != "WHERE":
- raise ValueError(f"adhoc_filters[{index}] must use
clause='WHERE'")
- operator = filter_.get("operator") or filter_.get("op")
- subject = filter_.get("subject") or filter_.get("col")
- comparator = filter_.get("comparator", filter_.get("val"))
- if operator == "TEMPORAL_RANGE":
- if not isinstance(subject, str) or not subject:
- raise ValueError(
- f"adhoc_filters[{index}] has no temporal subject"
- )
- if not isinstance(comparator, str):
- raise ValueError(
- f"adhoc_filters[{index}] requires a temporal
comparator"
- )
- comparator = validate_time_range(comparator) or
NO_TIME_RANGE
- if comparator == NO_TIME_RANGE:
- if data.get("temporal_column") not in (None, subject):
- raise ValueError(
- f"adhoc_filters[{index}] conflicts with
another "
- "dashboard temporal binding"
- )
- data["temporal_column"] = subject
- continue
- if (
- data.get("granularity_sqla") not in (None, subject)
- and data.get("time_range") != NO_TIME_RANGE
- ) or data.get("time_range") not in (
- None,
- NO_TIME_RANGE,
- comparator,
- ):
- raise ValueError(
- f"adhoc_filters[{index}] conflicts with another
temporal "
- "range; multiple distinct temporal ranges are not
supported"
- )
- data["granularity_sqla"] = subject
- data["time_range"] = comparator
- continue
- if operator == "==":
- operator = "="
- if operator not in
get_args(FilterConfig.model_fields["op"].annotation):
- raise ValueError(
- f"adhoc_filters[{index}] uses unsupported operator
{operator!r}"
- )
- filters.append({"column": subject, "op": operator, "value":
comparator})
- data["filters"] = filters
- return data
+ def adapt_native_form_data(cls, data: Any) -> Any:
+ """Accept bounded native form data without weakening typed
validation."""
+ return _adapt_native_single_metric_form_data(data)
@field_validator("time_range")
@classmethod
@@ -1458,7 +1462,7 @@ class GaugeChartConfig(BaseChartConfig):
return self
-class TreemapChartConfig(BaseChartConfig):
+class TreemapChartUpdateConfig(BaseChartConfig):
"""Config for treemap charts (viz_type ``treemap_v2``).
Matches the frontend Treemap buildQuery contract: one ``metric`` sizing
@@ -1469,15 +1473,25 @@ class TreemapChartConfig(BaseChartConfig):
model_config = ConfigDict(extra="ignore", populate_by_name=True)
- chart_type: Literal["treemap_v2"] = "treemap_v2"
- groupby: List[ColumnRef] = Field(
+ # Required even though every other field is optional: this partial model
+ # sits beside the discriminated ``ChartConfig`` union in the update
+ # requests. With a defaulted discriminator any config that failed the
+ # discriminated branch — including one that simply omitted
+ # ``chart_type`` — would fall through to this model and silently rewrite
+ # an existing chart of another type into a Treemap.
+ chart_type: Literal["treemap_v2"] = Field(
...,
+ description="Chart type discriminator; must be 'treemap_v2'",
+ )
+ groupby: List[ColumnRef] | None = Field(
+ None,
min_length=1,
+ max_length=20,
description="Ordered category columns forming the treemap hierarchy "
"(first = outermost level; order defines nesting)",
)
- metric: ColumnRef = Field(
- ...,
+ metric: ColumnRef | None = Field(
+ None,
description="Value metric sizing the tiles (use aggregate e.g. SUM, "
"COUNT for ad-hoc, or set saved_metric=True for a saved dataset
metric)",
)
@@ -1500,9 +1514,58 @@ class TreemapChartConfig(BaseChartConfig):
max_length=100,
)
+ show_labels: bool = True
+ show_upper_labels: bool = True
+ label_type: Literal["key", "Key", "value", "key_value"] = "key_value"
+ label_position: Literal[
+ "top",
+ "left",
+ "right",
+ "bottom",
+ "inside",
+ "insideLeft",
+ "insideRight",
+ "insideTop",
+ "insideBottom",
+ "insideTopLeft",
+ "insideBottomLeft",
+ "insideTopRight",
+ "insideBottomRight",
+ ] = "insideTopLeft"
+ number_format: str = Field("SMART_NUMBER", max_length=100)
+ date_format: str = Field("smart_date", max_length=100)
+ currency_format: CurrencyFormat | None = None
+ time_range: str | None = Field(None, max_length=1000)
+ granularity_sqla: str | None = Field(None, min_length=1, max_length=255)
+ template_params: str | None = Field(None, max_length=10000)
+
+ @model_validator(mode="before")
+ @classmethod
+ def adapt_native_form_data(cls, data: Any) -> Any:
+ """Accept native hierarchy and saved, SIMPLE, and SQL metric inputs."""
+ return _adapt_native_single_metric_form_data(data)
+
+ @field_validator("time_range")
+ @classmethod
+ def validate_treemap_time_range(cls, value: str | None) -> str | None:
+ """Validate time ranges using the shared parser."""
+ return validate_time_range(value)
+
@model_validator(mode="after")
- def reject_metric_style_groupby(self) -> "TreemapChartConfig":
+ def reject_metric_style_groupby(self) -> "TreemapChartUpdateConfig":
"""groupby entries are hierarchy dimensions, not metrics."""
+ names = [col.name for col in self.groupby or []]
+ if len(set(names)) != len(names):
+ raise ValueError("groupby must contain unique hierarchy columns")
+ metric_label = (self.metric.label or self.metric.name) if self.metric
else None
+ if (
+ self.metric
+ and metric_label in names
+ and (self.metric.label or self.metric.saved_metric)
+ ):
+ raise ValueError(
+ "metric output label must not collide with hierarchy columns"
+ )
for i, col in enumerate(self.groupby or []):
_reject_sql_expression_on_dimension(col, f"groupby[{i}]")
if col.is_metric:
@@ -1514,6 +1577,22 @@ class TreemapChartConfig(BaseChartConfig):
return self
+class TreemapChartConfig(TreemapChartUpdateConfig):
+ """Complete Treemap configuration required for generation and
compilation."""
+
+ # Restored to a default here: this model is only ever reached through the
+ # discriminated union, which already requires the key in client payloads,
+ # and internal call sites construct it directly.
+ chart_type: Literal["treemap_v2"] = "treemap_v2"
+ groupby: List[ColumnRef] = Field(
+ ...,
+ min_length=1,
+ max_length=20,
+ description="Ordered hierarchy columns, outermost first",
+ )
+ metric: ColumnRef = Field(..., description="Metric sizing the hierarchy
tiles")
+
+
class BubbleChartConfig(BaseChartConfig):
"""Config for bubble charts (viz_type ``bubble_v2``).
@@ -3863,7 +3942,7 @@ class UpdateChartRequest(ChartRequestNormalizerMixin,
QueryCacheControl):
description="Chart ID or UUID",
validation_alias=AliasChoices("identifier", "id", "chart_id"),
)
- config: ChartConfig | None = Field(
+ config: ChartConfig | TreemapChartUpdateConfig | None = Field(
None,
description="Chart configuration. Optional; omit to only update
chart_name.",
)
@@ -3938,7 +4017,9 @@ class
UpdateChartPreviewRequest(ChartRequestNormalizerMixin, FormDataCacheContro
),
)
dataset_id: int | str = Field(..., description="Dataset ID or UUID")
- config: ChartConfig = Field(..., description="Chart configuration")
+ config: ChartConfig | TreemapChartUpdateConfig = Field(
+ ..., description="Chart configuration"
+ )
generate_preview: bool = True
preview_formats: List[Literal["url", "ascii", "vega_lite", "table"]] =
Field(
default_factory=lambda: ["url"],
diff --git a/superset/mcp_service/chart/tool/get_chart_data.py
b/superset/mcp_service/chart/tool/get_chart_data.py
index 5369a436c3c..5ae0237ea34 100644
--- a/superset/mcp_service/chart/tool/get_chart_data.py
+++ b/superset/mcp_service/chart/tool/get_chart_data.py
@@ -45,6 +45,7 @@ from superset.mcp_service.chart.chart_helpers import (
)
from superset.mcp_service.chart.chart_utils import validate_chart_dataset
from superset.mcp_service.chart.query_result import (
+ normalize_chart_query_result,
query_result_failure,
)
from superset.mcp_service.chart.schemas import (
@@ -791,6 +792,10 @@ async def execute_chart_data( # noqa: C901
command.validate()
result = command.run()
+ if form_data.get("viz_type") == "treemap_v2":
+ result = normalize_chart_query_result(result, form_data)
+ if isinstance(result, ChartError):
+ return result
if query_failure := query_result_failure(result):
return query_failure
@@ -1164,6 +1169,10 @@ async def _query_from_form_data( # noqa: C901
command.validate()
result = command.run()
+ if form_data.get("viz_type") == "treemap_v2":
+ result = normalize_chart_query_result(result, form_data)
+ if isinstance(result, ChartError):
+ return result
if query_failure := query_result_failure(result):
return query_failure
diff --git a/superset/mcp_service/chart/tool/get_chart_preview.py
b/superset/mcp_service/chart/tool/get_chart_preview.py
index 31c9b577f9b..0a8e2cd13d1 100644
--- a/superset/mcp_service/chart/tool/get_chart_preview.py
+++ b/superset/mcp_service/chart/tool/get_chart_preview.py
@@ -47,7 +47,7 @@ from superset.mcp_service.chart.preview_utils import (
generate_gauge_vega_lite_preview,
)
from superset.mcp_service.chart.query_result import (
- normalize_gauge_query_result,
+ normalize_chart_query_result,
query_result_failure,
)
from superset.mcp_service.chart.schemas import (
@@ -62,6 +62,7 @@ from superset.mcp_service.chart.schemas import (
URLPreview,
VegaLitePreview,
)
+from superset.mcp_service.chart.treemap_preview import treemap_ascii,
treemap_vega_lite
from superset.mcp_service.utils.oauth2_utils import (
build_oauth2_redirect_message,
OAUTH2_CONFIG_ERROR_MESSAGE,
@@ -185,7 +186,14 @@ def _no_query_fields_error(chart: ChartLike) -> ChartError:
def _preview_row_limit(form_data: dict[str, Any], fallback: int) -> int:
- """Keep Gauge preview cardinality aligned with its frontend row limit."""
+ """Keep single-metric previews aligned with their frontend row limits."""
+ if form_data.get("viz_type") == "treemap_v2":
+ value = form_data.get("row_limit", 100)
+ try:
+ limit = int(value)
+ except (TypeError, ValueError, OverflowError):
+ limit = 100
+ return limit if 1 <= limit <= 10000 else 100
if form_data.get("viz_type") != "gauge_chart":
return fallback
value = form_data.get("row_limit", 10)
@@ -284,7 +292,7 @@ class ASCIIPreviewStrategy(PreviewFormatStrategy):
if query_failure := query_result_failure(result):
return query_failure
- result = normalize_gauge_query_result(result, form_data)
+ result = normalize_chart_query_result(result, form_data)
if isinstance(result, ChartError):
return result
@@ -292,12 +300,14 @@ class ASCIIPreviewStrategy(PreviewFormatStrategy):
if result and "queries" in result and len(result["queries"]) > 0:
data = result["queries"][0].get("data") or []
- if form_data.get("viz_type") == "gauge_chart":
+ if form_data.get("viz_type") == "treemap_v2":
+ ascii_chart = treemap_ascii(
+ data, form_data, self.request.ascii_width or 80
+ )
+ elif form_data.get("viz_type") == "gauge_chart":
ascii_chart = generate_gauge_ascii_preview(
data, form_data, self.request.ascii_width or 80
)
- if isinstance(ascii_chart, ChartError):
- return ascii_chart
else:
ascii_chart = generate_ascii_chart(
data,
@@ -306,6 +316,8 @@ class ASCIIPreviewStrategy(PreviewFormatStrategy):
self.request.ascii_height or 20,
)
+ if isinstance(ascii_chart, ChartError):
+ return ascii_chart
return ASCIIPreview(
ascii_content=ascii_chart,
width=self.request.ascii_width or 80,
@@ -364,7 +376,7 @@ class TablePreviewStrategy(PreviewFormatStrategy):
if query_failure := query_result_failure(result):
return query_failure
- result = normalize_gauge_query_result(result, form_data)
+ result = normalize_chart_query_result(result, form_data)
if isinstance(result, ChartError):
return result
@@ -483,7 +495,7 @@ class VegaLitePreviewStrategy(PreviewFormatStrategy):
if query_failure := query_result_failure(result):
return query_failure
- result = normalize_gauge_query_result(result, form_data)
+ result = normalize_chart_query_result(result, form_data)
if isinstance(result, ChartError):
return result
@@ -492,6 +504,8 @@ class VegaLitePreviewStrategy(PreviewFormatStrategy):
if result and "queries" in result and len(result["queries"]) > 0:
chart_data = result["queries"][0].get("data", [])
+ if form_data.get("viz_type") == "treemap_v2":
+ return treemap_vega_lite(chart_data, form_data)
if form_data.get("viz_type") == "gauge_chart":
return generate_gauge_vega_lite_preview(chart_data, form_data)
viz_type = getattr(self.chart, "viz_type", None) or form_data.get(
diff --git a/superset/mcp_service/chart/tool/get_chart_type_schema.py
b/superset/mcp_service/chart/tool/get_chart_type_schema.py
index 617579c7dc1..3e09d888e1b 100644
--- a/superset/mcp_service/chart/tool/get_chart_type_schema.py
+++ b/superset/mcp_service/chart/tool/get_chart_type_schema.py
@@ -22,6 +22,8 @@ MCP tool: get_chart_type_schema
from __future__ import annotations
import logging
+from copy import deepcopy
+from functools import lru_cache
from typing import Any, Dict
from pydantic import TypeAdapter
@@ -256,10 +258,27 @@ _CHART_EXAMPLES: Dict[str, list[Dict[str, Any]]] = {
"groupby": [{"name": "region"}, {"name": "product"}],
"metric": {"name": "revenue", "aggregate": "SUM"},
},
+ {
+ "chart_type": "treemap_v2",
+ "groupby": ["region", "product"],
+ "metric": "total_revenue",
+ "show_labels": True,
+ "show_upper_labels": True,
+ "label_type": "key_value",
+ "number_format": ",.2f",
+ "sort_by_metric": False,
+ "row_limit": 100,
+ },
],
}
+@lru_cache(maxsize=len(_CHART_TYPE_ADAPTERS))
+def _compiled_chart_schema(chart_type: str) -> dict[str, Any]:
+ """Compile static adapter schemas once; callers must copy before exposing
them."""
+ return _CHART_TYPE_ADAPTERS[chart_type].json_schema()
+
+
def _get_chart_type_schema_impl(
chart_type: str,
include_examples: bool = True,
@@ -314,7 +333,7 @@ def _get_chart_type_schema_impl(
"valid_chart_types": enabled_types,
}
- schema = adapter.json_schema()
+ schema = deepcopy(_compiled_chart_schema(chart_type))
result: ChartTypeSchemaResponse = {
"chart_type": chart_type,
"schema": schema,
diff --git a/superset/mcp_service/chart/tool/update_chart.py
b/superset/mcp_service/chart/tool/update_chart.py
index 43d08f99a0d..7c190c7f5b2 100644
--- a/superset/mcp_service/chart/tool/update_chart.py
+++ b/superset/mcp_service/chart/tool/update_chart.py
@@ -43,6 +43,7 @@ from superset.mcp_service.chart.chart_utils import (
merge_chart_form_data,
merge_interactive_pivot_ui_config,
merge_table_column_config,
+ resolve_treemap_update_config,
validate_gantt_form_data,
)
from superset.mcp_service.chart.compile import validate_and_compile
@@ -55,6 +56,7 @@ from superset.mcp_service.chart.schemas import (
GenerateChartResponse,
PerformanceMetadata,
TableChartConfig,
+ TreemapChartConfig,
UpdateChartRequest,
)
from superset.mcp_service.chart.validation.dataset_validator import (
@@ -375,7 +377,7 @@ def _build_replacement_form_data(
new_form_data.pop("_mcp_warnings", None)
dataset_rebind = replacement_dataset_id is not None
if replacement_dataset_id is not None and not isinstance(
- parsed_config, (GanttChartConfig, GaugeChartConfig)
+ parsed_config, (GanttChartConfig, GaugeChartConfig, TreemapChartConfig)
):
# Drop only the inherited state the replacement dataset cannot
# resolve, then merge as a same-dataset update. Gantt and Gauge keep
the
@@ -817,12 +819,16 @@ async def update_chart( # noqa: C901
request.dataset_id is not None
and request.dataset_id != getattr(chart, "datasource_id", None)
and request.config is None
- and getattr(chart, "viz_type", None) == "gauge_chart"
+ and getattr(chart, "viz_type", None) in ("gauge_chart",
"treemap_v2")
):
return _validation_error_response(
- message="Gauge dataset rebind requires a complete Gauge
config.",
+ message=(
+ "Gauge dataset rebind requires a complete Gauge config."
+ if chart.viz_type == "gauge_chart"
+ else "Treemap dataset rebind requires a complete Treemap
config."
+ ),
details=(
- "Provide chart_type='gauge' and a metric valid on the
target "
+ "Provide the chart type and complete roles valid on the
target "
"dataset. This prevents stale metric, groupby, and filter
roles "
"from the previous dataset from being retained."
),
@@ -859,7 +865,21 @@ async def update_chart( # noqa: C901
new_form_data: dict[str, Any] | None = None
# config is already a typed ChartConfig | None (validated by Pydantic)
- parsed_config = request.config
+ try:
+ parsed_config = (
+ resolve_treemap_update_config(
+ request.config,
+ _get_existing_form_data(chart),
+ dataset_rebind=request.dataset_id is not None
+ and request.dataset_id != chart.datasource_id,
+ )
+ if request.config is not None
+ else None
+ )
+ except ValueError as ex:
+ return _validation_error_response(
+ "Invalid Treemap update configuration", str(ex)
+ )
validation_config = parsed_config
if request.add_columns is not None:
validation_config = TableChartConfig(columns=request.add_columns)
diff --git a/superset/mcp_service/chart/tool/update_chart_preview.py
b/superset/mcp_service/chart/tool/update_chart_preview.py
index 3b50eb95e4b..ff278144717 100644
--- a/superset/mcp_service/chart/tool/update_chart_preview.py
+++ b/superset/mcp_service/chart/tool/update_chart_preview.py
@@ -41,6 +41,7 @@ from superset.mcp_service.chart.chart_utils import (
merge_chart_form_data,
merge_interactive_pivot_ui_config,
merge_table_column_config,
+ resolve_treemap_update_config,
validate_gantt_form_data,
)
from superset.mcp_service.chart.compile import validate_and_compile
@@ -182,6 +183,39 @@ def update_chart_preview( # noqa: C901
NORMALIZATION_EXCEPTIONS,
)
+ warnings: list[str] = []
+ previous_form_data: dict[str, Any] | None = None
+
+ if request.form_data_key:
+ previous_form_data =
_get_previous_form_data(request.form_data_key)
+ if previous_form_data is None:
+ warnings.append(INVALID_FORM_DATA_KEY_WARNING)
+ previous_datasource = str(
+ (previous_form_data or {}).get("datasource")
+ or (previous_form_data or {}).get("datasource_id")
+ or ""
+ ).split("__", 1)[0]
+ dataset_rebind = previous_datasource != str(dataset.id) and (
+ bool(previous_datasource) or config.chart_type == "treemap_v2"
+ )
+ try:
+ config = resolve_treemap_update_config(
+ config,
+ previous_form_data or {},
+ dataset_rebind=dataset_rebind,
+ )
+ except ValueError as ex:
+ return {
+ "chart": None,
+ "error": {
+ "error_type": "ValidationError",
+ "message": "Invalid Treemap update configuration",
+ "details": str(ex),
+ },
+ "success": False,
+ "schema_version": "2.0",
+ "api_version": "v1",
+ }
try:
config = DatasetValidator.normalize_column_names(
config,
@@ -200,25 +234,10 @@ def update_chart_preview( # noqa: C901
config, dataset_id=request.dataset_id
)
new_form_data.pop("_mcp_warnings", None)
- warnings: list[str] = []
- previous_form_data: dict[str, Any] | None = None
-
- if request.form_data_key:
- previous_form_data =
_get_previous_form_data(request.form_data_key)
- if previous_form_data is None:
- warnings.append(INVALID_FORM_DATA_KEY_WARNING)
if previous_form_data:
merge_table_column_config(previous_form_data, new_form_data)
merge_interactive_pivot_ui_config(previous_form_data,
new_form_data)
- previous_datasource = str(
- previous_form_data.get("datasource")
- or previous_form_data.get("datasource_id")
- or ""
- ).split("__", 1)[0]
- dataset_rebind = bool(
- previous_datasource
- ) and previous_datasource != str(dataset.id)
new_form_data = merge_chart_form_data(
previous_form_data,
new_form_data,
@@ -273,7 +292,7 @@ def update_chart_preview( # noqa: C901
config,
new_form_data,
dataset,
- run_compile_check=config.chart_type == "gauge",
+ run_compile_check=config.chart_type in ("gauge", "treemap_v2"),
)
if not compile_result.success:
logger.warning(
diff --git a/superset/mcp_service/chart/treemap_preview.py
b/superset/mcp_service/chart/treemap_preview.py
new file mode 100644
index 00000000000..ebfaf9f0491
--- /dev/null
+++ b/superset/mcp_service/chart/treemap_preview.py
@@ -0,0 +1,297 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""Bounded slice-and-dice Treemap previews using explicit rectangle
geometry."""
+
+from __future__ import annotations
+
+import math
+from typing import Any
+
+from superset.mcp_service.chart.query_result import (
+ metric_result_label,
+ normalize_chart_query_result,
+ treemap_hierarchy_labels,
+)
+from superset.mcp_service.chart.schemas import ChartError, VegaLitePreview
+
+# Match the built-in frontend categorical schemes. Unknown schemes are not
guessed.
+_PALETTES = {
+ "supersetColors": [
+ "#1FA8C9",
+ "#454E7C",
+ "#5AC189",
+ "#FF7F44",
+ "#666666",
+ "#E04355",
+ "#FCC700",
+ "#A868B7",
+ "#3CCCCB",
+ "#A38F79",
+ "#8FD3E4",
+ "#A1A6BD",
+ "#ACE1C4",
+ "#FEC0A1",
+ "#B2B2B2",
+ "#EFA1AA",
+ "#FDE380",
+ "#D3B3DA",
+ "#9EE5E5",
+ "#D1C6BC",
+ ],
+ "lyftColors": [
+ "#EA0B8C",
+ "#6C838E",
+ "#29ABE2",
+ "#33D9C1",
+ "#9DACB9",
+ "#7560AA",
+ "#2D5584",
+ "#831C4A",
+ "#333D47",
+ "#AC2077",
+ ],
+}
+_MAX_ROWS = 1000
+
+
+def treemap_ascii(
+ data: list[dict[str, Any]], form_data: dict[str, Any], width: int = 80
+) -> str | ChartError:
+ """Show the ordered hierarchy and values rather than unrelated bar
geometry."""
+ checked = normalize_chart_query_result({"queries": [{"data": data}]},
form_data)
+ if isinstance(checked, ChartError):
+ return checked
+ label = metric_result_label(form_data["metric"])
+ assert label is not None
+ hierarchy = treemap_hierarchy_labels(form_data)
+ assert hierarchy is not None
+ lines = [f"Treemap hierarchy | {label}"]
+ for row in data[:_MAX_ROWS]:
+ path = " > ".join(str(row[column]) for column in hierarchy)
+ lines.append(f"{path} | {row[label]}")
+ if len(data) > _MAX_ROWS:
+ lines.append(f"Showing {_MAX_ROWS} of {len(data)} rows")
+ return "\n".join(line[:width] for line in lines)
+
+
+def treemap_vega_lite( # noqa: C901
+ data: list[dict[str, Any]], form_data: dict[str, Any]
+) -> VegaLitePreview | ChartError:
+ """Render nested metric-proportional rectangles; never substitute
scatter/bar marks.
+
+ Vega-Lite has no hierarchy transform. A bounded slice-and-dice layout is
+ computed here and sent as explicit coordinates, without executable hooks.
+ Native ECharts layout and dashboard interactions remain available in
Explore.
+ """
+ checked = normalize_chart_query_result({"queries": [{"data": data}]},
form_data)
+ if isinstance(checked, ChartError):
+ return checked
+ label = metric_result_label(form_data["metric"])
+ assert label is not None
+ if not data:
+ return ChartError(error="No Treemap data available.",
error_type="NoDataError")
+ scheme = form_data.get("color_scheme") or "supersetColors"
+ if (
+ scheme not in _PALETTES
+ or form_data.get("currency_format")
+ or form_data.get("label_position", "insideTopLeft") != "insideTopLeft"
+ ):
+ return ChartError(
+ error=(
+ "This Treemap color/currency/label format requires the native "
+ "Explore renderer; use url or table preview."
+ ),
+ error_type="UnsupportedTreemapPreview",
+ )
+ if (
+ len(data) > _MAX_ROWS
+ or any(row[label] < 0 for row in data)
+ or not any(row[label] > 0 for row in data)
+ ):
+ return ChartError(
+ error=(
+ "Treemap geometry requires at most 1000 nonnegative rows and "
+ "a positive total; use table or url preview."
+ ),
+ error_type="UnsupportedTreemapPreview",
+ )
+ hierarchy = treemap_hierarchy_labels(form_data)
+ assert hierarchy is not None
+ if len(hierarchy) > 20 or not math.isfinite(sum(float(row[label]) for row
in data)):
+ return ChartError(
+ error="Treemap preview requires at most 20 levels and a finite
total.",
+ error_type="UnsupportedTreemapPreview",
+ )
+ nodes: list[dict[str, Any]] = []
+
+ def layout(
+ rows: list[dict[str, Any]],
+ depth: int,
+ path: list[str],
+ x: float,
+ y: float,
+ width: float,
+ height: float,
+ ) -> None:
+ """Recursively partition the parent rectangle in hierarchy order."""
+ groups: dict[tuple[str, str], list[dict[str, Any]]] = {}
+ for row in rows:
+ value = row[hierarchy[depth]]
+ groups.setdefault((type(value).__name__, str(value)),
[]).append(row)
+ total = sum(float(row[label]) for row in rows)
+ offset = 0.0
+ for (_, name), children in groups.items():
+ value = sum(float(row[label]) for row in children)
+ ratio = value / total if total else 0
+ nx, ny = (
+ (x + offset * width, y) if depth % 2 == 0 else (x, y + offset
* height)
+ )
+ nw, nh = (
+ (width * ratio, height) if depth % 2 == 0 else (width, height
* ratio)
+ )
+ node_path = [*path, name]
+ leaf = depth == len(hierarchy) - 1
+ nodes.append(
+ {
+ "x0": nx,
+ "y0": ny,
+ "x1": nx + nw,
+ "y1": ny + nh,
+ "label_y": ny + 14 * depth,
+ "name": name,
+ "path": " > ".join(node_path),
+ "value": value,
+ "leaf": leaf,
+ "depth": depth,
+ "percent": ratio,
+ }
+ )
+ if not leaf:
+ layout(children, depth + 1, node_path, nx, ny, nw, nh)
+ offset += ratio
+
+ layout(data, 0, [], 0, 0, 600, 400)
+ coordinates: dict[str, dict[str, Any]] = {
+ "x": {
+ "field": "x0",
+ "type": "quantitative",
+ "scale": {"domain": [0, 600]},
+ "axis": None,
+ },
+ "x2": {"field": "x1"},
+ "y": {
+ "field": "y0",
+ "type": "quantitative",
+ "scale": {"domain": [400, 0]},
+ "axis": None,
+ },
+ "y2": {"field": "y1"},
+ }
+ layers: list[dict[str, Any]] = [
+ {
+ "mark": {"type": "rect", "stroke": "white", "strokeWidth": 1},
+ "encoding": {
+ **coordinates,
+ "color": {
+ "field": "name",
+ "type": "nominal",
+ "scale": {
+ "range": _PALETTES[scheme],
+ # Native traversal assigns the metric root first.
+ "domain": list(
+ dict.fromkeys([label, *(node["name"] for node in
nodes)])
+ ),
+ },
+ "legend": None,
+ },
+ "tooltip": [
+ {"field": "path", "title": "Hierarchy"},
+ {"field": "value", "type": "quantitative", "title": label},
+ {
+ "field": "percent",
+ "type": "quantitative",
+ "format": ".2%",
+ "title": "Share of parent",
+ },
+ ],
+ },
+ }
+ ]
+ if form_data.get("show_labels", True) or
form_data.get("show_upper_labels", True):
+ label_type = form_data.get("label_type", "key_value")
+ number_format = form_data.get("number_format") or "SMART_NUMBER"
+ from superset.utils import json
+
+ fmt = json.dumps(".3~s" if number_format == "SMART_NUMBER" else
number_format)
+ expression = (
+ "datum.name"
+ if label_type in ("key", "Key")
+ else f"format(datum.value, {fmt})"
+ )
+ if label_type == "key_value":
+ expression = f"datum.name + ': ' + {expression}"
+ leaves = str(bool(form_data.get("show_labels", True))).lower()
+ parents = str(bool(form_data.get("show_upper_labels", True))).lower()
+ layers.append(
+ {
+ "transform": [
+ {
+ "filter": (
+ f"(datum.leaf ? {leaves} : {parents}) "
+ "&& datum.x1-datum.x0 > 50 "
+ "&& datum.y1-datum.label_y > 20"
+ )
+ },
+ {"calculate": expression, "as": "label"},
+ ],
+ "mark": {
+ "type": "text",
+ "align": "left",
+ "baseline": "top",
+ "dx": 3,
+ "dy": 3,
+ "limit": {"expr": "datum.x1 - datum.x0 - 6"},
+ },
+ "encoding": {
+ "x": coordinates["x"],
+ "y": {**coordinates["y"], "field": "label_y"},
+ "text": {"field": "label"},
+ },
+ }
+ )
+ return VegaLitePreview(
+ specification={
+ "$schema": "https://vega.github.io/schema/vega-lite/v5.json",
+ "width": 600,
+ "height": 400,
+ "data": {"values": nodes},
+ "layer": layers,
+ "usermeta": {
+ "viz_type": "treemap_v2",
+ "layout": "slice-and-dice",
+ "hierarchy": hierarchy,
+ "metric": label,
+ "row_count": len(data),
+ "native_renderer": False,
+ "format_note": (
+ "SMART_NUMBER uses SI approximation; use Explore for
native "
+ "formatting and interactions."
+ ),
+ },
+ }
+ )
diff --git a/superset/mcp_service/explore/tool/generate_explore_link.py
b/superset/mcp_service/explore/tool/generate_explore_link.py
index 9d8656da848..12c4c87f1b3 100644
--- a/superset/mcp_service/explore/tool/generate_explore_link.py
+++ b/superset/mcp_service/explore/tool/generate_explore_link.py
@@ -280,7 +280,8 @@ async def generate_explore_link(
normalized_config,
form_data,
dataset,
- run_compile_check=normalized_config.chart_type == "gauge",
+ run_compile_check=normalized_config.chart_type
+ in ("gauge", "treemap_v2"),
)
if not compile_result.success:
await ctx.warning(
diff --git a/tests/unit_tests/mcp_service/chart/test_treemap_completeness.py
b/tests/unit_tests/mcp_service/chart/test_treemap_completeness.py
new file mode 100644
index 00000000000..6bf55284d67
--- /dev/null
+++ b/tests/unit_tests/mcp_service/chart/test_treemap_completeness.py
@@ -0,0 +1,1373 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""Treemap regressions spanning native requests, query output and update
semantics."""
+
+import re
+from contextlib import nullcontext
+from copy import deepcopy
+from decimal import Decimal
+from types import SimpleNamespace
+from typing import Any
+from unittest.mock import Mock, patch
+
+import pytest
+from pydantic import ValidationError
+
+from superset.mcp_service.chart.chart_helpers import _build_single_query_dict
+from superset.mcp_service.chart.chart_utils import (
+ map_treemap_config,
+ merge_chart_form_data,
+ resolve_treemap_update_config,
+)
+from superset.mcp_service.chart.query_result import
normalize_chart_query_result
+from superset.mcp_service.chart.schemas import (
+ ChartError,
+ GenerateChartRequest,
+ GetChartPreviewRequest,
+ TreemapChartConfig,
+ UpdateChartPreviewRequest,
+ UpdateChartRequest,
+)
+from superset.mcp_service.chart.treemap_preview import treemap_ascii,
treemap_vega_lite
+from superset.utils import json
+
+FORM_DATA: dict[str, Any] = {
+ "viz_type": "treemap_v2",
+ "groupby": ["region", "product"],
+ "metric": "revenue",
+ "datasource": "7__table",
+ "color_scheme": "lyftColors",
+ "row_limit": 7,
+ "sort_by_metric": False,
+ "show_labels": False,
+ "show_upper_labels": False,
+ "number_format": ",.1f",
+ "date_format": "%Y",
+ "label_type": "value",
+ "currency_format": {"symbol": "USD", "symbolPosition": "prefix"},
+ "adhoc_filters": [
+ {
+ "expressionType": "SIMPLE",
+ "clause": "WHERE",
+ "subject": "region",
+ "operator": "==",
+ "comparator": "West",
+ }
+ ],
+ "time_range": "2025-01-01 : 2026-01-01",
+ "granularity_sqla": "ds",
+ "template_params": '{"scale": 2}',
+}
+ROWS = [
+ {"region": "West", "product": "A", "revenue": 30},
+ {"region": "West", "product": "B", "revenue": 10},
+ {"region": "East", "product": "A", "revenue": 60},
+]
+
+
[email protected]("sort", [False, True])
[email protected]("limit", [None, 0, 1, 7])
+def test_hierarchy_query_order_matches_frontend(sort: bool, limit: int | None)
-> None:
+ """Metric order has precedence, with hierarchy tie-breakers only when
bounded."""
+ form = {**FORM_DATA, "sort_by_metric": sort, "row_limit": limit}
+ query = _build_single_query_dict(form, form["groupby"], [form["metric"]])
+ expected = ([("revenue", False)] if sort else []) + [
+ ("region", True),
+ ("product", True),
+ ]
+ assert query.get("orderby", []) == (expected if limit else [])
+
+
[email protected](
+ "metric",
+ [
+ "revenue",
+ {"name": "revenue", "saved_metric": True},
+ {
+ "expressionType": "SIMPLE",
+ "column": {"column_name": "amount"},
+ "aggregate": "SUM",
+ "label": "revenue",
+ "hasCustomLabel": True,
+ },
+ {"expressionType": "SQL", "sqlExpression": "SUM(amount)", "label":
"revenue"},
+ ],
+)
[email protected](
+ "request_class", [GenerateChartRequest, UpdateChartPreviewRequest]
+)
+def test_native_request_roundtrip(metric: Any, request_class: Any) -> None:
+ """Native columns and all valid metric shapes survive actual request
validation."""
+ native = {**FORM_DATA, "metric": metric, "slice_id": 1}
+ request = request_class(dataset_id=7, config=native)
+ result = map_treemap_config(request.config)
+ assert result["groupby"] == FORM_DATA["groupby"]
+ assert result["show_labels"] is False
+ assert result["number_format"] == ",.1f"
+ assert result["metric"] == "revenue" or result["metric"]["label"] ==
"revenue"
+
+
[email protected](
+ "field,value",
+ [
+ ("javascript", "alert(1)"),
+ ("groupby", []),
+ ("metric", {"expressionType": "SQL", "sqlExpression": "SUM(amount)"}),
+ ("row_limit", 10001),
+ ],
+)
+def test_native_requests_do_not_accept_unbounded_or_hostile_controls(
+ field: str, value: Any
+) -> None:
+ """Unknown executable controls and invalid native roles remain validation
errors."""
+ with pytest.raises(ValidationError):
+ GenerateChartRequest(dataset_id=7, config={**FORM_DATA, field: value})
+
+
[email protected](
+ "patch_data",
+ [{"show_labels": True}, {"groupby": ["product", "region"], "metric":
"revenue"}],
+)
+def test_omitted_same_viz_controls_survive(patch_data: dict[str, Any]) -> None:
+ """Partial and full-role updates preserve all omitted native controls."""
+ request = UpdateChartRequest(
+ identifier=1, config={"chart_type": "treemap_v2", **patch_data}
+ )
+ assert request.config is not None
+ config = resolve_treemap_update_config(request.config, FORM_DATA)
+ merged = merge_chart_form_data(FORM_DATA, map_treemap_config(config),
config)
+ for key, value in FORM_DATA.items():
+ if key not in patch_data:
+ assert merged[key] == value
+ for key, value in patch_data.items():
+ assert merged[key] == value
+
+
[email protected](
+ "field",
+ [
+ "color_scheme",
+ "currency_format",
+ "time_range",
+ "granularity_sqla",
+ "template_params",
+ "filters",
+ ],
+)
+def test_nullable_clears_are_not_replaced_by_defaults(field: str) -> None:
+ """Explicit null clears native nullable controls rather than restoring
defaults."""
+ request = UpdateChartRequest(
+ identifier=1, config={"chart_type": "treemap_v2", field: None}
+ )
+ assert request.config is not None
+ config = resolve_treemap_update_config(request.config, FORM_DATA)
+ merged = merge_chart_form_data(FORM_DATA, map_treemap_config(config),
config)
+ assert ("adhoc_filters" if field == "filters" else field) not in merged
+
+
+def test_empty_filters_and_dataset_rebind() -> None:
+ """Rebinds retain presentation only and scrub stale query/template
roles."""
+ config = TreemapChartConfig(
+ groupby=[{"name": "other"}], metric="other_metric", filters=[]
+ )
+ existing = {**FORM_DATA, "metrics": ["stale"], "x_axis": "stale"}
+ merged = merge_chart_form_data(
+ existing, map_treemap_config(config), config, dataset_rebind=True
+ )
+ assert merged["color_scheme"] == "lyftColors"
+ assert merged["show_labels"] is False
+ assert merged["groupby"] == ["other"]
+ assert merged["metric"] == "other_metric"
+ for key in (
+ "adhoc_filters",
+ "time_range",
+ "granularity_sqla",
+ "template_params",
+ "metrics",
+ "x_axis",
+ ):
+ assert key not in merged
+ incomplete = UpdateChartRequest(
+ identifier=1, config={"chart_type": "treemap_v2", "show_labels": True}
+ )
+ assert incomplete.config is not None
+ with pytest.raises(ValidationError):
+ resolve_treemap_update_config(incomplete.config, existing,
dataset_rebind=True)
+
+
[email protected](
+ "result",
+ [
+ None,
+ {},
+ {"queries": []},
+ {"queries": [{}]},
+ {"queries": [{"data": {}}]},
+ {"queries": [{"data": [None]}]},
+ {"queries": [{"data": [{"region": "West", "revenue": 2}]}]},
+ {"status": "failed", "message": "Database rejected SQL"},
+ {"queries": [{"error": "SQL failed", "data": ROWS}]},
+ ],
+)
+def test_malformed_results_never_produce_success(result: Any) -> None:
+ """Missing hierarchy and malformed/error envelopes are actionable
failures."""
+ assert isinstance(normalize_chart_query_result(result, FORM_DATA),
ChartError)
+
+
[email protected](
+ "value", [None, True, "3", float("nan"), float("inf"), 10**400]
+)
+def test_metric_outputs_must_be_finite_numeric(value: Any) -> None:
+ """A single invalid metric fails instead of silently dropping a hierarchy
node."""
+ rows = [*ROWS, {**ROWS[0], "revenue": value}]
+ result = normalize_chart_query_result({"queries": [{"data": rows}]},
FORM_DATA)
+ assert isinstance(result, ChartError)
+ assert result.error_type == "InvalidTreemapMetric"
+
+
+def test_result_validation_is_non_mutating_and_chart_specific() -> None:
+ """Other charts retain their existing interpretation of data."""
+ result = {"queries": [{"data": ROWS}]}
+ original = deepcopy(result)
+ assert normalize_chart_query_result(result, FORM_DATA) == original
+ assert result == original
+ unrelated = {"queries": [{"data": [{"x": None}]}]}
+ assert normalize_chart_query_result(unrelated, {"viz_type": "table"}) is
unrelated
+
+
+def test_treemap_geometry_area_hierarchy_labels_and_color() -> None:
+ """Coordinates partition parents and area follows the metric, not row
count."""
+ preview = treemap_vega_lite(
+ ROWS,
+ {
+ **FORM_DATA,
+ "currency_format": None,
+ "show_labels": True,
+ "number_format": None,
+ },
+ )
+ assert not isinstance(preview, ChartError)
+ spec = preview.specification
+ nodes = spec["data"]["values"]
+ leaves = [node for node in nodes if node["leaf"]]
+ for leaf in leaves:
+ area = (leaf["x1"] - leaf["x0"]) * (leaf["y1"] - leaf["y0"])
+ assert area / (600 * 400) == pytest.approx(leaf["value"] / 100)
+ parent = next(
+ node for node in nodes if node["path"] == leaf["path"].split(" >
")[0]
+ )
+ assert parent["x0"] <= leaf["x0"] <= leaf["x1"] <= parent["x1"]
+ assert parent["y0"] <= leaf["y0"] <= leaf["y1"] <= parent["y1"]
+ assert spec["layer"][0]["mark"]["type"] == "rect"
+ assert spec["layer"][0]["encoding"]["color"]["scale"]["range"][0] ==
"#EA0B8C"
+ assert spec["layer"][0]["encoding"]["tooltip"][1]["title"] == "revenue"
+ assert spec["layer"][1]["mark"]["type"] == "text"
+ assert "West > A | 30" in treemap_ascii(ROWS, FORM_DATA)
+
+
[email protected](
+ "rows,override",
+ [
+ (ROWS, {"color_scheme": "unavailable"}),
+ (ROWS, {"currency_format": {"symbol": "USD"}}),
+ ([{**ROWS[0], "revenue": -1}], {}),
+ (ROWS * 334, {}),
+ ],
+)
+def test_unsupported_geometry_is_explicit(rows: Any, override: Any) -> None:
+ """Unsupported representation must not fall back to a plausible bar or
scatter."""
+ preview = treemap_vega_lite(
+ rows, {**FORM_DATA, "currency_format": None, **override}
+ )
+ assert isinstance(preview, ChartError)
+ assert preview.error_type == "UnsupportedTreemapPreview"
+
+
[email protected]("format_name", ["ascii", "table", "vega_lite"])
+def test_saved_and_unsaved_preview_dispatch_match(format_name: str) -> None:
+ """Both preview entry paths use the Treemap representation and result
contract."""
+ from superset.mcp_service.chart.preview_utils import
generate_preview_from_form_data
+ from superset.mcp_service.chart.tool.get_chart_preview import (
+ ASCIIPreviewStrategy,
+ TablePreviewStrategy,
+ VegaLitePreviewStrategy,
+ )
+
+ form = {**FORM_DATA, "currency_format": None}
+ chart = SimpleNamespace(
+ id=1,
+ viz_type="treemap_v2",
+ slice_name="Treemap",
+ datasource_id=7,
+ datasource_type="table",
+ params=json.dumps(form),
+ )
+ context = SimpleNamespace(
+ queries=[SimpleNamespace(metrics=["revenue"], columns=form["groupby"])]
+ )
+ with (
+ patch(
+ "superset.commands.chart.data.get_data_command.ChartDataCommand"
+ ) as command,
+ patch(
+
"superset.mcp_service.chart.tool.get_chart_preview.build_query_context_from_form_data",
+ return_value=context,
+ ),
+ patch(
+
"superset.mcp_service.chart.chart_helpers.build_query_context_from_form_data",
+ return_value=context,
+ ),
+ patch("superset.extensions.db.session") as session,
+ ):
+ session.get.return_value = Mock(id=7)
+ command.return_value.run.return_value = {"queries": [{"data": ROWS}]}
+ strategy = {
+ "ascii": ASCIIPreviewStrategy,
+ "table": TablePreviewStrategy,
+ "vega_lite": VegaLitePreviewStrategy,
+ }[format_name]
+ saved = strategy(
+ chart, GetChartPreviewRequest(identifier=1, format=format_name)
+ ).generate()
+ unsaved = generate_preview_from_form_data(form, 7, format_name)
+ assert not isinstance(saved, ChartError)
+ assert not isinstance(unsaved, ChartError)
+ if format_name == "vega_lite":
+ assert saved.specification == unsaved.specification
+ elif format_name == "ascii":
+ assert "West > A | 30" in saved.ascii_content
+ assert "West > A | 30" in unsaved.ascii_content
+ else:
+ assert "region" in saved.table_data
+ assert "revenue" in unsaved.table_data
+
+
[email protected]("rebind", [False, True])
+def test_saved_update_and_update_preview_payloads_agree(rebind: bool) -> None:
+ """Both save and preview-first update paths apply the same omission
contract."""
+ from superset.mcp_service.chart.tool.update_chart import (
+ _build_preview_form_data,
+ _build_update_payload,
+ )
+
+ config = TreemapChartConfig(
+ groupby=[{"name": "region"}], metric="revenue", show_labels=True
+ )
+ request = UpdateChartRequest(
+ identifier=1, config=config, dataset_id=8 if rebind else None
+ )
+ chart = Mock(
+ id=1, datasource_id=7, slice_name="Treemap",
params=json.dumps(FORM_DATA)
+ )
+ with patch(
+
"superset.mcp_service.chart.chart_utils._bind_dashboard_time_range_filter"
+ ):
+ preview = _build_preview_form_data(request, chart,
parsed_config=config)
+ payload = _build_update_payload(request, chart, parsed_config=config)
+ saved = json.loads(payload["params"])
+ for key in (
+ "color_scheme",
+ "row_limit",
+ "sort_by_metric",
+ "groupby",
+ "metric",
+ "number_format",
+ "show_labels",
+ ):
+ assert saved[key] == preview[key]
+ assert saved["color_scheme"] == "lyftColors"
+ assert saved["show_labels"] is True
+ assert ("template_params" not in saved) == rebind
+
+
+def test_normalization_preserves_explicit_field_set_and_rejects_ambiguity() ->
None:
+ """Canonicalization must not turn default values into explicit update
intent."""
+ from superset.mcp_service.chart.plugins.treemap import TreemapChartPlugin
+ from superset.mcp_service.common.error_schemas import DatasetContext
+
+ context = DatasetContext(
+ id=7,
+ table_name="sales",
+ database_name="database",
+ available_columns=[{"name": "Region", "type": "STRING"}],
+ available_metrics=[{"name": "Revenue"}],
+ )
+ config = TreemapChartConfig(groupby=[{"name": "region"}], metric="revenue")
+ normalized = TreemapChartPlugin().normalize_column_refs(config, context)
+ assert normalized.groupby[0].name == "Region"
+ assert normalized.metric.name == "Revenue"
+ assert normalized.model_fields_set == config.model_fields_set
+ context.available_columns.append({"name": "REGION", "type": "STRING"})
+ with pytest.raises(ValueError, match="[Aa]mbiguous"):
+ TreemapChartPlugin().normalize_column_refs(config, context)
+
+
[email protected]
[email protected](
+ "metric",
+ [
+ "revenue",
+ {
+ "expressionType": "SIMPLE",
+ "column": {"column_name": "amount"},
+ "aggregate": "SUM",
+ "label": "revenue",
+ },
+ {"expressionType": "SQL", "sqlExpression": "SUM(amount)", "label":
"revenue"},
+ ],
+)
[email protected]("valid_result", [False, True])
+async def test_registered_generate_chart_native_roundtrip(
+ metric: Any, valid_result: bool
+) -> None:
+ """Call the registered FastMCP tool, retaining real request and compile
checks."""
+ from fastmcp import Client
+
+ from superset.mcp_service.app import mcp
+
+ request = GenerateChartRequest(
+ dataset_id=7,
+ config={
+ "chart_type": "treemap_v2",
+ "groupby": ["region", "product"],
+ "metric": metric,
+ "currency_format": {"symbol": "USD", "symbolPosition": "suffix"},
+ },
+ preview_formats=["url"],
+ )
+ dataset = Mock(id=7, datasource_name="sales", table_name="sales")
+ user = Mock(id=1, username="admin", roles=[], groups=[])
+ with (
+ patch("superset.mcp_service.auth.get_user_from_request",
return_value=user),
+ patch(
+
"superset.mcp_service.chart.validation.ValidationPipeline.validate_request_with_warnings",
+ return_value=Mock(is_valid=True, request=request, warnings={},
error=None),
+ ),
+ patch(
+ "superset.mcp_service.chart.chart_utils.generate_explore_link",
+ return_value="http://localhost/explore/?form_data_key=treemap",
+ ),
+ patch("superset.daos.dataset.DatasetDAO.find_by_id",
return_value=dataset),
+ patch(
+
"superset.mcp_service.chart.tool.generate_chart.has_dataset_access",
+ return_value=True,
+ ),
+ patch(
+
"superset.mcp_service.chart.chart_helpers.build_query_context_from_form_data",
+ return_value=Mock(),
+ ),
+ patch(
+ "superset.commands.chart.data.get_data_command.ChartDataCommand"
+ ) as command,
+ ):
+ command.return_value.run.return_value = {
+ "queries": [
+ {"data": ROWS if valid_result else [{"region": "West",
"revenue": 1}]}
+ ]
+ }
+ async with Client(mcp) as client:
+ result = await client.call_tool(
+ "generate_chart",
+ {
+ "request": {
+ "dataset_id": 7,
+ "config": {
+ "viz_type": "treemap_v2",
+ "groupby": ["region", "product"],
+ "metric": metric,
+ "currency_format": {
+ "symbol": "USD",
+ "symbolPosition": "suffix",
+ },
+ },
+ "preview_formats": ["url"],
+ }
+ },
+ )
+ data = result.structured_content
+ assert data["success"] is valid_result
+ if valid_result:
+ assert data["form_data"]["currency_format"] == {
+ "symbol": "USD",
+ "symbolPosition": "suffix",
+ }
+ assert data["form_data"]["groupby"] == ["region", "product"]
+ assert data["form_data"]["viz_type"] == "treemap_v2"
+ else:
+ assert data["error"]["error_type"] == "InvalidTreemapResult"
+ command.return_value.validate.assert_called_once()
+
+
[email protected]("number_format", [None, ",.1f"])
+def test_vega_scenegraph_renders_nested_metric_geometry(
+ number_format: str | None,
+) -> None:
+ """Compile the specification and inspect rendered rectangles, not a
snapshot."""
+ import os
+ import shutil
+ import subprocess
+
+ if not os.environ.get("NODE_PATH"):
+ pytest.skip("Requires Node vega@5 and vega-lite@5 via NODE_PATH")
+ spec = treemap_vega_lite(
+ ROWS,
+ {
+ **FORM_DATA,
+ "currency_format": None,
+ "show_labels": True,
+ "number_format": number_format,
+ },
+ )
+ assert not isinstance(spec, ChartError)
+ script = r"""
+const assert = require('assert/strict');
+const vl = require('vega-lite');
+const vega = require('vega');
+(async () => {
+ const input = JSON.parse(require('fs').readFileSync(0, 'utf8'));
+ const warnings = [];
+ const compiled = vl.compile(input, {logger: {
+ warn: message => warnings.push(message), info() {}, debug() {},
+ error: message => { throw Error(message); }
+ }}).spec;
+ assert.deepEqual(warnings, []);
+ const view = new vega.View(vega.parse(compiled), {renderer: 'none'});
+ await view.runAsync();
+ const rectangles = [];
+ const labels = [];
+ function walk(item) {
+ if (item.mark?.marktype === 'rect') rectangles.push(item);
+ if (item.mark?.marktype === 'text') labels.push(item);
+ for (const child of item.items || []) walk(child);
+ }
+ walk(view.scenegraph().root);
+ assert.equal(rectangles.length, 5);
+ assert(labels.length > 0);
+ for (const tile of rectangles.filter(tile => tile.datum.leaf)) {
+ assert(tile.width >= 0 && tile.height >= 0);
+ assert(Math.abs(tile.width * tile.height / 240000 - tile.datum.value /
100) < 1e-8);
+ assert(tile.tooltip.Hierarchy.includes(' > '));
+ assert(tile.fill.startsWith('#'));
+ }
+ assert((await view.toSVG()).includes('<svg'));
+ view.finalize();
+})().catch(error => { console.error(error); process.exit(1); });
+"""
+ node = shutil.which("node")
+ assert node is not None
+ # Run a fixed test script; generated data is supplied only on stdin.
+ result = subprocess.run( # noqa: S603
+ [node, "-e", script],
+ input=json.dumps(spec.specification),
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+ assert result.returncode == 0, result.stderr
+
+
[email protected]
[email protected]("source", ["saved", "cached_saved", "cached_unsaved"])
[email protected]("row_limit", [1, 7])
[email protected]("format_name", ["ascii", "table", "vega_lite"])
+async def test_registered_cached_preview_is_treemap(
+ source: str, row_limit: int, format_name: str
+) -> None:
+ """Cached state overrides saved viz and uses identical Treemap dispatch."""
+ import importlib
+
+ from fastmcp import Client
+
+ from superset.mcp_service.app import mcp
+
+ module = importlib.import_module(
+ "superset.mcp_service.chart.tool.get_chart_preview"
+ )
+ form = {**FORM_DATA, "currency_format": None, "row_limit": row_limit}
+ chart = SimpleNamespace(
+ id=1,
+ viz_type="treemap_v2" if source == "saved" else "table",
+ slice_name="Chart",
+ datasource_id=7,
+ datasource_type="table",
+ params=json.dumps(form if source == "saved" else {"viz_type":
"table"}),
+ )
+ rows = [
+ {**row, "revenue": Decimal(str(row["revenue"]))} for row in
ROWS[:row_limit]
+ ]
+ if row_limit == 7:
+ rows[0]["region"], rows[1]["region"] = 1, "1"
+ context = SimpleNamespace(
+ queries=[SimpleNamespace(metrics=["revenue"], columns=form["groupby"])]
+ )
+ with (
+ patch(
+ "superset.mcp_service.auth.get_user_from_request",
+ return_value=Mock(id=1, username="admin", roles=[], groups=[]),
+ ),
+ patch.object(module, "find_chart_by_identifier", return_value=chart),
+ patch.object(module.db.session, "refresh"),
+ patch.object(
+ module.event_logger,
+ "log_context",
+ side_effect=lambda **kwargs: nullcontext(),
+ ),
+ patch.object(
+ module,
+ "validate_chart_dataset",
+ return_value=Mock(is_valid=True, warnings=[]),
+ ),
+ patch.object(
+ module, "build_query_context_from_form_data", return_value=context
+ ) as build_context,
+ patch(
+ "superset.commands.explore.form_data.get.GetFormDataCommand.run",
+ return_value=json.dumps(form),
+ ),
+ patch(
+ "superset.commands.chart.data.get_data_command.ChartDataCommand"
+ ) as command,
+ ):
+ command.return_value.run.return_value = {"queries": [{"data": rows}]}
+ request = {"format": format_name}
+ if source != "saved":
+ request["form_data_key"] = "treemap-key"
+ if source != "cached_unsaved":
+ request["identifier"] = "1"
+ async with Client(mcp) as client:
+ result = await client.call_tool("get_chart_preview", {"request":
request})
+ assert not result.is_error
+ assert build_context.call_args.kwargs["row_limit"] == row_limit
+ data = json.loads(result.content[0].text)
+ assert "error_type" not in data, data
+ content = data["content"]
+ assert content["type"] == format_name
+ if format_name == "vega_lite":
+ spec = content["specification"]
+ assert spec["layer"][0]["mark"]["type"] == "rect"
+ if row_limit == 7:
+ parents = [
+ n for n in spec["data"]["values"] if not n["leaf"] and
n["name"] == "1"
+ ]
+ assert len(parents) == 2
+ assert [n["value"] for n in parents] == [30, 10]
+ assert [
+ (n["x1"] - n["x0"]) * (n["y1"] - n["y0"]) / (600 * 400) for n
in parents
+ ] == pytest.approx([0.3, 0.1])
+ elif format_name == "ascii":
+ assert f"{rows[0]['region']} > A | 30" in content["ascii_content"]
+ else:
+ assert "revenue" in content["table_data"]
+
+
[email protected]
[email protected](
+ "patch_data", [{"show_labels": True}, {"filters": []}, {"color_scheme":
None}]
+)
[email protected]("known_dataset", [True, False, None])
+async def test_registered_update_preview_preserves_cached_controls(
+ patch_data: dict[str, Any],
+ known_dataset: bool | None,
+) -> None:
+ """Partial native updates reach real FastMCP hydration, merge, and cache
writes."""
+ import importlib
+
+ from fastmcp import Client
+
+ from superset.mcp_service.app import mcp
+ from superset.mcp_service.chart.compile import CompileResult
+
+ module = importlib.import_module(
+ "superset.mcp_service.chart.tool.update_chart_preview"
+ )
+ dataset = Mock(id=7, table_name="sales", schema=None, columns=[],
metrics=[])
+ with (
+ patch(
+ "superset.mcp_service.auth.get_user_from_request",
+ return_value=Mock(id=1, username="admin", roles=[], groups=[]),
+ ),
+ patch.object(module, "_find_dataset", return_value=dataset),
+ patch.object(
+ module,
+ "_get_previous_form_data",
+ return_value=(
+ FORM_DATA
+ if known_dataset
+ else {
+ key: value
+ for key, value in FORM_DATA.items()
+ if key != "datasource"
+ }
+ if known_dataset is False
+ else None
+ ),
+ ),
+ patch("superset.daos.dataset.DatasetDAO.find_by_id",
return_value=dataset),
+ patch.object(module, "has_dataset_access", return_value=True),
+ patch(
+
"superset.mcp_service.chart.validation.dataset_validator.DatasetValidator.normalize_column_names",
+ side_effect=lambda config, *args, **kwargs: config,
+ ),
+ patch(
+
"superset.mcp_service.chart.chart_utils._bind_dashboard_time_range_filter"
+ ),
+ patch.object(
+ module, "validate_and_compile",
return_value=CompileResult(success=True)
+ ) as compile_check,
+ patch.object(
+ module,
+ "generate_explore_link",
+ return_value="http://localhost/explore/?form_data_key=updated",
+ ) as cache_write,
+ ):
+ async with Client(mcp) as client:
+ result = await client.call_tool(
+ "update_chart_preview",
+ {
+ "request": {
+ "dataset_id": 7,
+ "form_data_key": "previous",
+ "config": {"chart_type": "treemap_v2", **patch_data},
+ "generate_preview": False,
+ }
+ },
+ )
+ assert not result.is_error
+ data = result.structured_content
+ if not known_dataset:
+ assert data["error"]["error_type"] == "ValidationError"
+ assert data["success"] is False
+ cache_write.assert_not_called()
+ return
+ assert data["success"] is True, data
+ merged = data["form_data"]
+ assert merged["row_limit"] == 7
+ assert merged["sort_by_metric"] is False
+ assert merged["metric"] == "revenue"
+ assert merged["groupby"] == ["region", "product"]
+ for key, value in FORM_DATA.items():
+ field = "filters" if key == "adhoc_filters" else key
+ if field not in patch_data:
+ assert merged[key] == value
+ assert compile_check.call_args.kwargs["run_compile_check"] is True
+ assert cache_write.call_args.args[1] == merged
+
+
[email protected]
[email protected]("malformed", [False, True])
+async def test_registered_saved_update_preserves_omissions(malformed: bool) ->
None:
+ """The registered save path persists the same partial Treemap merge as
preview."""
+ import importlib
+
+ from fastmcp import Client
+
+ from superset.mcp_service.app import mcp
+
+ module =
importlib.import_module("superset.mcp_service.chart.tool.update_chart")
+ chart = Mock(
+ id=1,
+ datasource_id=7,
+ slice_name="Treemap",
+ viz_type="treemap_v2",
+ uuid="11111111-1111-1111-1111-111111111111",
+ params=json.dumps(
+ {**FORM_DATA, "metric": {"expressionType": "SIMPLE"}}
+ if malformed
+ else FORM_DATA
+ ),
+ )
+ with (
+ patch(
+ "superset.mcp_service.auth.get_user_from_request",
+ return_value=Mock(id=1, username="admin", roles=[], groups=[]),
+ ),
+ patch.object(module, "find_chart_by_identifier", return_value=chart),
+ patch(
+ "superset.mcp_service.auth.check_chart_data_access",
+ return_value=Mock(is_valid=True),
+ ),
+ patch(
+
"superset.mcp_service.chart.validation.dataset_validator.DatasetValidator.normalize_column_names",
+ side_effect=lambda config, *args, **kwargs: config,
+ ),
+ patch.object(
+ module, "_validate_update_against_dataset", return_value=None
+ ) as validate,
+ patch(
+
"superset.mcp_service.chart.chart_utils._bind_dashboard_time_range_filter"
+ ),
+ patch("superset.commands.chart.update.UpdateChartCommand") as update,
+ patch("superset.db.session"),
+ ):
+ update.return_value.run.return_value = chart
+ async with Client(mcp) as client:
+ result = await client.call_tool(
+ "update_chart",
+ {
+ "request": {
+ "identifier": 1,
+ "generate_preview": False,
+ "preview_formats": [],
+ "config": {"chart_type": "treemap_v2", "show_labels":
True},
+ }
+ },
+ )
+ data = result.structured_content
+ if malformed:
+ from superset.mcp_service.chart.schemas import GenerateChartResponse
+
+ assert not result.is_error
+ assert GenerateChartResponse.model_validate(data).success is False
+ assert data["success"] is False
+ assert data["error"]["error_type"] == "ValidationError"
+ assert data["error"]["message"] == "Invalid Treemap update
configuration"
+ update.assert_not_called()
+ return
+ assert data["success"] is True, data
+ assert data["chart"]["is_unsaved_state"] is False
+ persisted = json.loads(update.call_args.args[1]["params"])
+ for key, value in FORM_DATA.items():
+ assert persisted[key] == (True if key == "show_labels" else value)
+ validate.assert_called_once()
+
+
+def test_treemap_query_ignores_stale_cross_chart_roles() -> None:
+ """Saved/native Treemaps must not query a previous plugin's raw columns."""
+ from superset.mcp_service.chart.chart_helpers import
resolve_metrics_and_groupby
+
+ form = {
+ **FORM_DATA,
+ "query_mode": "raw",
+ "all_columns": ["stale"],
+ "metrics": ["stale_metric"],
+ }
+ metrics, hierarchy = resolve_metrics_and_groupby(form)
+ assert metrics == ["revenue"]
+ assert hierarchy == ["region", "product"]
+
+
[email protected]("limit", ["0", "0.0", "", "1"])
+def test_native_string_row_limits_match_frontend(limit: str) -> None:
+ """Frontend applyOrderBy numerically parses string row limits."""
+ query = _build_single_query_dict(
+ {**FORM_DATA, "row_limit": limit}, ["region"], ["revenue"]
+ )
+ assert query.get("orderby", []) == ([("region", True)] if limit == "1"
else [])
+
+
+def test_explicit_temporal_clear_keeps_user_filters_and_template_state() ->
None:
+ """Removing a generated time binding must not erase user-owned
predicates."""
+ from superset.mcp_service.chart.chart_utils import
MCP_DASHBOARD_TIME_FILTER_SUBJECT
+ from superset.mcp_service.chart.tool.update_chart import
_build_preview_form_data
+
+ existing = {
+ **FORM_DATA,
+ MCP_DASHBOARD_TIME_FILTER_SUBJECT: "ds",
+ "adhoc_filters": [
+ *FORM_DATA["adhoc_filters"],
+ {
+ "expressionType": "SIMPLE",
+ "clause": "WHERE",
+ "subject": "ds",
+ "operator": "TEMPORAL_RANGE",
+ "comparator": "No filter",
+ },
+ ],
+ }
+ request = UpdateChartRequest(
+ identifier=1, config={"chart_type": "treemap_v2", "temporal_column":
None}
+ )
+ assert request.config is not None
+ config = resolve_treemap_update_config(request.config, existing)
+ chart = Mock(
+ id=1, datasource_id=7, slice_name="Treemap",
params=json.dumps(existing)
+ )
+ result = _build_preview_form_data(request, chart, parsed_config=config)
+ assert isinstance(result, dict)
+ assert result["adhoc_filters"] == FORM_DATA["adhoc_filters"]
+ assert result["template_params"] == FORM_DATA["template_params"]
+ assert MCP_DASHBOARD_TIME_FILTER_SUBJECT not in result
+
+
+def test_canonical_metric_reference_keeps_merged_default_sum() -> None:
+ """Preserve the community plugin's default SUM for a typed physical
column."""
+ config = TreemapChartConfig(groupby=[{"name": "region"}], metric={"name":
"amount"})
+ assert map_treemap_config(config)["metric"]["label"] == "SUM(amount)"
+ with pytest.raises(ValidationError):
+ TreemapChartConfig(
+ groupby=["region"],
+ metric={"expressionType": "SIMPLE", "column": {"column_name":
"amount"}},
+ )
+
+
+def test_compile_respects_treemap_row_limit() -> None:
+ """Compile must not validate rows excluded by a smaller native row
limit."""
+ from superset.mcp_service.chart.compile import _compile_chart
+
+ with (
+ patch(
+
"superset.mcp_service.chart.chart_helpers.build_query_context_from_form_data",
+ return_value=Mock(),
+ ) as build,
+ patch(
+ "superset.commands.chart.data.get_data_command.ChartDataCommand"
+ ) as command,
+ ):
+ command.return_value.run.return_value = {"queries": [{"data":
ROWS[:1]}]}
+ result = _compile_chart({**FORM_DATA, "row_limit": 1}, 7)
+ assert result.success is True
+ assert build.call_args.kwargs["row_limit"] == 1
+
+
[email protected]("selected", ["event_time", None])
[email protected]("override", ["dashboard_time", None, "omitted"])
+def test_treemap_selected_time_column_reaches_query(
+ selected: str | None,
+ override: str | None,
+) -> None:
+ """The selected time column and explicit dashboard overrides reach
execution."""
+ from superset.mcp_service.chart.chart_helpers import (
+ build_query_dicts_from_form_data,
+ )
+
+ form = {
+ "viz_type": "treemap_v2",
+ "groupby": ["region"],
+ "metric": "revenue",
+ "granularity_sqla": selected,
+ "time_range": "2026-01-01 : 2026-02-01",
+ }
+ extra = {} if override == "omitted" else {"granularity_sqla": override}
+ with patch(
+ "superset.mcp_service.chart.chart_helpers.resolve_datasource_engine",
+ return_value="postgresql",
+ ):
+ query = build_query_dicts_from_form_data(
+ form,
+ 7,
+ "table",
+ extra_form_data=extra,
+ )[0]
+ # Shared dashboard normalization treats a null extra as no override.
+ # Clearing the chart's selected column itself is covered by selected=None.
+ expected = selected if override in (None, "omitted") else override
+ assert query.get("granularity") == expected
+ assert query["time_range"] == form["time_range"]
+ assert query["columns"] == ["region"]
+ assert query["metrics"] == ["revenue"]
+
+
+def test_treemap_mixed_type_categories_remain_separate() -> None:
+ """Match frontend raw-key grouping and display-name categorical colors."""
+ preview = treemap_vega_lite(
+ [{"region": 1, "revenue": 1}, {"region": "1", "revenue": 3}],
+ {
+ "viz_type": "treemap_v2",
+ "groupby": ["region"],
+ "metric": "revenue",
+ "show_labels": True,
+ "color_scheme": "supersetColors",
+ },
+ )
+ assert not isinstance(preview, ChartError)
+ nodes = preview.specification["data"]["values"]
+ assert len(nodes) == 2
+ assert [node["name"] for node in nodes] == ["1", "1"]
+ assert [node["value"] for node in nodes] == [1, 3]
+
+
[email protected](
+ "value,valid",
+ [
+ (Decimal("2.75"), True),
+ (Decimal("1E1000"), True),
+ (Decimal("NaN"), False),
+ (Decimal("sNaN"), False),
+ (Decimal("Infinity"), False),
+ (Decimal("-Infinity"), False),
+ (True, False),
+ ("2.75", False),
+ ],
+)
+def test_decimal_result_contract(value: Any, valid: bool) -> None:
+ """SQL numeric values remain numeric; nonfinite and coercible strings do
not."""
+ rows = [{**ROWS[0], "revenue": value}]
+ result = {"queries": [{"data": rows}]}
+ checked = normalize_chart_query_result(result, FORM_DATA)
+ assert isinstance(checked, ChartError) is not valid
+ if valid:
+ assert checked is result
+ assert rows[0]["revenue"] is value
+
+
[email protected]
[email protected]("save", [False, True])
[email protected]("control", ["filters", "empty", "change_time",
"clear_time"])
+async def test_registered_filter_update_keeps_saved_temporal_binding(
+ save: bool,
+ control: str,
+) -> None:
+ """Exercise real mapping/binding/merge through both registered update
entries."""
+ import importlib
+
+ from fastmcp import Client
+
+ from superset.mcp_service.app import mcp
+ from superset.mcp_service.chart.chart_utils import
MCP_DASHBOARD_TIME_FILTER_SUBJECT
+ from superset.mcp_service.chart.compile import CompileResult
+
+ saved_module = importlib.import_module(
+ "superset.mcp_service.chart.tool.update_chart"
+ )
+ preview_module = importlib.import_module(
+ "superset.mcp_service.chart.tool.update_chart_preview"
+ )
+ dataset = Mock(
+ id=7,
+ table_name="sales",
+ schema=None,
+ columns=[],
+ metrics=[],
+ main_dttm_col="default_time",
+ )
+ existing = {
+ **FORM_DATA,
+ "granularity_sqla": None,
+ MCP_DASHBOARD_TIME_FILTER_SUBJECT: "saved_time",
+ "adhoc_filters": [
+ *FORM_DATA["adhoc_filters"],
+ {
+ "expressionType": "SIMPLE",
+ "clause": "WHERE",
+ "subject": "saved_time",
+ "operator": "TEMPORAL_RANGE",
+ "comparator": "No filter",
+ },
+ ],
+ }
+ config: dict[str, Any] = {
+ "chart_type": "treemap_v2",
+ "currency_format": {"symbol": "USD", "symbolPosition": "suffix"},
+ }
+ if control in ("filters", "empty"):
+ config["filters"] = (
+ [{"column": "product", "op": "=", "value": "A"}]
+ if control == "filters"
+ else []
+ )
+ else:
+ config["temporal_column"] = "new_time" if control == "change_time"
else None
+ chart = Mock(
+ id=1,
+ datasource_id=7,
+ slice_name="Treemap",
+ viz_type="treemap_v2",
+ uuid="11111111-1111-1111-1111-111111111111",
+ params=json.dumps(existing),
+ )
+ with (
+ patch(
+ "superset.mcp_service.auth.get_user_from_request",
+ return_value=Mock(id=1, username="admin", roles=[], groups=[]),
+ ),
+ patch.object(saved_module, "find_chart_by_identifier",
return_value=chart),
+ patch(
+ "superset.mcp_service.auth.check_chart_data_access",
+ return_value=Mock(is_valid=True),
+ ),
+ patch.object(preview_module, "_find_dataset", return_value=dataset),
+ patch.object(preview_module, "_get_previous_form_data",
return_value=existing),
+ patch.object(preview_module, "has_dataset_access", return_value=True),
+ patch("superset.daos.dataset.DatasetDAO.find_by_id",
return_value=dataset),
+ patch(
+
"superset.mcp_service.chart.chart_utils._find_dataset_by_id_or_uuid",
+ return_value=dataset,
+ ),
+ patch(
+
"superset.mcp_service.chart.chart_utils._is_temporal_for_dashboard_binding",
+ return_value=True,
+ ),
+ patch(
+
"superset.mcp_service.chart.validation.dataset_validator.DatasetValidator.normalize_column_names",
+ side_effect=lambda value, *args, **kwargs: value,
+ ),
+ patch.object(
+ saved_module, "_validate_update_against_dataset", return_value=None
+ ),
+ patch.object(
+ preview_module,
+ "validate_and_compile",
+ return_value=CompileResult(success=True),
+ ),
+ patch.object(
+ preview_module,
+ "generate_explore_link",
+ return_value="http://localhost/explore/?form_data_key=updated",
+ ) as cache,
+ patch("superset.commands.chart.update.UpdateChartCommand") as update,
+ patch("superset.db.session"),
+ ):
+ update.return_value.run.return_value = chart
+ request: dict[str, Any] = {"config": config, "generate_preview": False}
+ if save:
+ request.update(identifier=1, preview_formats=[])
+ else:
+ request.update(dataset_id=7, form_data_key="previous")
+ async with Client(mcp) as client:
+ result = await client.call_tool(
+ "update_chart" if save else "update_chart_preview",
+ {"request": request},
+ )
+ assert not result.is_error
+ data = result.structured_content
+ assert data["success"] is True, data
+ merged = (
+ json.loads(update.call_args.args[1]["params"])
+ if save
+ else cache.call_args.args[1]
+ )
+ filters = merged.get("adhoc_filters", [])
+ if control == "empty":
+ assert not filters
+ assert MCP_DASHBOARD_TIME_FILTER_SUBJECT not in merged
+ else:
+ for predicate in FORM_DATA["adhoc_filters"]:
+ assert predicate in filters
+ if control == "clear_time":
+ assert MCP_DASHBOARD_TIME_FILTER_SUBJECT not in merged
+ else:
+ subject = "saved_time" if control == "filters" else "new_time"
+ assert merged[MCP_DASHBOARD_TIME_FILTER_SUBJECT] == subject
+ temporal = [f for f in filters if f["operator"] ==
"TEMPORAL_RANGE"]
+ assert len(temporal) == 1
+ assert temporal[0]["subject"] == subject
+ assert temporal[0]["comparator"] == "No filter"
+ assert not any(f["subject"] == "default_time" for f in filters)
+ if control == "filters":
+ assert any(
+ f["subject"] == "product" and f["comparator"] == "A" for f in
filters
+ )
+ assert merged["currency_format"] == {"symbol": "USD", "symbolPosition":
"suffix"}
+
+
+def test_resolution_validation_error_is_caught_value_error() -> None:
+ """Use the installed Pydantic type, rather than assuming its
inheritance."""
+ request = UpdateChartPreviewRequest(
+ dataset_id=7, config={"chart_type": "treemap_v2", "show_labels": True}
+ )
+ with pytest.raises(ValidationError) as caught:
+ resolve_treemap_update_config(request.config, {})
+ assert isinstance(caught.value, ValueError)
+
+
+# --- Node-free equivalents of the scenegraph render assertion
-----------------
+#
+# ``test_vega_scenegraph_renders_nested_metric_geometry`` only runs where Node
+# with vega/vega-lite is installed, so the checks below reproduce the parts a
+# compile would catch — unresolvable ``datum`` references and unknown Vega
+# expression functions — using nothing but the standard library.
+
+_DATUM_REFERENCE = re.compile(r"datum\.([A-Za-z_$][A-Za-z0-9_$]*)")
+_FUNCTION_CALL = re.compile(r"\b([A-Za-z_$][A-Za-z0-9_$]*)\s*\(")
+# Vega expression functions the Treemap specification is allowed to emit.
+_ALLOWED_VEGA_FUNCTIONS = frozenset({"format", "if", "isValid", "join"})
+
+
+def _expression_strings(node: Any) -> list[str]:
+ """Collect every Vega expression embedded in a Vega-Lite specification."""
+ found: list[str] = []
+ if isinstance(node, dict):
+ for key, value in node.items():
+ if key in ("filter", "calculate", "expr") and isinstance(value,
str):
+ found.append(value)
+ else:
+ found.extend(_expression_strings(value))
+ elif isinstance(node, list):
+ for item in node:
+ found.extend(_expression_strings(item))
+ return found
+
+
+def _resolvable_fields(layer: dict[str, Any], data_keys: set[str]) -> set[str]:
+ """Return data keys plus every field the layer's transforms derive."""
+ derived = {
+ transform["as"]
+ for transform in layer.get("transform", [])
+ if isinstance(transform.get("as"), str)
+ }
+ return data_keys | derived
+
+
[email protected]("number_format", [None, ",.1f"])
[email protected]("show_labels", [False, True])
+def test_vega_spec_field_references_all_resolve(
+ number_format: str | None, show_labels: bool
+) -> None:
+ """Every encoding and expression field must exist in the data or a
transform."""
+ spec = treemap_vega_lite(
+ ROWS,
+ {
+ **FORM_DATA,
+ "currency_format": None,
+ "show_labels": show_labels,
+ "number_format": number_format,
+ },
+ )
+ assert not isinstance(spec, ChartError)
+ specification = spec.specification
+ data_keys = set(specification["data"]["values"][0])
+ assert specification["$schema"].startswith(
+ "https://vega.github.io/schema/vega-lite"
+ )
+ layers = specification["layer"]
+ assert layers
+
+ for layer in layers:
+ resolvable = _resolvable_fields(layer, data_keys)
+ for channel in layer["encoding"].values():
+ channels = channel if isinstance(channel, list) else [channel]
+ for definition in channels:
+ field = definition.get("field")
+ if field is not None:
+ assert field in resolvable, f"unknown encoding field
{field!r}"
+ for expression in _expression_strings(layer):
+ for reference in _DATUM_REFERENCE.findall(expression):
+ assert reference in resolvable, (
+ f"expression {expression!r} references unknown field
{reference!r}"
+ )
+ unknown = set(_FUNCTION_CALL.findall(expression)) -
_ALLOWED_VEGA_FUNCTIONS
+ assert not unknown, f"expression {expression!r} calls
{sorted(unknown)}"
+
+
+def test_vega_spec_field_reference_check_catches_a_bad_calculate() -> None:
+ """The Node-free check must fail on the typo class it exists to catch."""
+ spec = treemap_vega_lite(ROWS, {**FORM_DATA, "currency_format": None})
+ assert not isinstance(spec, ChartError)
+ layer = deepcopy(spec.specification["layer"][0])
+ layer.setdefault("transform", []).append(
+ {"calculate": "format(datum.valeu, ',.1f')", "as": "typo"}
+ )
+ data_keys = set(spec.specification["data"]["values"][0])
+ resolvable = _resolvable_fields(layer, data_keys)
+ references = {
+ reference
+ for expression in _expression_strings(layer)
+ for reference in _DATUM_REFERENCE.findall(expression)
+ }
+ assert references - resolvable == {"valeu"}
+
+
+# --- Update-union discriminator ----------------------------------------------
+
+
[email protected](
+ "config",
+ [
+ {"row_limit": 50},
+ {"groupby": ["region"], "metric": "revenue"},
+ {"color_scheme": "supersetColors"},
+ ],
+)
+def test_update_request_rejects_config_without_discriminator(
+ config: dict[str, Any],
+) -> None:
+ """A config omitting chart_type must not fall through to the Treemap
model."""
+ with pytest.raises(ValidationError):
+ UpdateChartRequest.model_validate({"identifier": 1, "config": config})
+ with pytest.raises(ValidationError):
+ UpdateChartPreviewRequest.model_validate({"dataset_id": 7, "config":
config})
+
+
+def test_update_request_still_accepts_partial_treemap_with_discriminator() ->
None:
+ """Partial Treemap updates remain valid when the discriminator is
present."""
+ request = UpdateChartRequest.model_validate(
+ {"identifier": 1, "config": {"chart_type": "treemap_v2", "row_limit":
50}}
+ )
+ assert request.config is not None
+ assert request.config.chart_type == "treemap_v2"
+ assert request.config.groupby is None
+ assert request.config.metric is None
+ assert "row_limit" in request.config.model_fields_set
+
+
+# --- Adhoc Custom SQL hierarchy columns --------------------------------------
+
+ADHOC_COLUMN: dict[str, Any] = {
+ "label": "Region Bucket",
+ "sqlExpression": "CASE WHEN region = 'West' THEN 'W' ELSE 'O' END",
+ "expressionType": "SQL",
+ "hasCustomLabel": True,
+}
+ADHOC_FORM_DATA: dict[str, Any] = {
+ **FORM_DATA,
+ "currency_format": None,
+ "groupby": ["region", ADHOC_COLUMN],
+}
+ADHOC_ROWS = [
+ {"region": "West", "Region Bucket": "W", "revenue": 30},
+ {"region": "East", "Region Bucket": "O", "revenue": 60},
+]
+
+
+def test_adhoc_hierarchy_column_is_normalized_to_its_output_label() -> None:
+ """Saved Treemaps using Explore's Custom SQL columns must not be
rejected."""
+ result = {"queries": [{"data": ADHOC_ROWS}]}
+ assert normalize_chart_query_result(result, ADHOC_FORM_DATA) is result
+
+
+def test_adhoc_hierarchy_column_without_label_falls_back_to_sql() -> None:
+ """An unlabelled adhoc column is keyed by its raw SQL, matching
getColumnLabel."""
+ form_data = {
+ **ADHOC_FORM_DATA,
+ "groupby": [
+ "region",
+ {"sqlExpression": "lower(city)", "expressionType": "SQL"},
+ ],
+ }
+ rows = [{"region": "West", "lower(city)": "sf", "revenue": 30}]
+ result = {"queries": [{"data": rows}]}
+ assert normalize_chart_query_result(result, form_data) is result
+
+
+def test_adhoc_hierarchy_previews_render_with_resolved_labels() -> None:
+ """Preview formats index rows by output label rather than the adhoc
object."""
+ ascii_preview = treemap_ascii(ADHOC_ROWS, ADHOC_FORM_DATA)
+ assert not isinstance(ascii_preview, ChartError)
+ assert "West > W" in ascii_preview
+ vega = treemap_vega_lite(ADHOC_ROWS, ADHOC_FORM_DATA)
+ assert not isinstance(vega, ChartError)
+ assert vega.specification["usermeta"]["hierarchy"] == ["region", "Region
Bucket"]
+
+
[email protected](
+ "groupby",
+ [
+ ["region", {"expressionType": "SQL"}],
+ ["region", {"label": "region"}],
+ ["region", 7],
+ ],
+)
+def test_unresolvable_or_duplicate_hierarchy_columns_stay_rejected(
+ groupby: list[Any],
+) -> None:
+ """Normalization must not weaken the unique-resolvable-hierarchy
contract."""
+ failure = normalize_chart_query_result(
+ {"queries": [{"data": ADHOC_ROWS}]}, {**ADHOC_FORM_DATA, "groupby":
groupby}
+ )
+ assert isinstance(failure, ChartError)
+ assert failure.error_type == "InvalidTreemapFormData"
diff --git a/tests/unit_tests/mcp_service/chart/tool/test_get_chart_data.py
b/tests/unit_tests/mcp_service/chart/tool/test_get_chart_data.py
index 792e7a3fdac..463894cb1c5 100644
--- a/tests/unit_tests/mcp_service/chart/tool/test_get_chart_data.py
+++ b/tests/unit_tests/mcp_service/chart/tool/test_get_chart_data.py
@@ -1938,6 +1938,161 @@ class TestSavedChartExtraFormDataFilters:
assert payload["queries"][0]["data"] is rows
assert payload["queries"][0]["rowcount"] == source_rowcount
+ @pytest.mark.asyncio
+ @pytest.mark.parametrize("data_path", ["saved", "saved_cache",
"unsaved_cache"])
+ @pytest.mark.parametrize("export_format", ["json", "csv", "excel"])
+ @pytest.mark.parametrize("has_finite", [True, False])
+ async def test_treemap_fastmcp_validates_results_and_exports(
+ self,
+ mcp_server: Any,
+ mock_auth: Any,
+ data_path: str,
+ export_format: str,
+ has_finite: bool,
+ ) -> None:
+ """Treemap validates hierarchy metrics on saved and cached export
paths."""
+ from decimal import Decimal
+
+ module = importlib.import_module(
+ "superset.mcp_service.chart.tool.get_chart_data"
+ )
+ chart = SimpleNamespace(
+ id=10,
+ slice_name="SLA",
+ viz_type="treemap_v2",
+ datasource_id=1,
+ datasource_type="table",
+ query_context=json.dumps(
+ {
+ "datasource": {"id": 1, "type": "table"},
+ "queries": [
+ {
+ "columns": ["team"],
+ "metrics": ["saved_sla"],
+ "row_limit": 10,
+ }
+ ],
+ }
+ ),
+ params=json.dumps(
+ {
+ "viz_type": "treemap_v2",
+ "metric": "saved_sla",
+ "groupby": ["team"],
+ }
+ ),
+ )
+
+ def fake_load(self: Any, data: dict[str, Any]) -> Any:
+ return SimpleNamespace(
+ queries=[
+ SimpleNamespace(
+ filter=[],
+ time_range=None,
+ to_dict=lambda: dict(data["queries"][0]),
+ )
+ ],
+ form_data={},
+ )
+
+ rows: list[dict[str, Any]] = [
+ {"team": "Blue", "saved_sla": Decimal("42") if has_finite else
None}
+ ]
+ source_rowcount = len(rows) + 7
+ payload = {
+ "queries": [
+ {
+ "data": rows,
+ "rowcount": source_rowcount,
+ "colnames": ["team", "saved_sla"],
+ }
+ ]
+ }
+
+ class Command:
+ def __init__(self, query_context: Any) -> None: ...
+ def validate(self) -> None: ...
+ def run(self) -> dict[str, Any]:
+ return payload
+
+ cached_form_data = {
+ "viz_type": "treemap_v2",
+ "datasource": "1__table",
+ "metric": "saved_sla",
+ "groupby": ["team"],
+ "slice_name": "SLA",
+ }
+ query = {"columns": ["team"], "metrics": ["saved_sla"]}
+ with (
+ patch.object(
+ module,
+ "get_cached_form_data",
+ return_value=json.dumps(cached_form_data),
+ ),
+ patch.object(
+ module, "build_query_dicts_from_form_data",
return_value=[query]
+ ),
+ patch.object(
+ module,
+ "build_query_context_from_form_data",
+ return_value=fake_load(None, {"queries": [query]}),
+ ),
+ patch.object(module, "find_chart_by_identifier",
return_value=chart),
+ patch.object(
+ module,
+ "validate_chart_dataset",
+ return_value=SimpleNamespace(is_valid=True, warnings=[],
error=None),
+ ),
+ patch(
+ "superset.charts.schemas.ChartDataQueryContextSchema.load",
fake_load
+ ),
+ patch(
+
"superset.commands.chart.data.get_data_command.ChartDataCommand",
+ Command,
+ ),
+ ):
+ async with Client(mcp_server) as client:
+ request = {"format": export_format}
+ if data_path != "unsaved_cache":
+ request["identifier"] = "10"
+ if data_path != "saved":
+ request["form_data_key"] = "raw-treemap-cache"
+ result = await client.call_tool("get_chart_data", {"request":
request})
+
+ data = json.loads(result.content[0].text)
+ if not has_finite:
+ assert data["error_type"] == "InvalidTreemapMetric"
+ return
+ assert data["row_count"] == len(rows)
+ assert data["total_rows"] == (
+ source_rowcount if export_format == "json" else len(rows)
+ )
+ expected_groups = [row["team"] for row in rows]
+ if export_format == "json":
+ assert [row["team"] for row in data["data"]] == expected_groups
+ assert data["data_quality"]["completeness"] == pytest.approx(1)
+ assert data.get("query_results") is None
+ elif export_format == "csv":
+ import csv
+ from io import StringIO
+
+ exported = list(csv.DictReader(StringIO(data["csv_data"])))
+ assert [row["team"] for row in exported] == expected_groups
+ else:
+ import base64
+ from io import BytesIO
+
+ from openpyxl import load_workbook
+
+ workbook =
load_workbook(BytesIO(base64.b64decode(data["excel_data"])))
+ assert list(workbook.active.values)[0] == ("team", "saved_sla")
+ assert [
+ row[0] for row in list(workbook.active.values)[1:]
+ ] == expected_groups
+ assert [row[1] for row in list(workbook.active.values)[1:]] == [42]
+ assert payload["queries"][0]["data"] is rows
+ assert payload["queries"][0]["rowcount"] == source_rowcount
+
class TestOAuthErrorRouting:
"""Query-time OAuth errors must reach the dedicated OAuth handlers.
diff --git
a/tests/unit_tests/mcp_service/chart/tool/test_get_chart_type_schema.py
b/tests/unit_tests/mcp_service/chart/tool/test_get_chart_type_schema.py
index 8a2ab50f6bf..27b22ae76ce 100644
--- a/tests/unit_tests/mcp_service/chart/tool/test_get_chart_type_schema.py
+++ b/tests/unit_tests/mcp_service/chart/tool/test_get_chart_type_schema.py
@@ -17,12 +17,15 @@
"""Tests for get_chart_type_schema tool logic."""
+from typing import Any
+
import pytest
from superset.extensions import feature_flag_manager
from superset.mcp_service.chart.tool.get_chart_type_schema import (
_CHART_EXAMPLES,
_CHART_TYPE_ADAPTERS,
+ _compiled_chart_schema,
_get_chart_type_schema_impl as _call_schema,
VALID_CHART_TYPES,
)
@@ -148,3 +151,26 @@ class TestGetChartTypeSchema:
for chart_type in VALID_CHART_TYPES:
assert chart_type in _CHART_EXAMPLES
assert len(_CHART_EXAMPLES[chart_type]) >= 1
+
+
+def test_schema_cache_compiles_once_and_isolates_response_mutation(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """Repeated discovery avoids compilation without sharing mutable response
data."""
+ _compiled_chart_schema.cache_clear()
+ adapter = _CHART_TYPE_ADAPTERS["treemap_v2"]
+ original = adapter.json_schema
+ calls = 0
+
+ def counted_schema() -> dict[str, Any]:
+ nonlocal calls
+ calls += 1
+ return original()
+
+ monkeypatch.setattr(adapter, "json_schema", counted_schema)
+ first = _call_schema("treemap_v2")
+ first["schema"]["properties"].clear()
+ second = _call_schema("treemap_v2")
+ assert "groupby" in second["schema"]["properties"]
+ assert calls == 1
+ _compiled_chart_schema.cache_clear()
diff --git
a/tests/unit_tests/mcp_service/explore/tool/test_generate_explore_link.py
b/tests/unit_tests/mcp_service/explore/tool/test_generate_explore_link.py
index dba58131399..3b8b79b88d7 100644
--- a/tests/unit_tests/mcp_service/explore/tool/test_generate_explore_link.py
+++ b/tests/unit_tests/mcp_service/explore/tool/test_generate_explore_link.py
@@ -97,6 +97,55 @@ async def
test_gauge_fastmcp_entry_compiles_and_returns_native_form_data(
assert mock_validate.call_args.kwargs["run_compile_check"] is True
[email protected]("time_range", ["", " ", "No filter"])
[email protected]("comparator", ["Last week", "", " "])
[email protected](generate_explore_link_module, "validate_and_compile")
+@patch("superset.daos.dataset.DatasetDAO.find_by_id")
[email protected]
+async def test_treemap_fastmcp_entry_compiles_native_temporal_form_data(
+ mock_find_dataset, mock_validate, mcp_server, time_range: str, comparator:
str
+) -> None:
+ """The public Treemap request reaches compile and a native Explore
payload."""
+ from superset.mcp_service.chart.compile import CompileResult
+
+ mock_find_dataset.return_value = _mock_dataset(id=3)
+ mock_validate.return_value = CompileResult(success=True)
+ request = {
+ "dataset_id": "3",
+ "config": {
+ "chart_type": "treemap_v2",
+ "metric": {"name": "num", "aggregate": "AVG"},
+ "groupby": ["name"],
+ "show_labels": False,
+ "number_format": ",.1f",
+ "time_range": time_range,
+ "adhoc_filters": [
+ {
+ "subject": "event_time",
+ "operator": "TEMPORAL_RANGE",
+ "comparator": comparator,
+ }
+ ],
+ },
+ }
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool("generate_explore_link", {"request":
request})
+
+ assert result.structured_content["success"] is True
+ form_data = result.structured_content["form_data"]
+ assert form_data["viz_type"] == "treemap_v2"
+ assert form_data["metric"]["label"] == "AVG(num)"
+ assert form_data["number_format"] == ",.1f"
+ if comparator == "Last week":
+ assert form_data["time_range"] == "Last week"
+ else:
+ assert form_data.get("time_range") in (None, "No filter")
+ assert form_data["adhoc_filters"][0]["subject"] == "event_time"
+ assert form_data["adhoc_filters"][0]["comparator"] == "No filter"
+ assert mock_validate.call_args.kwargs["run_compile_check"] is True
+
+
logging.basicConfig(level=logging.DEBUG)
logger = logging.getLogger(__name__)