You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by mi...@apache.org on 2026/09/09 14:34:57 UTC
(superset) branch master updated: fix(mcp): complete Gauge chart support follow-up (#43943)
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 08e686b5353 fix(mcp): complete Gauge chart support follow-up (#43943)
08e686b5353 is described below
commit 08e686b5353eb11f9c0fbe04cc05c7ff508d823f
Author: Amin Ghadersohi <am...@gmail.com>
AuthorDate: Wed Sep 9 10:34:50 2026 -0400
fix(mcp): complete Gauge chart support follow-up (#43943)
---
docs/admin_docs/configuration/mcp-server.mdx | 6 +-
superset/mcp_service/app.py | 2 +
superset/mcp_service/chart/chart_utils.py | 182 ++++
superset/mcp_service/chart/compile.py | 105 ++-
superset/mcp_service/chart/plugins/gauge.py | 75 +-
superset/mcp_service/chart/preview_utils.py | 537 ++++++++++-
superset/mcp_service/chart/query_result.py | 225 +++++
.../mcp_service/chart/resources/chart_configs.py | 24 +
superset/mcp_service/chart/schemas.py | 250 +++++-
superset/mcp_service/chart/tool/generate_chart.py | 6 +-
superset/mcp_service/chart/tool/get_chart_data.py | 52 +-
.../mcp_service/chart/tool/get_chart_preview.py | 95 +-
.../chart/tool/get_chart_type_schema.py | 10 +-
superset/mcp_service/chart/tool/update_chart.py | 96 +-
.../mcp_service/chart/tool/update_chart_preview.py | 92 +-
.../chart/validation/dataset_validator.py | 208 +++--
.../chart/validation/schema_validator.py | 11 +
.../explore/tool/generate_explore_link.py | 2 +-
.../common/test_query_context_processor.py | 25 +-
tests/unit_tests/connectors/sqla/models_test.py | 48 +
tests/unit_tests/mcp_service/chart/test_compile.py | 109 +++
.../mcp_service/chart/test_gauge_chart.py | 994 ++++++++++++++++++++-
.../mcp_service/chart/test_gauge_render.py | 106 +++
.../mcp_service/chart/test_preview_utils.py | 71 ++
.../mcp_service/chart/tool/test_generate_chart.py | 86 ++
.../mcp_service/chart/tool/test_get_chart_data.py | 281 +++++-
.../chart/tool/test_get_chart_preview.py | 152 ++++
.../chart/tool/test_get_chart_type_schema.py | 29 +
.../mcp_service/chart/tool/test_update_chart.py | 207 ++++-
.../chart/tool/test_update_chart_preview.py | 360 ++------
.../explore/tool/test_generate_explore_link.py | 123 ++-
31 files changed, 3990 insertions(+), 579 deletions(-)
diff --git a/docs/admin_docs/configuration/mcp-server.mdx b/docs/admin_docs/configuration/mcp-server.mdx
index 847961b6bae..d66c9270076 100644
--- a/docs/admin_docs/configuration/mcp-server.mdx
+++ b/docs/admin_docs/configuration/mcp-server.mdx
@@ -954,7 +954,11 @@ 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`, `pivot_table`, `mixed_timeseries`, `handlebars`, `big_number`, `histogram`, `box_plot`, `waterfall`) to a registered chart type plugin. Two settings let operators enable or disable individual chart type plugins at runtime, without a code deploy.
+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, [...]
+
+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 [...]
+
+Gauge previews retain data-derived automatic bounds even when configured interval thresholds extend beyond the visible range; Vega previews clip those thresholds into adjacent, non-overlapping bands. Explicit min/max bounds still constrain intervals. All-zero data with both bounds omitted uses a nondegenerate preview range of 0–1. On same-dataset Gauge updates, clearing or changing the dashboard temporal binding preserves user-authored temporal ranges; dataset rebinds discard old query r [...]
### Static deny-list
diff --git a/superset/mcp_service/app.py b/superset/mcp_service/app.py
index 145862681d4..d90ae7b03aa 100644
--- a/superset/mcp_service/app.py
+++ b/superset/mcp_service/app.py
@@ -395,6 +395,8 @@ Chart Types You Can CREATE with generate_chart/generate_explore_link:
- chart_type="table": Data table for detailed views
- chart_type="table", viz_type="ag-grid-table": Interactive AG Grid table
- chart_type="pie": Pie chart for proportional data (set donut=True for donut)
+- chart_type="gauge": Gauge/dial for one numeric metric, optionally grouped
+ into up to 10 dials (native viz_type is "gauge_chart")
- chart_type="pivot_table": OSS Pivot Table for cross-tabulation
- chart_type="interactive_pivot": Extension-provided AG Grid Interactive Pivot Table.
This type is distinct from pivot_table/pivot_table_v2 and is available only
diff --git a/superset/mcp_service/chart/chart_utils.py b/superset/mcp_service/chart/chart_utils.py
index a59222f35bb..d8ad21141a5 100644
--- a/superset/mcp_service/chart/chart_utils.py
+++ b/superset/mcp_service/chart/chart_utils.py
@@ -599,6 +599,168 @@ def merge_interactive_pivot_ui_config(
new_form_data["pivot_table_state"] = {**existing_state, **new_state}
+_GAUGE_FORM_DATA_FIELD_MAP: dict[str, str] = {
+ "groupby": "groupby",
+ "sort_by_metric": "sort_by_metric",
+ "row_limit": "row_limit",
+ "min_val": "min_val",
+ "max_val": "max_val",
+ "color_scheme": "color_scheme",
+ "font_size": "font_size",
+ "number_format": "number_format",
+ "currency_format": "currency_format",
+ "value_formatter": "value_formatter",
+ "start_angle": "start_angle",
+ "end_angle": "end_angle",
+ "show_pointer": "show_pointer",
+ "animation": "animation",
+ "show_axis_tick": "show_axis_tick",
+ "show_split_line": "show_split_line",
+ "split_number": "split_number",
+ "show_progress": "show_progress",
+ "overlap": "overlap",
+ "round_cap": "round_cap",
+ "intervals": "intervals",
+ "interval_color_indices": "interval_color_indices",
+ "time_range": "time_range",
+ "granularity_sqla": "granularity_sqla",
+}
+
+_GAUGE_PRESENTATION_FORM_DATA_KEYS = frozenset(
+ {
+ "min_val",
+ "max_val",
+ "color_scheme",
+ "font_size",
+ "number_format",
+ "currency_format",
+ "value_formatter",
+ "start_angle",
+ "end_angle",
+ "show_pointer",
+ "animation",
+ "show_axis_tick",
+ "show_split_line",
+ "split_number",
+ "show_progress",
+ "overlap",
+ "round_cap",
+ "intervals",
+ "interval_color_indices",
+ }
+)
+
+
+def _without_generated_gauge_time_filter(
+ form_data: dict[str, Any],
+) -> list[Any]:
+ """Return cached filters without the mapper-owned temporal binding."""
+ generated_subject = form_data.get(MCP_DASHBOARD_TIME_FILTER_SUBJECT)
+ return [
+ filter_
+ for filter_ in form_data.get("adhoc_filters", [])
+ if not (
+ generated_subject
+ and isinstance(filter_, dict)
+ and filter_.get("operator") == FilterOperator.TEMPORAL_RANGE.value
+ and filter_.get("subject") == generated_subject
+ and filter_.get("comparator") == NO_TIME_RANGE
+ and filter_.get("clause") == "WHERE"
+ and filter_.get("expressionType") == "SIMPLE"
+ )
+ ]
+
+
+def merge_chart_form_data( # noqa: C901
+ existing_form_data: dict[str, Any],
+ new_form_data: dict[str, Any],
+ config: ChartConfig,
+ *,
+ dataset_rebind: bool = False,
+) -> dict[str, Any]:
+ """Merge update form_data while preserving omitted same-viz controls.
+
+ A viz-type change never inherits old controls. Dataset rebinds similarly
+ drop query roles and filters; Gauge presentation controls remain safe to
+ preserve because they do not reference the old dataset.
+ """
+ if existing_form_data.get("viz_type") != new_form_data.get("viz_type"):
+ return dict(new_form_data)
+ if not isinstance(config, GaugeChartConfig):
+ if dataset_rebind:
+ return dict(new_form_data)
+ merged = {**existing_form_data, **new_form_data}
+ if getattr(config, "filters", None) == []:
+ merged.pop("adhoc_filters", None)
+ return merged
+
+ fields_set = config.model_fields_set
+ if dataset_rebind:
+ merged = {
+ key: value
+ for key, value in existing_form_data.items()
+ if key in _GAUGE_PRESENTATION_FORM_DATA_KEYS
+ }
+ else:
+ merged = dict(existing_form_data)
+
+ patch = dict(new_form_data)
+ for config_field, form_data_field in _GAUGE_FORM_DATA_FIELD_MAP.items():
+ if config_field not in fields_set:
+ patch.pop(form_data_field, None)
+
+ filters_explicit = "filters" in fields_set
+ temporal_explicit = "temporal_column" in fields_set
+ if not filters_explicit:
+ if temporal_explicit:
+ preserved_filters = (
+ []
+ if dataset_rebind
+ else _without_generated_gauge_time_filter(existing_form_data)
+ )
+ generated_filters = patch.get("adhoc_filters", [])
+ patch["adhoc_filters"] = [*preserved_filters, *generated_filters]
+ if config.temporal_column is None:
+ patch.pop(MCP_DASHBOARD_TIME_FILTER_SUBJECT, None)
+ else:
+ patch.pop("adhoc_filters", None)
+ patch.pop(MCP_DASHBOARD_TIME_FILTER_SUBJECT, None)
+
+ merged.update(patch)
+ if filters_explicit:
+ if config.filters == [] and not (temporal_explicit and config.temporal_column):
+ merged.pop("adhoc_filters", None)
+ if MCP_DASHBOARD_TIME_FILTER_SUBJECT not in patch:
+ merged.pop(MCP_DASHBOARD_TIME_FILTER_SUBJECT, None)
+ for nullable_field in (
+ "color_scheme",
+ "currency_format",
+ "time_range",
+ "granularity_sqla",
+ ):
+ if nullable_field in fields_set and getattr(config, nullable_field) is None:
+ merged.pop(_GAUGE_FORM_DATA_FIELD_MAP[nullable_field], None)
+ if temporal_explicit and config.temporal_column is None:
+ merged.pop(MCP_DASHBOARD_TIME_FILTER_SUBJECT, None)
+ if subject := merged.get(MCP_DASHBOARD_TIME_FILTER_SUBJECT):
+ seen_binding = False
+ filters = []
+ for filter_ in merged.get("adhoc_filters", []):
+ is_binding = (
+ isinstance(filter_, dict)
+ and filter_.get("subject") == subject
+ and filter_.get("operator") == FilterOperator.TEMPORAL_RANGE.value
+ and filter_.get("comparator") == NO_TIME_RANGE
+ and filter_.get("clause") == "WHERE"
+ and filter_.get("expressionType") == "SIMPLE"
+ )
+ if not is_binding or not seen_binding:
+ filters.append(filter_)
+ seen_binding = seen_binding or is_binding
+ merged["adhoc_filters"] = filters
+ return merged
+
+
def create_metric_object(col: ColumnRef) -> Dict[str, Any] | str:
"""Create a metric object for a column with enhanced validation.
@@ -1062,7 +1224,27 @@ def map_gauge_config(config: GaugeChartConfig) -> Dict[str, Any]:
"min_val": config.min_val,
"max_val": config.max_val,
"color_scheme": config.color_scheme or "supersetColors",
+ "font_size": config.font_size,
+ "number_format": config.number_format,
+ "value_formatter": config.value_formatter,
+ "start_angle": config.start_angle,
+ "end_angle": config.end_angle,
+ "show_pointer": config.show_pointer,
+ "animation": config.animation,
+ "show_axis_tick": config.show_axis_tick,
+ "show_split_line": config.show_split_line,
+ "split_number": config.split_number,
+ "show_progress": config.show_progress,
+ "overlap": config.overlap,
+ "round_cap": config.round_cap,
+ "intervals": config.intervals,
+ "interval_color_indices": config.interval_color_indices,
}
+ if config.time_range is not None:
+ form_data["time_range"] = config.time_range
+ if config.granularity_sqla is not None:
+ form_data["granularity_sqla"] = config.granularity_sqla
+ add_currency_format(form_data, config.currency_format)
_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 062be8ef6cb..f4f3e80728e 100644
--- a/superset/mcp_service/chart/compile.py
+++ b/superset/mcp_service/chart/compile.py
@@ -33,6 +33,7 @@ tier(s) appropriate for its SLA.
from __future__ import annotations
import logging
+from copy import deepcopy
from dataclasses import dataclass, field
from typing import Any, Dict, List, Literal
@@ -40,9 +41,16 @@ 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,
+ query_result_failure,
+)
+from superset.mcp_service.chart.schemas import ChartError
from superset.mcp_service.chart.validation.dataset_validator import (
+ AmbiguousDatasetReferenceError,
build_dataset_context_from_orm,
DatasetValidator,
+ resolve_dataset_reference,
)
from superset.mcp_service.common.error_schemas import (
ChartGenerationError,
@@ -116,39 +124,21 @@ def _compile_chart(
ChartDataCacheLoadError,
ChartDataQueryFailedError,
)
- from superset.common.query_context_factory import QueryContextFactory
- from superset.mcp_service.chart.chart_utils import adhoc_filters_to_query_filters
- from superset.mcp_service.chart.preview_utils import _build_query_columns
+ from superset.mcp_service.chart.chart_helpers import (
+ build_query_context_from_form_data,
+ )
try:
- columns = _build_query_columns(form_data)
- query_filters = adhoc_filters_to_query_filters(
- form_data.get("adhoc_filters", [])
- )
-
- # Big Number charts use singular "metric" instead of "metrics"
- metrics = form_data.get("metrics", [])
- if not metrics and form_data.get("metric"):
- metrics = [form_data["metric"]]
-
- # Big Number with trendline uses granularity_sqla as the time column
- if not columns and form_data.get("granularity_sqla"):
- columns = [form_data["granularity_sqla"]]
-
- factory = QueryContextFactory()
- query_context = factory.create(
- datasource={"id": dataset_id, "type": "table"},
- queries=[
- {
- "columns": columns,
- "metrics": metrics,
- "orderby": form_data.get("orderby", []),
- "row_limit": 2,
- "filters": query_filters,
- "time_range": form_data.get("time_range", "No filter"),
- }
- ],
- form_data=form_data,
+ query_form_data = deepcopy(form_data)
+ query_form_data["datasource"] = f"{dataset_id}__table"
+ query_form_data["datasource_id"] = dataset_id
+ query_form_data["datasource_type"] = "table"
+ 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"
+ else 2,
+ force=False,
)
command = ChartDataCommand(query_context)
@@ -157,16 +147,34 @@ def _compile_chart(
warnings: List[str] = []
row_count = 0
+ if query_failure := query_result_failure(result):
+ error_str = query_failure.error
+ return CompileResult(
+ success=False,
+ error=error_str,
+ error_code="CHART_COMPILE_FAILED",
+ tier="compile",
+ error_obj=_build_compile_error(error_str),
+ )
+ result = normalize_gauge_query_result(result, form_data)
+ if isinstance(result, ChartError):
+ return CompileResult(
+ success=False,
+ error=result.error,
+ error_code="INVALID_GAUGE_RESULT",
+ tier="compile",
+ error_obj=ChartGenerationError(
+ error_type=result.error_type,
+ message="Gauge metric query returned invalid values",
+ details=result.error,
+ suggestions=[
+ "Use a numeric-producing metric",
+ "Check the metric alias and SQL expression",
+ ],
+ error_code="INVALID_GAUGE_RESULT",
+ ),
+ )
for query in result.get("queries", []):
- if query.get("error"):
- error_str = str(query["error"])
- return CompileResult(
- success=False,
- error=error_str,
- error_code="CHART_COMPILE_FAILED",
- tier="compile",
- error_obj=_build_compile_error(error_str),
- )
row_count += len(query.get("data", []))
return CompileResult(success=True, warnings=warnings, row_count=row_count)
@@ -224,9 +232,13 @@ def _adhoc_filter_column_valid(
"""
if clause == "HAVING":
return DatasetValidator._column_exists(column, dataset_context)
- return any(
- col["name"].lower() == column.lower()
- for col in dataset_context.available_columns
+ return (
+ resolve_dataset_reference(
+ column,
+ (col["name"] for col in dataset_context.available_columns),
+ "physical column",
+ )
+ is not None
)
@@ -254,8 +266,11 @@ def _validate_adhoc_filter_columns(
if not column or not isinstance(column, str):
continue
clause = f.get("clause", "WHERE").upper()
- if not _adhoc_filter_column_valid(column, clause, dataset_context):
- invalid.append(column)
+ try:
+ if not _adhoc_filter_column_valid(column, clause, dataset_context):
+ invalid.append(column)
+ except AmbiguousDatasetReferenceError as ex:
+ return DatasetValidator._build_ambiguous_reference_error(ex)
if not invalid:
return None
diff --git a/superset/mcp_service/chart/plugins/gauge.py b/superset/mcp_service/chart/plugins/gauge.py
index 723cd09ca20..97a2540bbf9 100644
--- a/superset/mcp_service/chart/plugins/gauge.py
+++ b/superset/mcp_service/chart/plugins/gauge.py
@@ -29,14 +29,17 @@ from superset.mcp_service.chart.chart_utils import (
)
from superset.mcp_service.chart.plugin import BaseChartPlugin
from superset.mcp_service.chart.schemas import ColumnRef, GaugeChartConfig
-from superset.mcp_service.chart.validation.dataset_validator import DatasetValidator
+from superset.mcp_service.chart.validation.dataset_validator import (
+ DatasetValidator,
+ is_numeric_column,
+)
from superset.mcp_service.common.error_schemas import ChartGenerationError
class GaugeChartPlugin(BaseChartPlugin):
"""Plugin for gauge chart type."""
- chart_type = "gauge_chart"
+ chart_type = "gauge"
display_name = "Gauge Chart"
native_viz_types: ClassVar[Mapping[str, str]] = {
"gauge_chart": "Gauge Chart",
@@ -56,7 +59,7 @@ class GaugeChartPlugin(BaseChartPlugin):
),
suggestions=[
"Add 'metric' field: {'name': 'progress', 'aggregate': 'AVG'}",
- "Example: {'chart_type': 'gauge_chart', "
+ "Example: {'chart_type': 'gauge', "
"'metric': {'name': 'progress', 'aggregate': 'AVG'}}",
],
error_code="MISSING_GAUGE_FIELDS",
@@ -69,6 +72,8 @@ class GaugeChartPlugin(BaseChartPlugin):
refs: list[ColumnRef] = [config.metric]
if config.groupby:
refs.extend(config.groupby)
+ 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))
@@ -87,8 +92,62 @@ class GaugeChartPlugin(BaseChartPlugin):
def resolve_viz_type(self, config: Any) -> str:
return "gauge_chart"
+ def post_map_validate(
+ self,
+ config: Any,
+ form_data: dict[str, Any],
+ dataset_id: int | str | None = None,
+ ) -> ChartGenerationError | None:
+ """Reject definitively non-numeric SIMPLE Gauge metrics.
+
+ Saved and SQL metrics remain supported because their output type is not
+ reliably inferable from metadata; compile/data result validation checks
+ their concrete values instead.
+ """
+ if not isinstance(config, GaugeChartConfig):
+ return None
+ metric = config.metric
+ if (
+ dataset_id is None
+ or metric.saved_metric
+ or metric.sql_expression
+ or metric.aggregate in {"COUNT", "COUNT_DISTINCT"}
+ or metric.name is None
+ ):
+ return None
+ dataset_context = DatasetValidator._get_dataset_context(dataset_id)
+ if dataset_context is None:
+ return None
+ column = next(
+ (
+ candidate
+ for candidate in dataset_context.available_columns
+ if candidate["name"] == metric.name
+ ),
+ None,
+ )
+ if column is None or is_numeric_column(column):
+ return None
+ type_name = str(column.get("type") or "UNKNOWN")
+ if type_name.strip().upper() in {"", "UNKNOWN"}:
+ return None
+ return ChartGenerationError(
+ error_type="non_numeric_gauge_metric",
+ message="Gauge metric must produce numeric values",
+ details=(
+ f"{metric.aggregate}({metric.name}) uses a non-numeric "
+ f"column of type {type_name}."
+ ),
+ suggestions=[
+ "Choose a numeric dataset column",
+ "Use COUNT or COUNT_DISTINCT for a categorical column",
+ "Use a saved or SQL metric that returns a numeric value",
+ ],
+ error_code="NON_NUMERIC_GAUGE_METRIC",
+ )
+
def normalize_column_refs(self, config: Any, dataset_context: Any) -> Any:
- config_dict = config.model_dump()
+ config_dict = config.model_dump(exclude_unset=True)
if config_dict.get("metric"):
if config_dict["metric"].get("sql_expression"):
@@ -110,6 +169,12 @@ class GaugeChartPlugin(BaseChartPlugin):
col["name"] = DatasetValidator.get_canonical_column_name(
col["name"], dataset_context
)
+ if config_dict.get("granularity_sqla"):
+ config_dict["granularity_sqla"] = (
+ DatasetValidator.get_canonical_column_name(
+ config_dict["granularity_sqla"], dataset_context
+ )
+ )
DatasetValidator.normalize_filters(config_dict, dataset_context)
return GaugeChartConfig.model_validate(config_dict)
@@ -123,7 +188,7 @@ class GaugeChartPlugin(BaseChartPlugin):
),
suggestions=[
"Ensure 'metric' field has 'name' and 'aggregate'",
- "Example: {'chart_type': 'gauge_chart', "
+ "Example: {'chart_type': 'gauge', "
"'metric': {'name': 'progress', 'aggregate': 'AVG'}}",
],
error_code="GAUGE_VALIDATION_ERROR",
diff --git a/superset/mcp_service/chart/preview_utils.py b/superset/mcp_service/chart/preview_utils.py
index a9e99e814e4..09f19c6439a 100644
--- a/superset/mcp_service/chart/preview_utils.py
+++ b/superset/mcp_service/chart/preview_utils.py
@@ -24,8 +24,14 @@ from form data without requiring a saved chart object.
import logging
import math
+from copy import deepcopy
from typing import Any, Dict, List
+from superset.mcp_service.chart.query_result import (
+ metric_result_label,
+ normalize_gauge_query_result,
+ query_result_failure,
+)
from superset.mcp_service.chart.schemas import (
ASCIIPreview,
ChartError,
@@ -75,43 +81,18 @@ def generate_preview_from_form_data(
error=f"Dataset {dataset_id} not found", error_type="DatasetNotFound"
)
- # Create query context from form data using factory
- from superset.common.query_context_factory import QueryContextFactory
- from superset.mcp_service.chart.chart_utils import (
- adhoc_filters_to_query_filters,
- )
-
- # Build columns list: include x_axis and groupby for XY charts,
- # fall back to form_data "columns" for table charts
- columns = _build_query_columns(form_data)
-
- query_filters = adhoc_filters_to_query_filters(
- form_data.get("adhoc_filters", [])
+ from superset.mcp_service.chart.chart_helpers import (
+ build_query_context_from_form_data,
)
- # Big Number charts use singular "metric" instead of "metrics"
- metrics = form_data.get("metrics", [])
- if not metrics and form_data.get("metric"):
- metrics = [form_data["metric"]]
-
- # Big Number with trendline uses granularity_sqla as the time column
- if not columns and form_data.get("granularity_sqla"):
- columns = [form_data["granularity_sqla"]]
-
- factory = QueryContextFactory()
- query_context_obj = factory.create(
- datasource={"id": dataset_id, "type": "table"},
- queries=[
- {
- "columns": columns,
- "metrics": metrics,
- "orderby": form_data.get("orderby", []),
- "row_limit": form_data.get("row_limit", 100),
- "filters": query_filters,
- "time_range": form_data.get("time_range", "No filter"),
- }
- ],
- form_data=form_data,
+ query_form_data = deepcopy(form_data)
+ query_form_data["datasource"] = f"{dataset_id}__table"
+ query_form_data["datasource_id"] = dataset_id
+ query_form_data["datasource_type"] = "table"
+ query_context_obj = build_query_context_from_form_data(
+ query_form_data,
+ row_limit=form_data.get("row_limit", 100),
+ force=False,
)
# Execute query
@@ -119,6 +100,11 @@ def generate_preview_from_form_data(
command.validate()
result = command.run()
+ if query_failure := query_result_failure(result):
+ return query_failure
+ result = normalize_gauge_query_result(result, form_data)
+ if isinstance(result, ChartError):
+ return result
if not result or not result.get("queries"):
return ChartError(
error="No data returned from query", error_type="EmptyResult"
@@ -149,12 +135,17 @@ def generate_preview_from_form_data(
def _generate_ascii_preview_from_data(
data: List[Dict[str, Any]], form_data: Dict[str, Any]
-) -> ASCIIPreview:
+) -> ASCIIPreview | ChartError:
"""Generate ASCII preview from raw data."""
viz_type = form_data.get("viz_type", "table")
# Handle different chart types
- if viz_type in ["bar", "dist_bar", "column"]:
+ if viz_type == "gauge_chart":
+ content_or_error = generate_gauge_ascii_preview(data, form_data)
+ if isinstance(content_or_error, ChartError):
+ return content_or_error
+ content = content_or_error
+ elif viz_type in ["bar", "dist_bar", "column"]:
content = _generate_safe_ascii_bar_chart(data)
elif viz_type in ["line", "area"]:
content = _generate_safe_ascii_line_chart(data)
@@ -469,11 +460,481 @@ def _is_nan(value: Any) -> bool:
return False
+_GAUGE_COLORS = (
+ "#1FA8C9",
+ "#454E7C",
+ "#5AC189",
+ "#FF7F44",
+ "#666666",
+ "#E04355",
+ "#FCC700",
+ "#A868B7",
+)
+
+
+def _gauge_column_label(column: Any) -> str | None:
+ """Resolve the result label for a native QueryFormColumn."""
+ if isinstance(column, str) and column:
+ return column
+ if not isinstance(column, dict) or not 0 < len(column) <= 20:
+ return None
+ label = (
+ column.get("label")
+ or column.get("sqlExpression")
+ or column.get("column_name")
+ or column.get("columnName")
+ )
+ return label if isinstance(label, str) and label else None
+
+
+def _parse_gauge_number_list(value: Any, field_name: str) -> list[float]:
+ """Parse a bounded comma-separated Gauge control."""
+ if value in (None, ""):
+ return []
+ if not isinstance(value, str) or len(value) > 1000:
+ raise ValueError(f"Gauge {field_name} must be a bounded string")
+ try:
+ parsed = [float(part.strip()) for part in value.split(",")]
+ except ValueError as ex:
+ raise ValueError(
+ f"Gauge {field_name} must be a comma-separated list of numbers"
+ ) from ex
+ if not all(math.isfinite(number) for number in parsed):
+ raise ValueError(f"Gauge {field_name} values must be finite")
+ return parsed
+
+
+def _prepare_gauge_preview( # noqa: C901
+ data: Any, form_data: Dict[str, Any]
+) -> tuple[list[dict[str, Any]], dict[str, Any]] | ChartError:
+ """Validate Gauge rows and derive display values used by both previews."""
+ normalized = normalize_gauge_query_result(
+ {"queries": [{"data": data}]}, {**form_data, "viz_type": "gauge_chart"}
+ )
+ if isinstance(normalized, ChartError):
+ return normalized
+ data = normalized["queries"][0]["data"]
+ metric_label = metric_result_label(form_data.get("metric"))
+ if not isinstance(data, list) or metric_label is None:
+ return ChartError(
+ error="Gauge preview requires rows and a metric result label.",
+ error_type="InvalidGaugeFormData",
+ )
+
+ raw_groupby = form_data.get("groupby") or []
+ if isinstance(raw_groupby, str):
+ raw_groupby = [raw_groupby]
+ if not isinstance(raw_groupby, list) or len(raw_groupby) > 10:
+ return ChartError(
+ error="Gauge groupby must contain at most 10 column references.",
+ error_type="InvalidGaugeFormData",
+ )
+ group_labels: list[str] = []
+ for index, column in enumerate(raw_groupby):
+ label = _gauge_column_label(column)
+ if label is None:
+ return ChartError(
+ error=f"Gauge groupby[{index}] has no resolvable result label.",
+ error_type="InvalidGaugeFormData",
+ )
+ group_labels.append(label)
+
+ values = [float(row[metric_label]) for row in data]
+
+ def numeric_bound(field_name: str) -> float | None:
+ value = form_data.get(field_name)
+ if value in (None, ""):
+ return None
+ if isinstance(value, bool) or not isinstance(value, (int, float, str)):
+ raise ValueError(f"Gauge {field_name} must be numeric")
+ try:
+ converted = float(value)
+ except ValueError as ex:
+ raise ValueError(f"Gauge {field_name} must be numeric") from ex
+ if not math.isfinite(converted):
+ raise ValueError(f"Gauge {field_name} must be finite")
+ return converted
+
+ try:
+ minimum = numeric_bound("min_val")
+ maximum = numeric_bound("max_val")
+ interval_bounds = _parse_gauge_number_list(
+ form_data.get("intervals", ""), "intervals"
+ )
+ color_indices = _parse_gauge_number_list(
+ form_data.get("interval_color_indices", ""),
+ "interval_color_indices",
+ )
+ except ValueError as ex:
+ return ChartError(error=str(ex), error_type="InvalidGaugeFormData")
+
+ configured_minimum, configured_maximum = minimum, maximum
+
+ # Match transformProps auto-range semantics: twice the extrema including 0.
+ if minimum is None:
+ minimum = 2 * min([*values, 0]) if values else 0
+ if maximum is None:
+ maximum = 2 * max([*values, 0]) if values else 1
+ if configured_minimum is None and configured_maximum is None and minimum == maximum:
+ maximum = minimum + 1
+ if minimum >= maximum:
+ return ChartError(
+ error=(
+ f"Gauge preview range is invalid: min_val {minimum:g} must be "
+ f"less than max_val {maximum:g}."
+ ),
+ error_type="InvalidGaugeRange",
+ )
+ if any(
+ left >= right
+ for left, right in zip(interval_bounds, interval_bounds[1:], strict=False)
+ ):
+ return ChartError(
+ error="Gauge intervals must be strictly increasing.",
+ error_type="InvalidGaugeFormData",
+ )
+ if any(
+ (configured_minimum is not None and bound <= configured_minimum)
+ or (configured_maximum is not None and bound > configured_maximum)
+ for bound in interval_bounds
+ ):
+ return ChartError(
+ error="Gauge intervals must fall within the configured min/max range.",
+ error_type="InvalidGaugeFormData",
+ )
+ if color_indices and len(color_indices) != len(interval_bounds):
+ return ChartError(
+ error="Gauge interval colors must match the number of interval bounds.",
+ error_type="InvalidGaugeFormData",
+ )
+ if any(index < 1 or not index.is_integer() for index in color_indices):
+ return ChartError(
+ error="Gauge interval color indices must be positive integers.",
+ error_type="InvalidGaugeFormData",
+ )
+
+ currency = form_data.get("currency_format")
+ if currency is not None and not isinstance(currency, dict):
+ return ChartError(
+ error="Gauge currency_format must be an object.",
+ error_type="InvalidGaugeFormData",
+ )
+ number_format = form_data.get("number_format", "SMART_NUMBER")
+ value_formatter = form_data.get("value_formatter", "{value}")
+ if not isinstance(number_format, str) or not isinstance(value_formatter, str):
+ return ChartError(
+ error="Gauge number_format and value_formatter must be strings.",
+ error_type="InvalidGaugeFormData",
+ )
+ from superset.utils.number_format import format_number_with_config
+
+ decorated: list[dict[str, Any]] = []
+ for row_index, row in enumerate(data):
+ value = float(row[metric_label])
+ group = (
+ ", ".join(f"{label}: {row.get(label)}" for label in group_labels) or "Value"
+ )
+ formatted = format_number_with_config(number_format, currency, value)
+ decorated.append(
+ {
+ **row,
+ "__mcp_gauge_group": group,
+ "__mcp_gauge_ratio": (value - minimum) / (maximum - minimum),
+ "__mcp_gauge_display": value_formatter.replace(
+ "{value}", str(formatted), 1
+ ),
+ "__mcp_gauge_row": row_index,
+ }
+ )
+
+ colors = [
+ _GAUGE_COLORS[(int(index) - 1) % len(_GAUGE_COLORS)] for index in color_indices
+ ] or list(_GAUGE_COLORS[: max(1, len(interval_bounds))])
+ return decorated, {
+ "metric_label": metric_label,
+ "group_labels": group_labels,
+ "minimum": minimum,
+ "maximum": maximum,
+ "interval_bounds": interval_bounds,
+ "interval_colors": colors,
+ }
+
+
+def generate_gauge_ascii_preview(
+ data: Any, form_data: Dict[str, Any], width: int = 80
+) -> str | ChartError:
+ """Render a bounded dial-like ASCII Gauge using the configured value format."""
+ prepared = _prepare_gauge_preview(data, form_data)
+ if isinstance(prepared, ChartError):
+ return prepared
+ decorated, metadata = prepared
+ minimum = metadata["minimum"]
+ maximum = metadata["maximum"]
+ lines = [
+ "Gauge Chart",
+ f"Range: {minimum:g} to {maximum:g}",
+ ]
+ if metadata["interval_bounds"]:
+ lines.append(
+ "Intervals: "
+ + ", ".join(f"{value:g}" for value in metadata["interval_bounds"])
+ )
+ if not decorated:
+ lines.append("No data available")
+ return "\n".join(lines)
+
+ width = min(max(width, 40), 200)
+ bar_width = max(10, min(40, width - 38))
+ for row in decorated[:10]:
+ ratio = min(1.0, max(0.0, row["__mcp_gauge_ratio"]))
+ filled = round(ratio * bar_width)
+ bar = "█" * filled + "░" * (bar_width - filled)
+ label = str(row["__mcp_gauge_group"])
+ lines.append(f"{label[:20]:>20} [{bar}] {row['__mcp_gauge_display']}")
+ return "\n".join(lines)
+
+
+def generate_gauge_vega_lite_preview( # noqa: C901
+ data: Any, form_data: Dict[str, Any]
+) -> VegaLitePreview | ChartError:
+ """Build a Gauge-specific layered radial Vega-Lite preview."""
+ prepared = _prepare_gauge_preview(data, form_data)
+ if isinstance(prepared, ChartError):
+ return prepared
+ decorated, metadata = prepared
+ start_angle = form_data.get("start_angle", 225)
+ end_angle = form_data.get("end_angle", -45)
+
+ def finite_angle(value: Any) -> float | None:
+ if isinstance(value, bool) or not isinstance(value, (int, float, str)):
+ return None
+ try:
+ converted = float(value)
+ except ValueError:
+ return None
+ return converted if math.isfinite(converted) else None
+
+ numeric_start_angle = finite_angle(start_angle)
+ numeric_end_angle = finite_angle(end_angle)
+ if numeric_start_angle is None or numeric_end_angle is None:
+ return ChartError(
+ error="Gauge start_angle and end_angle must be finite numbers.",
+ error_type="InvalidGaugeFormData",
+ )
+
+ angle_range = [math.radians(numeric_start_angle), math.radians(numeric_end_angle)]
+ theta_scale = {"domain": [0, 1], "range": angle_range}
+ metric_label = metadata["metric_label"]
+ tooltip = [
+ {"field": field, "type": "nominal"} for field in metadata["group_labels"]
+ ]
+ tooltip.extend(
+ [
+ {"field": metric_label, "type": "quantitative"},
+ {"field": "__mcp_gauge_display", "type": "nominal", "title": "Value"},
+ ]
+ )
+ progress_color: dict[str, Any]
+ interval_bounds = metadata["interval_bounds"]
+ if interval_bounds:
+ progress_color = {
+ "field": metric_label,
+ "type": "quantitative",
+ "scale": {
+ "type": "threshold",
+ "domain": interval_bounds[:-1],
+ "range": metadata["interval_colors"],
+ },
+ "legend": None,
+ }
+ elif metadata["group_labels"]:
+ progress_color = {
+ "field": "__mcp_gauge_group",
+ "type": "nominal",
+ "legend": None,
+ }
+ else:
+ progress_color = {"value": _GAUGE_COLORS[0]}
+
+ inner_radius, outer_radius = 55, 82
+ pointer_radius = (inner_radius + outer_radius) / 2
+ background_layers: list[dict[str, Any]] = []
+ previous_ratio = 0.0
+ for bound, color in zip(interval_bounds, metadata["interval_colors"], strict=False):
+ # Automatic bounds can put configured thresholds outside the visible dial.
+ ratio = min(
+ 1.0,
+ max(
+ 0.0,
+ (bound - metadata["minimum"])
+ / (metadata["maximum"] - metadata["minimum"]),
+ ),
+ )
+ if ratio <= previous_ratio:
+ continue
+ background_layers.append(
+ {
+ "mark": {
+ "type": "arc",
+ "innerRadius": inner_radius,
+ "outerRadius": outer_radius,
+ "color": color,
+ "opacity": 0.35,
+ },
+ "encoding": {
+ "theta": {
+ "datum": previous_ratio,
+ "type": "quantitative",
+ "scale": theta_scale,
+ "stack": None,
+ },
+ "theta2": {"datum": ratio},
+ },
+ }
+ )
+ previous_ratio = ratio
+ if not background_layers:
+ background_layers.append(
+ {
+ "mark": {
+ "type": "arc",
+ "innerRadius": inner_radius,
+ "outerRadius": outer_radius,
+ "color": "#D3D3D3",
+ },
+ "encoding": {
+ "theta": {
+ "datum": 1,
+ "type": "quantitative",
+ "scale": theta_scale,
+ "stack": None,
+ },
+ "theta2": {"datum": 0},
+ },
+ }
+ )
+
+ progress_layer = {
+ "mark": {
+ "type": "arc",
+ "innerRadius": inner_radius,
+ "outerRadius": outer_radius,
+ "cornerRadius": 4 if form_data.get("round_cap") else 0,
+ "tooltip": True,
+ },
+ "encoding": {
+ "theta": {
+ "field": "__mcp_gauge_ratio",
+ "type": "quantitative",
+ "scale": theta_scale,
+ "stack": None,
+ },
+ "theta2": {"datum": 0},
+ "color": progress_color,
+ "tooltip": tooltip,
+ },
+ }
+ facet_width, dial_height = 200, 300
+ # Faceted value expressions cannot use the outer width/height signals.
+ center_x = str(facet_width / 2) if metadata["group_labels"] else "width / 2"
+ center_y = str(dial_height / 2) if metadata["group_labels"] else "height / 2"
+ pointer_layer = {
+ "transform": [
+ {
+ "calculate": (
+ f"{angle_range[0]} + datum.__mcp_gauge_ratio * "
+ f"({angle_range[1]} - {angle_range[0]})"
+ ),
+ "as": "__mcp_gauge_angle",
+ },
+ ],
+ "mark": {"type": "rule", "strokeWidth": 3, "color": "#444"},
+ "encoding": {
+ "x": {"value": {"expr": center_x}},
+ "y": {"value": {"expr": center_y}},
+ "x2": {
+ "value": {
+ "expr": (
+ f"{center_x} + {pointer_radius} * sin(datum.__mcp_gauge_angle)"
+ )
+ }
+ },
+ "y2": {
+ "value": {
+ "expr": (
+ f"{center_y} - {pointer_radius} * cos(datum.__mcp_gauge_angle)"
+ )
+ }
+ },
+ "tooltip": tooltip,
+ },
+ }
+ text_layer = {
+ "mark": {"type": "text", "fontSize": form_data.get("font_size", 15)},
+ "encoding": {"text": {"field": "__mcp_gauge_display", "type": "nominal"}},
+ }
+ layers: list[dict[str, Any]] = [*background_layers]
+ if form_data.get("show_progress", True):
+ layers.append(progress_layer)
+ if form_data.get("show_pointer", True):
+ layers.append(pointer_layer)
+ layers.append(text_layer)
+ unit_spec: dict[str, Any] = {"layer": layers}
+ specification: dict[str, Any] = {
+ "$schema": "https://vega.github.io/schema/vega-lite/v5.json",
+ "data": {"values": decorated},
+ "width": "container",
+ "height": dial_height,
+ "usermeta": {
+ "viz_type": "gauge_chart",
+ "min_val": metadata["minimum"],
+ "max_val": metadata["maximum"],
+ "intervals": interval_bounds,
+ "interval_color_indices": form_data.get("interval_color_indices", ""),
+ "color_scheme": form_data.get("color_scheme"),
+ "show_pointer": form_data.get("show_pointer", True),
+ "show_progress": form_data.get("show_progress", True),
+ "show_axis_tick": form_data.get("show_axis_tick", False),
+ "show_split_line": form_data.get("show_split_line", False),
+ "split_number": form_data.get("split_number", 10),
+ "overlap": form_data.get("overlap", True),
+ "round_cap": form_data.get("round_cap", False),
+ "animation": form_data.get("animation", True),
+ "font_size": form_data.get("font_size", 15),
+ "number_format": form_data.get("number_format", "SMART_NUMBER"),
+ "value_formatter": form_data.get("value_formatter", "{value}"),
+ },
+ }
+ if metadata["group_labels"]:
+ specification.pop("width")
+ unit_spec["width"] = facet_width
+ unit_spec["height"] = specification.pop("height")
+ specification.update(
+ {
+ "facet": {
+ "field": "__mcp_gauge_group",
+ "type": "nominal",
+ },
+ "columns": min(3, max(1, len(decorated))),
+ "spec": unit_spec,
+ }
+ )
+ else:
+ specification.update(unit_spec)
+ return VegaLitePreview(
+ specification=specification,
+ data_url=None,
+ supports_streaming=False,
+ )
+
+
def _generate_vega_lite_preview_from_data( # noqa: C901
data: List[Dict[str, Any]], form_data: Dict[str, Any]
-) -> VegaLitePreview:
+) -> VegaLitePreview | ChartError:
"""Generate Vega-Lite preview from raw data and form_data."""
viz_type = form_data.get("viz_type", "table")
+ if viz_type == "gauge_chart":
+ return generate_gauge_vega_lite_preview(data, form_data)
# Map Superset viz types to Vega-Lite marks
viz_to_mark = {
diff --git a/superset/mcp_service/chart/query_result.py b/superset/mcp_service/chart/query_result.py
new file mode 100644
index 00000000000..bc468be316a
--- /dev/null
+++ b/superset/mcp_service/chart/query_result.py
@@ -0,0 +1,225 @@
+# 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.
+
+"""Helpers for interpreting ChartDataCommand result envelopes."""
+
+import math
+from collections.abc import Mapping
+from typing import Any
+
+from superset.mcp_service.chart.schemas import ChartError
+
+FAILED_QUERY_STATUSES = frozenset(
+ {"error", "failed", "stopped", "timed_out", "cancelled", "canceled"}
+)
+
+
+def _query_error_text(value: Any) -> str | None:
+ """Convert a bounded query error payload into a useful message."""
+ if value is None or value is False:
+ return None
+ if isinstance(value, Mapping):
+ for key in ("error", "error_message", "message", "detail"):
+ if text := _query_error_text(value.get(key)):
+ return text
+ return None
+ if isinstance(value, (list, tuple)):
+ parts = [text for item in value if (text := _query_error_text(item))]
+ return "; ".join(parts[:3]) or None
+ text = str(value)
+ return text[:2000] if text else None
+
+
+def _failure_for_query_payload(
+ payload: Mapping[str, Any], label: str
+) -> ChartError | None:
+ """Extract one failure from a top-level or per-query payload."""
+ for key in ("error", "errors", "error_message"):
+ if message := _query_error_text(payload.get(key)):
+ return ChartError(
+ error=f"{label} failed: {message}", error_type="QueryError"
+ )
+
+ raw_status = payload.get("status")
+ status = str(getattr(raw_status, "value", raw_status) or "")
+ normalized_status = status.strip().casefold().replace("-", "_").replace(" ", "_")
+ if normalized_status in FAILED_QUERY_STATUSES:
+ message = (
+ _query_error_text(payload.get("message"))
+ or _query_error_text(payload.get("error_message"))
+ or normalized_status
+ )
+ return ChartError(error=f"{label} failed: {message}", error_type="QueryError")
+ if payload.get("success") is False:
+ message = _query_error_text(payload.get("message")) or "request failed"
+ return ChartError(error=f"{label} failed: {message}", error_type="QueryError")
+ if (
+ raw_status is None
+ and "data" not in payload
+ and "queries" not in payload
+ and (message := _query_error_text(payload.get("message")))
+ ):
+ return ChartError(error=f"{label} failed: {message}", error_type="QueryError")
+ return None
+
+
+def query_result_failure(result: Any) -> ChartError | None:
+ """Return a structured failure embedded in a ChartDataCommand payload."""
+ if not isinstance(result, Mapping):
+ return None
+
+ if failure := _failure_for_query_payload(result, "Chart query"):
+ return failure
+ queries = result.get("queries")
+ if isinstance(queries, list):
+ for index, query in enumerate(queries, start=1):
+ if isinstance(query, Mapping) and (
+ failure := _failure_for_query_payload(query, f"Chart query {index}")
+ ):
+ return failure
+ return None
+
+
+def metric_result_label(metric: Any) -> str | None:
+ """Resolve the query-result key using frontend ``getMetricLabel`` rules."""
+ if isinstance(metric, str) and metric:
+ return metric
+ if not isinstance(metric, Mapping):
+ return None
+ label = metric.get("label")
+ if isinstance(label, str) and label:
+ return label
+ if label not in (None, ""):
+ return None
+ expression_type = metric.get("expressionType")
+ if expression_type == "SIMPLE":
+ aggregate = metric.get("aggregate")
+ column = metric.get("column")
+ if not isinstance(aggregate, str) or not isinstance(column, Mapping):
+ return None
+ column_name = column.get("columnName") or column.get("column_name")
+ if isinstance(column_name, str) and column_name:
+ return f"{aggregate}({column_name})"
+ return None
+ if expression_type == "SQL":
+ sql_expression = metric.get("sqlExpression")
+ if isinstance(sql_expression, str) and sql_expression:
+ return sql_expression
+ return None
+
+
+def normalize_gauge_query_result( # noqa: C901
+ result: Any, form_data: Mapping[str, Any]
+) -> Any:
+ """Copy Gauge query envelopes with only finite dials; preserve empty results.
+
+ Malformed envelopes/aliases remain errors. Invalid dial values are skipped,
+ but a nonempty query with no finite dials returns an actionable error.
+ Non-Gauge results and the caller's query payloads are left untouched.
+ """
+ if form_data.get("viz_type") != "gauge_chart":
+ return result
+ if failure := query_result_failure(result):
+ return failure
+
+ metric_label = metric_result_label(form_data.get("metric"))
+ if metric_label is None:
+ return ChartError(
+ error="Gauge form_data has no resolvable metric result label.",
+ error_type="InvalidGaugeFormData",
+ )
+ if not isinstance(result, Mapping):
+ return ChartError(
+ error="Gauge query result is not an object.",
+ error_type="InvalidGaugeResult",
+ )
+ queries = result.get("queries")
+ if not isinstance(queries, list):
+ return ChartError(
+ error="Gauge query result has no queries array.",
+ error_type="InvalidGaugeResult",
+ )
+ normalized_queries = []
+ for query_index, query in enumerate(queries):
+ if not isinstance(query, Mapping):
+ return ChartError(
+ error=f"Gauge query {query_index} is not an object.",
+ error_type="InvalidGaugeResult",
+ )
+ data = query.get("data", [])
+ if not isinstance(data, list):
+ return ChartError(
+ error=f"Gauge query {query_index} data is not an array of rows.",
+ error_type="InvalidGaugeResult",
+ )
+ finite_rows = []
+ value_error = None
+ for row_index, row in enumerate(data):
+ if not isinstance(row, Mapping):
+ return ChartError(
+ error=(
+ f"Gauge query {query_index} row {row_index} is not an object."
+ ),
+ error_type="InvalidGaugeResult",
+ )
+ if metric_label not in row:
+ return ChartError(
+ error=(
+ f"Gauge query {query_index} row {row_index} is missing "
+ f"metric output {metric_label!r}."
+ ),
+ error_type="InvalidGaugeResult",
+ )
+ value = row[metric_label]
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
+ value_error = value_error or ChartError(
+ error=(
+ f"Gauge query {query_index} row {row_index} metric "
+ f"{metric_label!r} is not numeric."
+ ),
+ error_type="NonNumericGaugeMetric",
+ )
+ continue
+ try:
+ finite = math.isfinite(float(value))
+ except (OverflowError, ValueError):
+ finite = False
+ if not finite:
+ value_error = value_error or ChartError(
+ error=(
+ f"Gauge query {query_index} row {row_index} metric "
+ f"{metric_label!r} is not finite."
+ ),
+ error_type="NonFiniteGaugeMetric",
+ )
+ continue
+ finite_rows.append(row)
+ if data and not finite_rows:
+ return value_error
+ normalized_query = {**query, "data": finite_rows}
+ if len(finite_rows) != len(data) and "rowcount" in query:
+ normalized_query["rowcount"] = len(finite_rows)
+ normalized_queries.append(normalized_query)
+ return {**result, "queries": normalized_queries}
+
+
+def validate_gauge_query_result(
+ result: Any, form_data: Mapping[str, Any]
+) -> ChartError | None:
+ """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
diff --git a/superset/mcp_service/chart/resources/chart_configs.py b/superset/mcp_service/chart/resources/chart_configs.py
index 81cac8f8080..c76046ae97a 100644
--- a/superset/mcp_service/chart/resources/chart_configs.py
+++ b/superset/mcp_service/chart/resources/chart_configs.py
@@ -262,6 +262,24 @@ def get_chart_configs_resource() -> str:
],
}
+ # Gauge configs
+ gauge_configs = {
+ "attainment_gauge": {
+ "description": "Gauge with a target range and formatted value",
+ "config": {
+ "chart_type": "gauge",
+ "metric": {"name": "attainment", "aggregate": "AVG"},
+ "min_val": 0,
+ "max_val": 100,
+ "number_format": ".1f",
+ "value_formatter": "{value}%",
+ "intervals": "50,80,100",
+ "interval_color_indices": "6,7,3",
+ },
+ "use_cases": ["Target attainment", "SLA health", "Progress tracking"],
+ }
+ }
+
# Best practices
best_practices = {
"xy_charts": [
@@ -284,6 +302,11 @@ def get_chart_configs_resource() -> str:
"Pair comparison_period with comparison_type for time comparison",
"This type appears only when the host enables its visualization plugin",
],
+ "gauge_charts": [
+ "Use the public chart_type='gauge'; gauge_chart is the native viz_type",
+ "Use an aggregate, saved metric, or SQL metric that returns finite numbers",
+ "Set min_val/max_val and aligned intervals for a stable shared scale",
+ ],
"general": [
"Always verify column names with get_dataset_info before charting",
"Set temporal_column when dashboard time filters should use a column "
@@ -298,6 +321,7 @@ def get_chart_configs_resource() -> str:
"xy_chart_configs": xy_chart_configs,
"table_chart_configs": table_chart_configs,
"interactive_pivot_configs": interactive_pivot_configs,
+ "gauge_configs": gauge_configs,
"best_practices": best_practices,
"usage_notes": [
"All examples are valid ChartConfig objects that pass validation",
diff --git a/superset/mcp_service/chart/schemas.py b/superset/mcp_service/chart/schemas.py
index 740de721c63..46f0054af10 100644
--- a/superset/mcp_service/chart/schemas.py
+++ b/superset/mcp_service/chart/schemas.py
@@ -23,9 +23,10 @@ from __future__ import annotations
import difflib
import logging
+import math
import re
from datetime import datetime
-from typing import Annotated, Any, Dict, List, Literal, Protocol
+from typing import Annotated, Any, Dict, get_args, List, Literal, Protocol
from pydantic import (
AliasChoices,
@@ -41,7 +42,7 @@ from pydantic import (
)
from typing_extensions import Self
-from superset.constants import TimeGrain
+from superset.constants import NO_TIME_RANGE, TimeGrain
from superset.daos.base import ColumnOperator, ColumnOperatorEnum
from superset.mcp_service.common.cache_schemas import (
CacheStatus,
@@ -56,6 +57,7 @@ from superset.mcp_service.common.pagination_schemas import (
PaginatedListRequest,
PaginatedResponse,
)
+from superset.mcp_service.common.time_range_validation import validate_time_range
from superset.mcp_service.privacy import (
filter_user_directory_fields,
strip_user_directory_fields_from_schema,
@@ -1055,9 +1057,11 @@ class GaugeChartConfig(BaseChartConfig):
(both default to auto).
"""
- model_config = ConfigDict(extra="ignore", populate_by_name=True)
+ model_config = ConfigDict(
+ extra="ignore", populate_by_name=True, allow_inf_nan=False
+ )
- chart_type: Literal["gauge_chart"] = "gauge_chart"
+ chart_type: Literal["gauge"] = "gauge"
metric: ColumnRef = Field(
...,
description="Value metric the dial displays (use aggregate e.g. AVG, "
@@ -1093,10 +1097,200 @@ class GaugeChartConfig(BaseChartConfig):
),
max_length=100,
)
+ font_size: int = Field(15, description="Gauge text size", ge=10, le=20)
+ number_format: str = Field(
+ "SMART_NUMBER", description="D3 number format", max_length=50
+ )
+ currency_format: CurrencyFormat | None = Field(
+ None, description="Currency symbol applied to the gauge value"
+ )
+ value_formatter: str = Field(
+ "{value}",
+ description="Value template; {value} is replaced with the formatted metric",
+ max_length=200,
+ )
+ start_angle: float = Field(225, description="Gauge start angle in degrees")
+ end_angle: float = Field(-45, description="Gauge end angle in degrees")
+ show_pointer: bool = Field(True, description="Show the gauge pointer")
+ animation: bool = Field(True, description="Animate gauge value changes")
+ show_axis_tick: bool = Field(False, description="Show minor axis ticks")
+ show_split_line: bool = Field(False, description="Show axis split lines")
+ split_number: int = Field(10, description="Number of axis segments", ge=3, le=30)
+ show_progress: bool = Field(True, description="Show the progress arc")
+ overlap: bool = Field(
+ True, description="Overlap progress arcs when multiple groups are present"
+ )
+ round_cap: bool = Field(False, description="Use rounded progress-arc caps")
+ intervals: str = Field(
+ "",
+ description="Comma-separated interval upper bounds",
+ max_length=1000,
+ )
+ interval_color_indices: str = Field(
+ "",
+ description="Comma-separated 1-based color indices for intervals",
+ max_length=1000,
+ )
+ time_range: str | None = Field(
+ None,
+ description="Optional Superset time range applied to the gauge query",
+ max_length=1000,
+ )
+ granularity_sqla: str | None = Field(
+ None,
+ description="Temporal column associated with time_range in native form_data",
+ min_length=1,
+ max_length=255,
+ )
+
+ @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
+
+ @field_validator("time_range")
+ @classmethod
+ def validate_gauge_time_range(cls, value: str | None) -> str | None:
+ """Validate time ranges with the shared MCP parser contract."""
+ return validate_time_range(value)
@model_validator(mode="after")
def reject_metric_style_groupby(self) -> "GaugeChartConfig":
- """groupby entries are dimensions, not metrics."""
+ """Require one numeric metric role and unique dimension roles."""
+ if not self.metric.is_metric:
+ raise ValueError(
+ "metric must define an aggregate, saved_metric=True, or a "
+ "sql_expression"
+ )
+ seen: set[str] = set()
for i, col in enumerate(self.groupby or []):
_reject_sql_expression_on_dimension(col, f"groupby[{i}]")
if col.is_metric:
@@ -1105,6 +1299,14 @@ class GaugeChartConfig(BaseChartConfig):
"'aggregate'/'saved_metric' (metrics belong in the 'metric' "
"field)"
)
+ if col.name is None:
+ raise ValueError(f"groupby[{i}] requires a column name")
+ normalized_name = col.name.casefold()
+ if normalized_name in seen:
+ raise ValueError(
+ f"groupby[{i}] duplicates the dimension role '{col.name}'"
+ )
+ seen.add(normalized_name)
return self
@model_validator(mode="after")
@@ -1120,6 +1322,41 @@ class GaugeChartConfig(BaseChartConfig):
)
return self
+ @model_validator(mode="after")
+ def validate_intervals(self) -> "GaugeChartConfig":
+ """Keep interval bounds finite, ordered, and aligned with color picks."""
+
+ def parse_numbers(value: str, field_name: str) -> list[float]:
+ if not value.strip():
+ return []
+ try:
+ parsed = [float(part.strip()) for part in value.split(",")]
+ except ValueError as ex:
+ raise ValueError(
+ f"{field_name} must be a comma-separated list of numbers"
+ ) from ex
+ if not all(math.isfinite(number) for number in parsed):
+ raise ValueError(f"{field_name} values must be finite")
+ return parsed
+
+ bounds = parse_numbers(self.intervals, "intervals")
+ color_indices = parse_numbers(
+ self.interval_color_indices, "interval_color_indices"
+ )
+ if any(not number.is_integer() or number < 1 for number in color_indices):
+ raise ValueError("interval_color_indices must contain positive integers")
+ if color_indices and len(color_indices) != len(bounds):
+ raise ValueError(
+ "interval_color_indices must have the same length as intervals"
+ )
+ if any(left >= right for left, right in zip(bounds, bounds[1:], strict=False)):
+ raise ValueError("intervals must be strictly increasing")
+ if self.min_val is not None and any(bound <= self.min_val for bound in bounds):
+ raise ValueError("intervals must be greater than min_val")
+ if self.max_val is not None and any(bound > self.max_val for bound in bounds):
+ raise ValueError("intervals must not exceed max_val")
+ return self
+
class PivotTableChartConfig(BaseChartConfig):
model_config = ConfigDict(extra="ignore", populate_by_name=True)
@@ -2376,7 +2613,7 @@ ChartConfig = Annotated[
discriminator="chart_type",
description=(
"Chart configuration - specify chart_type as 'xy', 'table', "
- "'pie', 'gauge_chart', 'pivot_table', 'interactive_pivot', "
+ "'pie', 'gauge', 'pivot_table', 'interactive_pivot', "
"'mixed_timeseries', 'handlebars', "
"'big_number', 'histogram', 'box_plot', or 'waterfall'"
),
@@ -2408,6 +2645,7 @@ _VIZ_TYPE_TO_CHART_TYPE: dict[str, tuple[str, str | None]] = {
"pivot_table_v2": ("pivot_table", None),
"ag-grid-pivot-table": ("interactive_pivot", None),
"histogram_v2": ("histogram", None),
+ "gauge_chart": ("gauge", None),
}
diff --git a/superset/mcp_service/chart/tool/generate_chart.py b/superset/mcp_service/chart/tool/generate_chart.py
index 0ba1790f722..0bef46e299b 100644
--- a/superset/mcp_service/chart/tool/generate_chart.py
+++ b/superset/mcp_service/chart/tool/generate_chart.py
@@ -86,7 +86,7 @@ async def generate_chart( # noqa: C901
- LLM clients MUST display returned chart URL to users
- Use numeric dataset ID or UUID (NOT schema.table_name format)
- MUST include chart_type in config (one of: 'xy', 'table', 'pie',
- 'gauge_chart', 'pivot_table', 'mixed_timeseries', 'handlebars',
+ 'gauge', 'pivot_table', 'mixed_timeseries', 'handlebars',
'big_number', 'histogram', 'box_plot', 'waterfall', plus host-gated
types returned by get_chart_type_schema such as 'interactive_pivot')
@@ -124,7 +124,7 @@ async def generate_chart( # noqa: C901
- chart_type='big_number' for single KPI metric displays.
Required fields: metric
- - chart_type='gauge_chart' for a dial/gauge display of a metric.
+ - chart_type='gauge' for a dial/gauge display of a metric.
Required fields: metric; optional: groupby (one dial per value),
min_val, max_val
@@ -150,7 +150,7 @@ async def generate_chart( # noqa: C901
only when get_chart_type_schema confirms it is available
- "compare two metrics over time" -> chart_type='mixed_timeseries'
- "single number" / "KPI" / "scorecard" -> chart_type='big_number'
- - "gauge" / "dial" / "speedometer" -> chart_type='gauge_chart'
+ - "gauge" / "dial" / "speedometer" -> chart_type='gauge'
- "custom HTML template" -> chart_type='handlebars'
- "histogram" / "distribution" -> chart_type='histogram'
- "box plot" / "box and whisker" -> chart_type='box_plot'
diff --git a/superset/mcp_service/chart/tool/get_chart_data.py b/superset/mcp_service/chart/tool/get_chart_data.py
index 77f4904c8c8..c88ea3db0c7 100644
--- a/superset/mcp_service/chart/tool/get_chart_data.py
+++ b/superset/mcp_service/chart/tool/get_chart_data.py
@@ -20,6 +20,7 @@ MCP tool: get_chart_data
"""
import logging
+import math
import time
from typing import Any, Dict, List, TYPE_CHECKING
@@ -46,6 +47,9 @@ from superset.mcp_service.chart.chart_helpers import (
rejected_requested_filter_columns,
)
from superset.mcp_service.chart.chart_utils import validate_chart_dataset
+from superset.mcp_service.chart.query_result import (
+ query_result_failure,
+)
from superset.mcp_service.chart.schemas import (
ChartData,
ChartError,
@@ -518,6 +522,23 @@ async def get_chart_data( # noqa: C901
# The query_context contains all the information needed to reproduce
# the chart's data exactly as shown in the visualization
query_context_json = None
+ if using_unsaved_state and cached_form_data_dict is not None:
+ form_data = cached_form_data_dict
+ else:
+ try:
+ parsed_saved_form_data = (
+ utils_json.loads(chart.params) if chart.params else {}
+ )
+ form_data = (
+ parsed_saved_form_data
+ if isinstance(parsed_saved_form_data, dict)
+ else {}
+ )
+ except (TypeError, ValueError):
+ form_data = {}
+
+ if not using_unsaved_state:
+ form_data["viz_type"] = chart.viz_type or form_data.get("viz_type")
# If using cached form_data, we need to build query_context from it
if using_unsaved_state and cached_form_data_dict is not None:
@@ -562,7 +583,6 @@ async def get_chart_data( # noqa: C901
"Consider re-saving the chart to enable full data retrieval."
)
# Try to construct from form_data as a fallback
- form_data = utils_json.loads(chart.params) if chart.params else {}
from superset.common.query_context_factory import QueryContextFactory
factory = QueryContextFactory()
@@ -689,6 +709,9 @@ async def get_chart_data( # noqa: C901
command.validate()
result = command.run()
+ if query_failure := query_result_failure(result):
+ return query_failure
+
if rejected := rejected_requested_filter_columns(
result, request.extra_form_data
):
@@ -1051,6 +1074,9 @@ async def _query_from_form_data( # noqa: C901
command.validate()
result = command.run()
+ if query_failure := query_result_failure(result):
+ return query_failure
+
if rejected := rejected_requested_filter_columns(
result, request.extra_form_data
):
@@ -1115,6 +1141,23 @@ async def _query_from_form_data( # noqa: C901
)
chart_name = form_data.get("slice_name", "Unsaved chart")
+ if request.format in {"csv", "excel"}:
+ from superset.models.slice import Slice
+
+ # A transient chart supplies export metadata without saving anything.
+ chart = Slice(id=0, slice_name=chart_name, viz_type=viz_type)
+ export = (
+ _export_data_as_csv
+ if request.format == "csv"
+ else _export_data_as_excel
+ )
+ return export(
+ chart,
+ data[: request.limit] if request.limit else data,
+ raw_columns,
+ cache_status,
+ PerformanceMetadata(query_duration_ms=0, cache_status="fresh_query"),
+ )
summary = (
f"Unsaved chart ({viz_type}). "
f"Contains {len(data)} rows across {len(raw_columns)} columns."
@@ -1273,6 +1316,9 @@ def _write_excel_data(ws: Any, data: List[Dict[str, Any]], columns: List[str]) -
value = row.get(col, "")
if value is None:
value = ""
+ elif isinstance(value, float) and not math.isfinite(value):
+ # XLSX has no non-finite numbers; preserve them as CSV-style text.
+ value = str(value)
elif isinstance(value, (list, dict)):
value = str(value)
ws.cell(row=row_idx, column=col_idx, value=value)
@@ -1315,7 +1361,9 @@ def _create_excel_with_xlsxwriter(
import xlsxwriter
output = io.BytesIO()
- workbook = xlsxwriter.Workbook(output, {"in_memory": True})
+ workbook = xlsxwriter.Workbook(
+ output, {"in_memory": True, "nan_inf_to_errors": True}
+ )
sheet_name = chart.slice_name[:31] if chart.slice_name else "Chart Data"
worksheet = workbook.add_worksheet(sheet_name)
diff --git a/superset/mcp_service/chart/tool/get_chart_preview.py b/superset/mcp_service/chart/tool/get_chart_preview.py
index 37dfb5b27b7..9ed1c624173 100644
--- a/superset/mcp_service/chart/tool/get_chart_preview.py
+++ b/superset/mcp_service/chart/tool/get_chart_preview.py
@@ -39,6 +39,14 @@ from superset.mcp_service.chart.chart_helpers import (
find_chart_by_identifier,
)
from superset.mcp_service.chart.chart_utils import validate_chart_dataset
+from superset.mcp_service.chart.preview_utils import (
+ generate_gauge_ascii_preview,
+ generate_gauge_vega_lite_preview,
+)
+from superset.mcp_service.chart.query_result import (
+ normalize_gauge_query_result,
+ query_result_failure,
+)
from superset.mcp_service.chart.schemas import (
AccessibilityMetadata,
ASCIIPreview,
@@ -173,6 +181,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."""
+ if form_data.get("viz_type") != "gauge_chart":
+ return fallback
+ value = form_data.get("row_limit", 10)
+ return value if isinstance(value, int) and 1 <= value <= 10 else 10
+
+
def _build_chart_description(chart: ChartLike) -> str:
"""Build a human-readable chart description, with hints for special chart types."""
base = (
@@ -245,10 +261,11 @@ class ASCIIPreviewStrategy(PreviewFormatStrategy):
error_type="InvalidChart",
)
+ form_data["viz_type"] = self.chart.viz_type or form_data.get("viz_type")
query_context = build_query_context_from_form_data(
form_data,
chart=self.chart,
- row_limit=50,
+ row_limit=_preview_row_limit(form_data, 50),
order_desc=True,
force=False,
)
@@ -261,16 +278,29 @@ class ASCIIPreviewStrategy(PreviewFormatStrategy):
command.validate()
result = command.run()
+ if query_failure := query_result_failure(result):
+ return query_failure
+ result = normalize_gauge_query_result(result, form_data)
+ if isinstance(result, ChartError):
+ return result
+
data: list[Any] = []
if result and "queries" in result and len(result["queries"]) > 0:
data = result["queries"][0].get("data") or []
- ascii_chart = generate_ascii_chart(
- data,
- self.chart.viz_type or "table",
- self.request.ascii_width or 80,
- self.request.ascii_height or 20,
- )
+ if 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,
+ self.chart.viz_type or "table",
+ self.request.ascii_width or 80,
+ self.request.ascii_height or 20,
+ )
return ASCIIPreview(
ascii_content=ascii_chart,
@@ -310,10 +340,11 @@ class TablePreviewStrategy(PreviewFormatStrategy):
error_type="InvalidChart",
)
+ form_data["viz_type"] = self.chart.viz_type or form_data.get("viz_type")
query_context = build_query_context_from_form_data(
form_data,
chart=self.chart,
- row_limit=20,
+ row_limit=_preview_row_limit(form_data, 20),
order_desc=True,
force=False,
)
@@ -326,6 +357,12 @@ class TablePreviewStrategy(PreviewFormatStrategy):
command.validate()
result = command.run()
+ if query_failure := query_result_failure(result):
+ return query_failure
+ result = normalize_gauge_query_result(result, form_data)
+ if isinstance(result, ChartError):
+ return result
+
data: list[Any] = []
if result and "queries" in result and len(result["queries"]) > 0:
data = result["queries"][0].get("data") or []
@@ -366,7 +403,7 @@ class VegaLitePreviewStrategy(PreviewFormatStrategy):
except (ValueError, TypeError):
return None
- def generate(self) -> VegaLitePreview | ChartError:
+ def generate(self) -> VegaLitePreview | ChartError: # noqa: C901
"""Generate Vega-Lite JSON specification from chart data."""
try:
# Get chart data directly using the same logic as get_chart_data tool
@@ -404,10 +441,11 @@ class VegaLitePreviewStrategy(PreviewFormatStrategy):
utils_json.loads(self.chart.params) if self.chart.params else {}
)
+ form_data["viz_type"] = self.chart.viz_type or form_data.get("viz_type")
query_context = build_query_context_from_form_data(
form_data,
chart=self.chart,
- row_limit=1000,
+ row_limit=_preview_row_limit(form_data, 1000),
order_desc=True,
force=self.request.force_refresh,
)
@@ -418,11 +456,19 @@ class VegaLitePreviewStrategy(PreviewFormatStrategy):
command.validate()
result = command.run()
+ if query_failure := query_result_failure(result):
+ return query_failure
+ result = normalize_gauge_query_result(result, form_data)
+ if isinstance(result, ChartError):
+ return result
+
# Extract data from result
chart_data = []
if result and "queries" in result and len(result["queries"]) > 0:
chart_data = result["queries"][0].get("data", [])
+ if form_data.get("viz_type") == "gauge_chart":
+ return generate_gauge_vega_lite_preview(chart_data, form_data)
if not chart_data or not isinstance(chart_data, list):
return ChartError(
error="No data available for Vega-Lite visualization",
@@ -514,7 +560,6 @@ class VegaLitePreviewStrategy(PreviewFormatStrategy):
"box_plot": ["box_plot"],
"heatmap": ["heatmap", "heatmap_v2", "cal_heatmap"],
"funnel": ["funnel"],
- "gauge": ["gauge_chart"],
"mixed": ["mixed_timeseries"],
"table": ["table"],
}
@@ -921,34 +966,6 @@ class VegaLitePreviewStrategy(PreviewFormatStrategy):
},
}
- def _gauge_chart_spec(
- self, fields: List[str], field_types: Dict[str, str] | None = None
- ) -> Dict[str, Any]:
- """Create gauge chart using arc marks."""
- value_field = fields[0] if fields else "value"
-
- return {
- "mark": {
- "type": "arc",
- "innerRadius": 50,
- "outerRadius": 80,
- "tooltip": True,
- },
- "encoding": {
- "theta": {
- "field": value_field,
- "type": "quantitative",
- "scale": {"range": [0, 6.28]},
- },
- "color": {
- "field": value_field,
- "type": "quantitative",
- "scale": {"scheme": "redyellowgreen"},
- },
- "tooltip": [{"field": f, "type": "nominal"} for f in fields[:3]],
- },
- }
-
def _mixed_chart_spec(
self, fields: List[str], field_types: Dict[str, str] | None = None
) -> Dict[str, Any]:
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 a29c2c9e201..d3ed18a4baf 100644
--- a/superset/mcp_service/chart/tool/get_chart_type_schema.py
+++ b/superset/mcp_service/chart/tool/get_chart_type_schema.py
@@ -50,7 +50,7 @@ _CHART_TYPE_ADAPTERS: Dict[str, TypeAdapter[Any]] = {
"xy": TypeAdapter(XYChartConfig),
"table": TypeAdapter(TableChartConfig),
"pie": TypeAdapter(PieChartConfig),
- "gauge_chart": TypeAdapter(GaugeChartConfig),
+ "gauge": TypeAdapter(GaugeChartConfig),
"pivot_table": TypeAdapter(PivotTableChartConfig),
"interactive_pivot": TypeAdapter(InteractivePivotChartConfig),
"mixed_timeseries": TypeAdapter(MixedTimeseriesChartConfig),
@@ -206,9 +206,9 @@ _CHART_EXAMPLES: Dict[str, list[Dict[str, Any]]] = {
"show_total": True,
},
],
- "gauge_chart": [
+ "gauge": [
{
- "chart_type": "gauge_chart",
+ "chart_type": "gauge",
"metric": {"name": "progress", "aggregate": "AVG"},
},
],
@@ -222,6 +222,8 @@ def _get_chart_type_schema_impl(
"""Pure logic for chart type schema lookup — no auth, no decorators."""
from superset.mcp_service.chart.registry import get_registry
+ if chart_type == "gauge_chart":
+ chart_type = "gauge"
enabled_types = sorted(get_registry().all_types())
adapter = _CHART_TYPE_ADAPTERS.get(chart_type)
if adapter is None:
@@ -299,7 +301,7 @@ def get_chart_type_schema(
for a chart configuration before calling generate_chart or update_chart.
Valid chart_type values depend on the host deployment. Core types are xy,
- table, pie, gauge_chart, pivot_table, mixed_timeseries, handlebars, big_number,
+ table, pie, gauge, pivot_table, mixed_timeseries, handlebars, big_number,
histogram, box_plot, and waterfall. Deployments that enable an AG Grid
pivot extension also expose interactive_pivot.
diff --git a/superset/mcp_service/chart/tool/update_chart.py b/superset/mcp_service/chart/tool/update_chart.py
index cbc76165500..0bc76fb6ee6 100644
--- a/superset/mcp_service/chart/tool/update_chart.py
+++ b/superset/mcp_service/chart/tool/update_chart.py
@@ -40,12 +40,14 @@ from superset.mcp_service.chart.chart_utils import (
analyze_chart_semantics,
generate_chart_name,
map_config_to_form_data,
+ merge_chart_form_data,
merge_interactive_pivot_ui_config,
merge_table_column_config,
)
from superset.mcp_service.chart.compile import validate_and_compile
from superset.mcp_service.chart.schemas import (
AccessibilityMetadata,
+ ChartConfig,
ColumnRef,
GenerateChartResponse,
PerformanceMetadata,
@@ -196,19 +198,40 @@ def _append_table_columns(
def _merge_replacement_config(
existing_form_data: dict[str, Any],
new_form_data: dict[str, Any],
- parsed_config: Any,
+ parsed_config: ChartConfig,
+ *,
+ dataset_rebind: bool = False,
) -> dict[str, Any]:
- """Merge a replacement config, honoring an explicit empty filter list."""
- merged = {
- **{
- key: value
- for key, value in existing_form_data.items()
- if key != "column_config"
- },
- **new_form_data,
- }
- if getattr(parsed_config, "filters", None) == []:
- merged.pop("adhoc_filters", None)
+ """Delegate update semantics to the shared form-data merge helper."""
+ return merge_chart_form_data(
+ existing_form_data,
+ new_form_data,
+ parsed_config,
+ dataset_rebind=dataset_rebind,
+ )
+
+
+def _build_replacement_form_data(
+ existing_form_data: dict[str, Any],
+ parsed_config: ChartConfig,
+ effective_dataset_id: int | None,
+ replacement_dataset_id: int | None = None,
+) -> dict[str, Any]:
+ """Map and merge a replacement config for preview and save paths."""
+ new_form_data = map_config_to_form_data(
+ parsed_config, dataset_id=effective_dataset_id
+ )
+ new_form_data.pop("_mcp_warnings", None)
+ merge_table_column_config(existing_form_data, new_form_data)
+ merge_interactive_pivot_ui_config(existing_form_data, new_form_data)
+ merged = _merge_replacement_config(
+ existing_form_data,
+ new_form_data,
+ parsed_config,
+ dataset_rebind=replacement_dataset_id is not None,
+ )
+ if replacement_dataset_id is not None:
+ merged["datasource"] = f"{replacement_dataset_id}__table"
return merged
@@ -230,12 +253,16 @@ def _build_update_payload(
)
if parsed_config is not None:
- new_form_data = map_config_to_form_data(
- parsed_config, dataset_id=effective_dataset_id
+ new_form_data = _build_replacement_form_data(
+ _get_existing_form_data(chart),
+ parsed_config,
+ effective_dataset_id,
+ replacement_dataset_id=(
+ request.dataset_id
+ if request.dataset_id != getattr(chart, "datasource_id", None)
+ else None
+ ),
)
- new_form_data.pop("_mcp_warnings", None)
- merge_table_column_config(_get_existing_form_data(chart), new_form_data)
- merge_interactive_pivot_ui_config(_get_existing_form_data(chart), new_form_data)
chart_name = (
request.chart_name
@@ -312,16 +339,15 @@ def _build_preview_form_data(
)
if parsed_config is not None:
- new_form_data = map_config_to_form_data(
- parsed_config, dataset_id=effective_dataset_id
- )
- new_form_data.pop("_mcp_warnings", None)
- merge_table_column_config(existing_form_data, new_form_data)
- merge_interactive_pivot_ui_config(existing_form_data, new_form_data)
- # In the preview, an explicit filters list, including [], replaces saved
- # filters. An omitted filters field preserves them through the shallow merge.
- merged = _merge_replacement_config(
- existing_form_data, new_form_data, parsed_config
+ merged = _build_replacement_form_data(
+ existing_form_data,
+ parsed_config,
+ effective_dataset_id,
+ replacement_dataset_id=(
+ request.dataset_id
+ if request.dataset_id != getattr(chart, "datasource_id", None)
+ else None
+ ),
)
elif request.add_columns is not None:
patched = _append_table_columns(existing_form_data, request.add_columns)
@@ -601,6 +627,21 @@ async def update_chart( # noqa: C901
}
)
+ if (
+ 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"
+ ):
+ return _validation_error_response(
+ message="Gauge dataset rebind requires a complete Gauge config.",
+ details=(
+ "Provide chart_type='gauge' and a metric valid on the target "
+ "dataset. This prevents stale metric, groupby, and filter roles "
+ "from the previous dataset from being retained."
+ ),
+ )
+
# Validate dataset access before allowing update.
# check_chart_data_access is the centralized data-level
# permission check that complements the class-level RBAC
@@ -719,6 +760,7 @@ async def update_chart( # noqa: C901
preview_or_error = _build_preview_form_data(request, chart, parsed_config)
if isinstance(preview_or_error, GenerateChartResponse):
return preview_or_error
+ new_form_data = preview_or_error
# Validate before caching the form_data — same rationale as above.
if validation_config is not None:
diff --git a/superset/mcp_service/chart/tool/update_chart_preview.py b/superset/mcp_service/chart/tool/update_chart_preview.py
index 29cdfc05531..ead15007f13 100644
--- a/superset/mcp_service/chart/tool/update_chart_preview.py
+++ b/superset/mcp_service/chart/tool/update_chart_preview.py
@@ -38,10 +38,9 @@ from superset.mcp_service.chart.chart_utils import (
generate_chart_name,
generate_explore_link,
map_config_to_form_data,
- MCP_DASHBOARD_TIME_FILTER_SUBJECT,
+ merge_chart_form_data,
merge_interactive_pivot_ui_config,
merge_table_column_config,
- NO_TIME_RANGE,
)
from superset.mcp_service.chart.compile import validate_and_compile
from superset.mcp_service.chart.preview_utils import (
@@ -106,50 +105,6 @@ def _get_previous_form_data(form_data_key: str) -> dict[str, Any] | None:
return None
-def _preserve_previous_adhoc_filters(
- new_form_data: dict[str, Any], previous_form_data: dict[str, Any]
-) -> None:
- """Preserve cached filters without dropping mapper-generated bindings."""
- previous_filters = previous_form_data.get("adhoc_filters")
- if not isinstance(previous_filters, list) or not previous_filters:
- return
-
- generated_filters = new_form_data.get("adhoc_filters", [])
- previous_binding = previous_form_data.get(MCP_DASHBOARD_TIME_FILTER_SUBJECT)
- new_binding = new_form_data.get(MCP_DASHBOARD_TIME_FILTER_SUBJECT)
- merged_filters = [
- filter_
- for filter_ in previous_filters
- if not (
- previous_binding
- and previous_binding != new_binding
- and isinstance(filter_, dict)
- and filter_.get("operator") == "TEMPORAL_RANGE"
- and filter_.get("subject") == previous_binding
- and filter_.get("comparator") == NO_TIME_RANGE
- )
- ]
- for generated_filter in generated_filters:
- if not isinstance(generated_filter, dict):
- if generated_filter not in merged_filters:
- merged_filters.append(generated_filter)
- continue
-
- is_same_filter = any(
- isinstance(previous_filter, dict)
- and previous_filter.get("clause") == generated_filter.get("clause")
- and previous_filter.get("expressionType")
- == generated_filter.get("expressionType")
- and previous_filter.get("subject") == generated_filter.get("subject")
- and previous_filter.get("operator") == generated_filter.get("operator")
- for previous_filter in merged_filters
- )
- if not is_same_filter:
- merged_filters.append(generated_filter)
-
- new_form_data["adhoc_filters"] = merged_filters
-
-
@tool(
tags=["mutate"],
class_permission_name="Chart",
@@ -216,6 +171,24 @@ def update_chart_preview( # noqa: C901
}
with event_logger.log_context(action="mcp.update_chart_preview.form_data"):
+ from superset.mcp_service.chart.validation.dataset_validator import (
+ build_dataset_context_from_orm,
+ DatasetValidator,
+ NORMALIZATION_EXCEPTIONS,
+ )
+
+ try:
+ config = DatasetValidator.normalize_column_names(
+ config,
+ request.dataset_id,
+ dataset_context=build_dataset_context_from_orm(dataset),
+ )
+ except NORMALIZATION_EXCEPTIONS as ex:
+ logger.warning(
+ "Column normalization failed for preview dataset %s: %s",
+ request.dataset_id,
+ ex,
+ )
# Map the new config to form_data format
# Pass dataset_id to enable column type checking
new_form_data = map_config_to_form_data(
@@ -230,16 +203,23 @@ def update_chart_preview( # noqa: C901
if previous_form_data is None:
warnings.append(INVALID_FORM_DATA_KEY_WARNING)
- # Preserve adhoc filters from the previous cached form_data
- # when the new config doesn't explicitly specify filters
- if getattr(config, "filters", None) is None and previous_form_data:
- _preserve_previous_adhoc_filters(
- new_form_data,
- previous_form_data,
- )
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,
+ config,
+ dataset_rebind=dataset_rebind,
+ )
# Tier-1 schema validation against the dataset (no DB roundtrip).
# Runs AFTER the filter merge so filter columns are also validated.
@@ -271,7 +251,10 @@ def update_chart_preview( # noqa: C901
}
compile_result = validate_and_compile(
- config, new_form_data, dataset, run_compile_check=False
+ config,
+ new_form_data,
+ dataset,
+ run_compile_check=config.chart_type == "gauge",
)
if not compile_result.success:
logger.warning(
@@ -390,6 +373,7 @@ def update_chart_preview( # noqa: C901
"semantics": semantics.model_dump() if semantics else None,
"explore_url": explore_url,
"form_data_key": new_form_data_key,
+ "form_data": new_form_data,
"previous_form_data_key": request.form_data_key, # For reference
"warnings": warnings,
"api_endpoints": {}, # No API endpoints for unsaved charts
diff --git a/superset/mcp_service/chart/validation/dataset_validator.py b/superset/mcp_service/chart/validation/dataset_validator.py
index 45333e90aeb..7747dca4c0d 100644
--- a/superset/mcp_service/chart/validation/dataset_validator.py
+++ b/superset/mcp_service/chart/validation/dataset_validator.py
@@ -23,7 +23,7 @@ Validates that referenced columns exist in the dataset schema.
import difflib
import logging
import re
-from collections.abc import Mapping
+from collections.abc import Iterable, Mapping
from typing import Any, Dict, List, Tuple, TypeVar
from superset.mcp_service.chart.schemas import (
@@ -46,6 +46,38 @@ _NUMERIC_TYPE_PATTERN = re.compile(
)
+class AmbiguousDatasetReferenceError(ValueError):
+ """A non-exact reference matches multiple names that differ only by case."""
+
+ def __init__(self, name: str, matches: list[str], reference_kind: str) -> None:
+ self.name = name
+ self.matches = sorted(matches)
+ self.reference_kind = reference_kind
+ choices = ", ".join(repr(match) for match in self.matches)
+ super().__init__(
+ f"{reference_kind.capitalize()} reference {name!r} is ambiguous because "
+ f"the dataset contains names that differ only by case: {choices}. "
+ f"Use the exact {reference_kind} name."
+ )
+
+
+def resolve_dataset_reference(
+ name: str, candidates: Iterable[str], reference_kind: str
+) -> str | None:
+ """Resolve exact names first and reject ambiguous case-insensitive matches."""
+ candidate_names = list(candidates)
+ if name in candidate_names:
+ return name
+ matches = [
+ candidate
+ for candidate in candidate_names
+ if candidate.casefold() == name.casefold()
+ ]
+ if len(matches) > 1:
+ raise AmbiguousDatasetReferenceError(name, matches, reference_kind)
+ return matches[0] if matches else None
+
+
def is_numeric_column(column: Mapping[str, Any]) -> bool:
"""Return whether dataset metadata identifies a numeric SQL column."""
if column.get("is_numeric", False):
@@ -218,11 +250,19 @@ class DatasetValidator:
if not temporal_column:
return None
+ try:
+ resolved_name = resolve_dataset_reference(
+ temporal_column,
+ (column["name"] for column in dataset_context.available_columns),
+ "physical column",
+ )
+ except AmbiguousDatasetReferenceError as ex:
+ return DatasetValidator._build_ambiguous_reference_error(ex)
matching_column = next(
(
column
for column in dataset_context.available_columns
- if column["name"].lower() == temporal_column.lower()
+ if column["name"] == resolved_name
),
None,
)
@@ -267,15 +307,12 @@ class DatasetValidator:
emit ``SUM(sum_boys)`` as an ad-hoc SIMPLE metric, producing the
broken-SQL pattern this validator is meant to prevent.
"""
- column_names_lower = {
- col["name"].lower() for col in dataset_context.available_columns
- }
- metric_names_lower = {
- metric["name"].lower() for metric in dataset_context.available_metrics
- }
+ column_names = [col["name"] for col in dataset_context.available_columns]
+ metric_names = [metric["name"] for metric in dataset_context.available_metrics]
invalid_columns: List[ColumnRef] = []
saved_metric_typo: List[ColumnRef] = []
+ ambiguous_references: list[AmbiguousDatasetReferenceError] = []
for col_ref in column_refs:
if col_ref.saved_metric:
continue
@@ -285,10 +322,24 @@ class DatasetValidator:
if col_ref.name is None:
# Should be unreachable per validate_metric_shape; defensive.
continue
- name_lower = col_ref.name.lower()
- if name_lower in column_names_lower:
+ try:
+ resolved_column = resolve_dataset_reference(
+ col_ref.name, column_names, "physical column"
+ )
+ except AmbiguousDatasetReferenceError as ex:
+ ambiguous_references.append(ex)
+ continue
+ if resolved_column is not None:
continue
- if name_lower in metric_names_lower:
+ try:
+ resolved_metric = resolve_dataset_reference(
+ col_ref.name, metric_names, "saved metric"
+ )
+ except AmbiguousDatasetReferenceError:
+ # This non-saved ref is still best explained by the tailored
+ # saved-metric hint rather than choosing one metric arbitrarily.
+ resolved_metric = metric_names[0] if metric_names else None
+ if resolved_metric is not None:
# Name matches a saved metric but the ref didn't opt into
# saved-metric resolution. Surface a tailored hint so the
# caller (typically an LLM) can flip ``saved_metric=true``.
@@ -296,6 +347,10 @@ class DatasetValidator:
else:
invalid_columns.append(col_ref)
+ if ambiguous_references:
+ return DatasetValidator._build_ambiguous_reference_error(
+ ambiguous_references[0]
+ )
if saved_metric_typo:
return DatasetValidator._build_saved_metric_hint_error(saved_metric_typo)
@@ -316,6 +371,25 @@ class DatasetValidator:
invalid_columns, suggestions_map, dataset_context
)
+ @staticmethod
+ def _build_ambiguous_reference_error(
+ error: AmbiguousDatasetReferenceError,
+ ) -> ChartGenerationError:
+ """Build an actionable error for case-colliding dataset metadata."""
+ return ChartGenerationError(
+ error_type="ambiguous_dataset_reference",
+ message=(
+ f"{error.reference_kind.capitalize()} reference "
+ f"'{error.name}' is ambiguous"
+ ),
+ details=str(error),
+ suggestions=[
+ f"Use one exact name: {', '.join(error.matches)}",
+ "Use get_dataset_info to inspect exact dataset casing",
+ ],
+ error_code="AMBIGUOUS_DATASET_REFERENCE",
+ )
+
@staticmethod
def _build_saved_metric_hint_error(
refs: List[ColumnRef],
@@ -404,19 +478,20 @@ class DatasetValidator:
@staticmethod
def _column_exists(column_name: str, dataset_context: DatasetContext) -> bool:
- """Check if column exists in dataset (case-insensitive)."""
- column_lower = column_name.lower()
-
- # Check regular columns
- for col in dataset_context.available_columns:
- if col["name"].lower() == column_lower:
- return True
-
- # Check metrics
- for metric in dataset_context.available_metrics:
- if metric["name"].lower() == column_lower:
+ """Check if a physical column or saved metric resolves unambiguously."""
+ for candidates, reference_kind in (
+ (dataset_context.available_columns, "physical column"),
+ (dataset_context.available_metrics, "saved metric"),
+ ):
+ if (
+ resolve_dataset_reference(
+ column_name,
+ (candidate["name"] for candidate in candidates),
+ reference_kind,
+ )
+ is not None
+ ):
return True
-
return False
@staticmethod
@@ -438,20 +513,21 @@ class DatasetValidator:
The canonical column name from the dataset, or the original name
if no match is found.
"""
- column_lower = column_name.lower()
-
- # Check regular columns first
- for col in dataset_context.available_columns:
- if col["name"].lower() == column_lower:
- return col["name"]
-
- # Check metrics
- for metric in dataset_context.available_metrics:
- if metric["name"].lower() == column_lower:
- return metric["name"]
-
- # Return original if not found (validation should catch this case)
- return column_name
+ resolved_column = resolve_dataset_reference(
+ column_name,
+ (col["name"] for col in dataset_context.available_columns),
+ "physical column",
+ )
+ if resolved_column is not None:
+ return resolved_column
+ return (
+ resolve_dataset_reference(
+ column_name,
+ (metric["name"] for metric in dataset_context.available_metrics),
+ "saved metric",
+ )
+ or column_name
+ )
@staticmethod
def get_canonical_metric_name(
@@ -466,11 +542,14 @@ class DatasetValidator:
Returns the original name when no metric matches (validation catches
the missing-metric case separately).
"""
- metric_lower = metric_name.lower()
- for metric in dataset_context.available_metrics:
- if metric["name"].lower() == metric_lower:
- return metric["name"]
- return metric_name
+ return (
+ resolve_dataset_reference(
+ metric_name,
+ (metric["name"] for metric in dataset_context.available_metrics),
+ "saved metric",
+ )
+ or metric_name
+ )
@staticmethod
def normalize_filters(
@@ -640,15 +719,19 @@ class DatasetValidator:
a regular column name marked as saved_metric would pass
_column_exists (which checks both lists) but fail at query time.
"""
- metric_names = {m["name"].lower() for m in dataset_context.available_metrics}
- # ``saved_metric=True`` requires ``name`` per ColumnRef.validate_metric_shape.
- invalid: list[str] = [
- col_ref.name
- for col_ref in column_refs
- if col_ref.saved_metric
- and col_ref.name is not None
- and col_ref.name.lower() not in metric_names
- ]
+ metric_names = [m["name"] for m in dataset_context.available_metrics]
+ invalid: list[str] = []
+ for col_ref in column_refs:
+ if not col_ref.saved_metric or col_ref.name is None:
+ continue
+ try:
+ resolved = resolve_dataset_reference(
+ col_ref.name, metric_names, "saved metric"
+ )
+ except AmbiguousDatasetReferenceError as ex:
+ return DatasetValidator._build_ambiguous_reference_error(ex)
+ if resolved is None:
+ invalid.append(col_ref.name)
if not invalid:
return None
@@ -694,12 +777,23 @@ class DatasetValidator:
# Should be unreachable per validate_metric_shape; defensive.
continue
- # Find column info
- col_info = None
- for col in dataset_context.available_columns:
- if col["name"].lower() == col_ref.name.lower():
- col_info = col
- break
+ try:
+ resolved_name = resolve_dataset_reference(
+ col_ref.name,
+ (col["name"] for col in dataset_context.available_columns),
+ "physical column",
+ )
+ except AmbiguousDatasetReferenceError as ex:
+ errors.append(DatasetValidator._build_ambiguous_reference_error(ex))
+ continue
+ col_info = next(
+ (
+ col
+ for col in dataset_context.available_columns
+ if col["name"] == resolved_name
+ ),
+ None,
+ )
if col_info:
# Check numeric aggregates on non-numeric columns.
diff --git a/superset/mcp_service/chart/validation/schema_validator.py b/superset/mcp_service/chart/validation/schema_validator.py
index bdd777593ad..940a022def9 100644
--- a/superset/mcp_service/chart/validation/schema_validator.py
+++ b/superset/mcp_service/chart/validation/schema_validator.py
@@ -26,6 +26,7 @@ from typing import Any, Dict, Tuple
from pydantic import ValidationError as PydanticValidationError
from superset.mcp_service.chart.schemas import (
+ _normalize_chart_request_input,
GenerateChartRequest,
)
from superset.mcp_service.common.error_schemas import ChartGenerationError
@@ -79,6 +80,14 @@ class SchemaValidator:
error_code="INVALID_REQUEST_FORMAT",
)
+ # Pre-validation runs before GenerateChartRequest's model validator,
+ # so apply the same public/native vocabulary adapter here. Copy both
+ # levels to avoid mutating the caller's request dictionary.
+ data = dict(data)
+ if isinstance(data.get("config"), dict):
+ data["config"] = dict(data["config"])
+ data = _normalize_chart_request_input(data)
+
# Check for required top-level fields
if "dataset_id" not in data:
return False, ChartGenerationError(
@@ -131,6 +140,7 @@ class SchemaValidator:
"Add 'chart_type': 'xy' for line/bar/area/scatter charts",
"Add 'chart_type': 'table' for table visualizations",
"Add 'chart_type': 'pie' for pie or donut charts",
+ "Add 'chart_type': 'gauge' for a gauge or dial",
"Add 'chart_type': 'pivot_table' for OSS pivot tables",
"Use 'interactive_pivot' only when schema discovery exposes it",
"Add 'chart_type': 'mixed_timeseries' for dual-series time charts",
@@ -167,6 +177,7 @@ class SchemaValidator:
"Use 'chart_type': 'xy' for line, bar, area, or scatter charts",
"Use 'chart_type': 'table' for tabular data display",
"Use 'chart_type': 'pie' for pie or donut charts",
+ "Use 'chart_type': 'gauge' for a gauge or dial",
"Use 'chart_type': 'pivot_table' for OSS pivot tables",
"Use 'interactive_pivot' only when schema discovery exposes it",
"Use 'chart_type': 'mixed_timeseries' for dual-series time charts",
diff --git a/superset/mcp_service/explore/tool/generate_explore_link.py b/superset/mcp_service/explore/tool/generate_explore_link.py
index 178c95ea8ae..9d8656da848 100644
--- a/superset/mcp_service/explore/tool/generate_explore_link.py
+++ b/superset/mcp_service/explore/tool/generate_explore_link.py
@@ -280,7 +280,7 @@ async def generate_explore_link(
normalized_config,
form_data,
dataset,
- run_compile_check=False,
+ run_compile_check=normalized_config.chart_type == "gauge",
)
if not compile_result.success:
await ctx.warning(
diff --git a/tests/unit_tests/common/test_query_context_processor.py b/tests/unit_tests/common/test_query_context_processor.py
index ad964ee2540..613e2faf72b 100644
--- a/tests/unit_tests/common/test_query_context_processor.py
+++ b/tests/unit_tests/common/test_query_context_processor.py
@@ -49,8 +49,8 @@ def _wire_contribution_totals(mock_query_context: MagicMock) -> None:
``QueryContext`` owns the normalization and the processor delegates to it, so a
bare MagicMock would return a value the processor cannot unpack.
"""
- mock_query_context.prepare_contribution_totals.side_effect = (
- lambda: normalize_contribution_totals(
+ mock_query_context.prepare_contribution_totals.side_effect = lambda: (
+ normalize_contribution_totals(
mock_query_context.queries, mock_query_context.cache_values
)
)
@@ -2604,3 +2604,24 @@ def test_mark_force_executed_uses_per_query_nonce(processor, mock_query_context)
cache_manager.data_cache.set.assert_called_once_with(
"gtf-force-nonce:task-uuid:ck", 1, timeout=123
)
+
+
+def test_gauge_json_metric_label_ignores_verbose_name(
+ processor: QueryContextProcessor, mock_query_context: MagicMock
+) -> None:
+ """Gauge JSON rows retain saved metric keys, unlike CSV/XLSX exports."""
+ from superset.mcp_service.chart.query_result import validate_gauge_query_result
+
+ mock_query_context.datasource.data = {"verbose_map": {"saved_sla": "SLA percent"}}
+ mock_query_context.result_format = ChartDataResultFormat.JSON
+ rows = processor.get_data(
+ pd.DataFrame({"saved_sla": [42]}), [GenericDataType.NUMERIC]
+ )
+ assert rows == [{"saved_sla": 42}]
+ assert (
+ validate_gauge_query_result(
+ {"queries": [{"data": rows}]},
+ {"viz_type": "gauge_chart", "metric": "saved_sla"},
+ )
+ is None
+ )
diff --git a/tests/unit_tests/connectors/sqla/models_test.py b/tests/unit_tests/connectors/sqla/models_test.py
index 48fd76d2ac7..fca6cf038e1 100644
--- a/tests/unit_tests/connectors/sqla/models_test.py
+++ b/tests/unit_tests/connectors/sqla/models_test.py
@@ -1953,3 +1953,51 @@ def test_count_distinct_calculated_column_is_parenthesized() -> None:
f"COUNT_DISTINCT over a calculated column should parenthesize the "
f"expression. Rendered: {rendered}"
)
+
+
+def test_gauge_query_restores_long_sql_metric_label(mocker: MockerFixture) -> None:
+ """Engine-compatible SQL aliases do not replace the frontend result label."""
+ from collections.abc import Callable
+
+ from sqlalchemy import literal_column
+
+ from superset.mcp_service.chart.query_result import validate_gauge_query_result
+
+ table = _build_sqla_table_for_query(mocker, "SELECT 42 AS truncated")
+ expected = "SUM(" + "long_column_" * 200 + ")"
+ mocker.patch.object(
+ table.db_engine_spec, "make_label_compatible", return_value="truncated"
+ )
+ column = table.make_sqla_column_compatible(literal_column("42"), expected)
+ assert column.name == "truncated"
+ assert column.key == expected
+ mocker.patch.object(
+ table,
+ "get_query_str_extended",
+ return_value=mocker.MagicMock(
+ sql="SELECT 42 AS truncated", labels_expected=[column.key]
+ ),
+ )
+
+ def get_df(
+ sql: str,
+ catalog: str,
+ schema: str,
+ mutator: Callable[[pd.DataFrame], pd.DataFrame],
+ ) -> pd.DataFrame:
+ """Apply the real query result relabeling callback to driver output."""
+ return mutator(pd.DataFrame({"truncated": [42]}))
+
+ mocker.patch.object(table.database, "get_df", side_effect=get_df)
+ result = table.query(_query_obj())
+ assert list(result.df.columns) == [expected]
+ assert (
+ validate_gauge_query_result(
+ {"queries": [{"data": result.df.to_dict(orient="records")}]},
+ {
+ "viz_type": "gauge_chart",
+ "metric": {"expressionType": "SQL", "sqlExpression": expected},
+ },
+ )
+ is None
+ )
diff --git a/tests/unit_tests/mcp_service/chart/test_compile.py b/tests/unit_tests/mcp_service/chart/test_compile.py
index 60608f5e4b5..65440b8353d 100644
--- a/tests/unit_tests/mcp_service/chart/test_compile.py
+++ b/tests/unit_tests/mcp_service/chart/test_compile.py
@@ -465,6 +465,74 @@ class TestValidateAndCompileTier2:
assert result.error_code == "DATASET_NOT_FOUND"
+@patch("superset.commands.chart.data.get_data_command.ChartDataCommand")
+@patch("superset.mcp_service.chart.chart_helpers.build_query_context_from_form_data")
+def test_compile_gauge_uses_shared_query_builder_and_skips_invalid_dials(
+ mock_build_query_context, mock_cmd_cls
+):
+ """Gauge compile matches frontend ordering and retains finite groups."""
+ from superset.mcp_service.chart.compile import _compile_chart
+
+ mock_build_query_context.return_value = Mock()
+ mock_cmd_cls.return_value.validate.return_value = None
+ mock_cmd_cls.return_value.run.return_value = {
+ "queries": [
+ {
+ "data": [
+ {"team": "A", "AVG(num)": 10},
+ {"team": "B", "AVG(num)": "not numeric"},
+ ]
+ }
+ ]
+ }
+ form_data = {
+ "viz_type": "gauge_chart",
+ "metric": {
+ "expressionType": "SIMPLE",
+ "aggregate": "AVG",
+ "column": {"column_name": "num"},
+ "label": "AVG(num)",
+ },
+ "groupby": ["team"],
+ "sort_by_metric": True,
+ "row_limit": 10,
+ }
+
+ result = _compile_chart(form_data, dataset_id=3)
+
+ assert result.success
+ assert result.row_count == 1
+ query_form_data = mock_build_query_context.call_args.args[0]
+ assert query_form_data["sort_by_metric"] is True
+ assert query_form_data["metric"] == form_data["metric"]
+ assert query_form_data["datasource"] == "3__table"
+ mock_build_query_context.assert_called_once_with(
+ query_form_data, row_limit=10, force=False
+ )
+
+
+@patch("superset.commands.chart.data.get_data_command.ChartDataCommand")
+@patch("superset.mcp_service.chart.chart_helpers.build_query_context_from_form_data")
+def test_compile_gauge_accepts_numeric_saved_metric_result(
+ mock_build_query_context, mock_cmd_cls
+):
+ """Saved/SQL metrics stay supported when their concrete output is numeric."""
+ from superset.mcp_service.chart.compile import _compile_chart
+
+ mock_build_query_context.return_value = Mock()
+ mock_cmd_cls.return_value.validate.return_value = None
+ mock_cmd_cls.return_value.run.return_value = {
+ "queries": [{"data": [{"saved_sla": 99.5}]}]
+ }
+
+ result = _compile_chart(
+ {"viz_type": "gauge_chart", "metric": "saved_sla"}, dataset_id=3
+ )
+
+ assert result.success
+ assert result.row_count == 1
+
+
@patch("superset.daos.dataset.DatasetDAO")
@patch("superset.commands.chart.data.get_data_command.ChartDataCommand")
@patch("superset.common.query_context_factory.QueryContextFactory")
@@ -572,3 +640,44 @@ def test_valid_configs_pass_tier1(config_factory):
ds = _orm_dataset()
result = validate_and_compile(config_factory(), {}, ds, run_compile_check=False)
assert result.success, result.error
+
+
+@pytest.mark.parametrize("clause", ["WHERE", "HAVING"])
+@pytest.mark.parametrize("subject", ["score", "Score", "SCORE"])
+def test_preserved_filter_ambiguity_is_actionable(clause: str, subject: str) -> None:
+ """The public compiler rejects ambiguous preserved filters, exact-first."""
+ dataset = _orm_dataset(column_names=["gender", "Score", "SCORE"])
+ config = TableChartConfig(chart_type="table", columns=[ColumnRef(name="gender")])
+ form_data = {
+ "adhoc_filters": [
+ {
+ "expressionType": "SIMPLE",
+ "clause": clause,
+ "subject": subject,
+ "operator": ">",
+ "comparator": 0,
+ }
+ ]
+ }
+ result = validate_and_compile(config, form_data, dataset, run_compile_check=False)
+ assert result.success is (subject != "score")
+ if subject == "score":
+ assert result.error_obj is not None
+ assert result.error_obj.error_code == "AMBIGUOUS_DATASET_REFERENCE"
+ assert "Score" in result.error_obj.details
+ assert "SCORE" in result.error_obj.details
+
+
+def test_aggregation_ambiguity_returns_validation_errors() -> None:
+ """Direct aggregation validation has the same structured ambiguity contract."""
+ from superset.mcp_service.chart.validation.dataset_validator import DatasetValidator
+
+ context = build_dataset_context_from_orm(
+ _orm_dataset(column_names=["Score", "SCORE"])
+ )
+ assert context is not None
+ errors = DatasetValidator._validate_aggregations(
+ [ColumnRef(name="score", aggregate="AVG")], context
+ )
+ assert len(errors) == 1
+ assert errors[0].error_code == "AMBIGUOUS_DATASET_REFERENCE"
diff --git a/tests/unit_tests/mcp_service/chart/test_gauge_chart.py b/tests/unit_tests/mcp_service/chart/test_gauge_chart.py
index cabf97ac437..93c6890aba3 100644
--- a/tests/unit_tests/mcp_service/chart/test_gauge_chart.py
+++ b/tests/unit_tests/mcp_service/chart/test_gauge_chart.py
@@ -22,11 +22,33 @@ contract for viz_type ``gauge_chart`` — a single ``metric`` with an optional
multi ``groupby`` producing one dial per row), and registry integration.
"""
+from collections.abc import Callable
+from typing import Any
+
import pytest
from pydantic import TypeAdapter, ValidationError
-from superset.mcp_service.chart.chart_utils import map_gauge_config
-from superset.mcp_service.chart.schemas import ChartConfig, GaugeChartConfig
+from superset.mcp_service.chart.chart_utils import (
+ map_config_to_form_data,
+ map_gauge_config,
+ MCP_DASHBOARD_TIME_FILTER_SUBJECT,
+ merge_chart_form_data,
+)
+from superset.mcp_service.chart.preview_utils import (
+ generate_gauge_ascii_preview,
+ generate_gauge_vega_lite_preview,
+)
+from superset.mcp_service.chart.query_result import (
+ metric_result_label,
+ validate_gauge_query_result,
+)
+from superset.mcp_service.chart.schemas import (
+ ChartConfig,
+ ChartError,
+ GaugeChartConfig,
+ GenerateChartRequest,
+)
+from superset.mcp_service.common.error_schemas import DatasetContext
class TestGaugeChartConfigSchema:
@@ -34,7 +56,7 @@ class TestGaugeChartConfigSchema:
def test_basic_gauge_config(self) -> None:
config = GaugeChartConfig(
- chart_type="gauge_chart",
+ chart_type="gauge",
metric={"name": "progress", "aggregate": "AVG"},
)
assert config.metric.name == "progress"
@@ -45,12 +67,12 @@ class TestGaugeChartConfigSchema:
def test_gauge_missing_metric(self) -> None:
with pytest.raises(ValidationError):
- GaugeChartConfig(chart_type="gauge_chart", groupby=[{"name": "team"}])
+ GaugeChartConfig(chart_type="gauge", groupby=[{"name": "team"}])
def test_gauge_rejects_extra_fields(self) -> None:
with pytest.raises(ValidationError):
GaugeChartConfig(
- chart_type="gauge_chart",
+ chart_type="gauge",
metric={"name": "progress", "aggregate": "AVG"},
bogus=1,
)
@@ -59,7 +81,7 @@ class TestGaugeChartConfigSchema:
"""A groupby dial dimension is a category, not a metric."""
with pytest.raises(ValidationError):
GaugeChartConfig(
- chart_type="gauge_chart",
+ chart_type="gauge",
metric={"name": "progress", "aggregate": "AVG"},
groupby=[{"name": "count", "saved_metric": True}],
)
@@ -68,7 +90,7 @@ class TestGaugeChartConfigSchema:
"""An aggregate makes a dial dimension metric-like; reject it."""
with pytest.raises(ValidationError):
GaugeChartConfig(
- chart_type="gauge_chart",
+ chart_type="gauge",
metric={"name": "progress", "aggregate": "AVG"},
groupby=[{"name": "team", "aggregate": "SUM"}],
)
@@ -77,7 +99,7 @@ class TestGaugeChartConfigSchema:
"""min_val >= max_val yields an inverted dial; reject it."""
with pytest.raises(ValidationError):
GaugeChartConfig(
- chart_type="gauge_chart",
+ chart_type="gauge",
metric={"name": "progress", "aggregate": "AVG"},
min_val=100,
max_val=0,
@@ -87,7 +109,7 @@ class TestGaugeChartConfigSchema:
"""The frontend limits gauge dials to 10."""
with pytest.raises(ValidationError):
GaugeChartConfig(
- chart_type="gauge_chart",
+ chart_type="gauge",
metric={"name": "progress", "aggregate": "AVG"},
row_limit=25,
)
@@ -95,19 +117,160 @@ class TestGaugeChartConfigSchema:
def test_chart_config_union_dispatches_gauge(self) -> None:
config = TypeAdapter(ChartConfig).validate_python(
{
- "chart_type": "gauge_chart",
+ "chart_type": "gauge",
"metric": {"name": "progress", "aggregate": "AVG"},
}
)
assert isinstance(config, GaugeChartConfig)
+ def test_public_union_rejects_native_viz_type_as_discriminator(self) -> None:
+ with pytest.raises(ValidationError):
+ TypeAdapter(ChartConfig).validate_python(
+ {
+ "chart_type": "gauge_chart",
+ "metric": {"name": "progress", "aggregate": "AVG"},
+ }
+ )
+
+ def test_request_aliases_native_gauge_form_data(self) -> None:
+ request = GenerateChartRequest.model_validate(
+ {
+ "datasource_id": 7,
+ "config": {
+ "viz_type": "gauge_chart",
+ "metric": "saved_progress",
+ "groupby": ["team"],
+ },
+ }
+ )
+ assert request.dataset_id == 7
+ assert request.config.chart_type == "gauge"
+ assert request.config.metric.saved_metric is True
+
+ def test_native_form_data_full_control_surface_round_trips(self) -> None:
+ native = {
+ "viz_type": "gauge_chart",
+ "datasource": "7__table",
+ "slice_id": 11,
+ "metric": {
+ "expressionType": "SQL",
+ "sqlExpression": "AVG(progress)",
+ "label": "SLA",
+ },
+ "groupby": ["team", {"name": "region"}],
+ "sort_by_metric": False,
+ "row_limit": 5,
+ "min_val": 0,
+ "max_val": 100,
+ "color_scheme": "lyftColors",
+ "font_size": 18,
+ "number_format": ",.1f",
+ "currency_format": {"symbol": "%", "symbolPosition": "suffix"},
+ "value_formatter": "Progress: {value}",
+ "start_angle": 180,
+ "end_angle": 0,
+ "show_pointer": False,
+ "animation": False,
+ "show_axis_tick": True,
+ "show_split_line": True,
+ "split_number": 8,
+ "show_progress": False,
+ "overlap": False,
+ "round_cap": True,
+ "intervals": "50,80,100",
+ "interval_color_indices": "1,3,6",
+ "time_range": "Last week",
+ "granularity_sqla": "event_time",
+ "adhoc_filters": [
+ {
+ "clause": "WHERE",
+ "expressionType": "SIMPLE",
+ "subject": "country",
+ "operator": "==",
+ "comparator": "US",
+ }
+ ],
+ }
+ config = GaugeChartConfig.model_validate(native)
+ mapped = map_gauge_config(config)
+ for field in (
+ "groupby",
+ "sort_by_metric",
+ "row_limit",
+ "min_val",
+ "max_val",
+ "color_scheme",
+ "font_size",
+ "number_format",
+ "currency_format",
+ "value_formatter",
+ "start_angle",
+ "end_angle",
+ "show_pointer",
+ "animation",
+ "show_axis_tick",
+ "show_split_line",
+ "split_number",
+ "show_progress",
+ "overlap",
+ "round_cap",
+ "intervals",
+ "interval_color_indices",
+ "time_range",
+ "granularity_sqla",
+ ):
+ expected = ["team", "region"] if field == "groupby" else native[field]
+ assert mapped[field] == expected
+ assert mapped["metric"]["sqlExpression"] == "AVG(progress)"
+ assert mapped["adhoc_filters"][0]["operator"] == "=="
+
+ @pytest.mark.parametrize(
+ "metric",
+ [
+ {"name": "progress"},
+ {"name": "progress", "aggregate": None},
+ ],
+ )
+ def test_gauge_rejects_unaggregated_metric(self, metric: dict[str, Any]) -> None:
+ with pytest.raises(ValidationError, match="metric must define"):
+ GaugeChartConfig(chart_type="gauge", metric=metric)
+
+ def test_gauge_rejects_duplicate_casefold_groupby(self) -> None:
+ with pytest.raises(ValidationError, match="duplicates"):
+ GaugeChartConfig(
+ chart_type="gauge",
+ metric={"name": "progress", "aggregate": "AVG"},
+ groupby=[{"name": "Team"}, {"name": "team"}],
+ )
+
+ @pytest.mark.parametrize(
+ ("field", "value"),
+ [("min_val", float("nan")), ("max_val", float("inf"))],
+ )
+ def test_gauge_rejects_nonfinite_bounds(self, field: str, value: float) -> None:
+ with pytest.raises(ValidationError):
+ GaugeChartConfig(
+ chart_type="gauge",
+ metric={"name": "progress", "aggregate": "AVG"},
+ **{field: value},
+ )
+
+ def test_gauge_rejects_misaligned_intervals(self) -> None:
+ with pytest.raises(ValidationError, match="same length"):
+ GaugeChartConfig(
+ chart_type="gauge",
+ metric={"name": "progress", "aggregate": "AVG"},
+ intervals="50,100",
+ interval_color_indices="1",
+ )
+
class TestMapGaugeConfig:
"""form_data mapping must match the frontend Gauge buildQuery."""
def test_basic_gauge_form_data(self) -> None:
config = GaugeChartConfig(
- chart_type="gauge_chart",
+ chart_type="gauge",
metric={"name": "progress", "aggregate": "AVG"},
)
form_data = map_gauge_config(config)
@@ -118,7 +281,7 @@ class TestMapGaugeConfig:
def test_gauge_form_data_with_dials_and_range(self) -> None:
config = GaugeChartConfig(
- chart_type="gauge_chart",
+ chart_type="gauge",
metric={"name": "progress", "aggregate": "AVG"},
groupby=[{"name": "team"}],
min_val=0,
@@ -134,7 +297,7 @@ class TestMapGaugeConfig:
def test_gauge_color_scheme_defaults_and_bounds_emitted(self) -> None:
"""color_scheme defaults to supersetColors; min/max keys are emitted."""
config = GaugeChartConfig(
- chart_type="gauge_chart",
+ chart_type="gauge",
metric={"name": "progress", "aggregate": "AVG"},
min_val=0,
max_val=100,
@@ -146,7 +309,7 @@ class TestMapGaugeConfig:
# explicit scheme passes through unchanged
explicit = map_gauge_config(
GaugeChartConfig(
- chart_type="gauge_chart",
+ chart_type="gauge",
metric={"name": "progress", "aggregate": "AVG"},
color_scheme="lyftColors",
)
@@ -163,7 +326,7 @@ class TestMapGaugeConfig:
lambda datasource_id, datasource_type: "base",
)
config = GaugeChartConfig(
- chart_type="gauge_chart",
+ chart_type="gauge",
metric={"name": "progress", "aggregate": "AVG"},
groupby=[{"name": "team"}],
)
@@ -176,7 +339,7 @@ class TestMapGaugeConfig:
def test_gauge_saved_metric_maps_to_name_string(self) -> None:
config = GaugeChartConfig(
- chart_type="gauge_chart",
+ chart_type="gauge",
metric={"name": "sla_attainment", "saved_metric": True},
)
assert map_gauge_config(config)["metric"] == "sla_attainment"
@@ -188,7 +351,7 @@ class TestGaugePluginRegistry:
def test_gauge_plugin_registered(self) -> None:
from superset.mcp_service.chart import registry
- plugin = registry.get("gauge_chart")
+ plugin = registry.get("gauge")
assert plugin is not None
assert plugin.resolve_viz_type(None) == "gauge_chart"
@@ -200,9 +363,9 @@ class TestGaugePluginRegistry:
def test_pre_validate_missing_metric(self) -> None:
from superset.mcp_service.chart import registry
- plugin = registry.get("gauge_chart")
+ plugin = registry.get("gauge")
assert plugin is not None
- error = plugin.pre_validate({"chart_type": "gauge_chart"})
+ error = plugin.pre_validate({"chart_type": "gauge"})
assert error is not None
assert "metric" in error.message
@@ -210,9 +373,9 @@ class TestGaugePluginRegistry:
"""groupby is optional; metric alone is a valid gauge."""
from superset.mcp_service.chart import registry
- plugin = registry.get("gauge_chart")
+ plugin = registry.get("gauge")
assert plugin is not None
- assert plugin.pre_validate({"chart_type": "gauge_chart", "metric": {}}) is None
+ assert plugin.pre_validate({"chart_type": "gauge", "metric": {}}) is None
class TestGaugeRecommendationCategory:
@@ -228,4 +391,791 @@ class TestGaugeRecommendationCategory:
_CHART_TYPE_ADAPTERS,
)
- assert "gauge_chart" in _CHART_TYPE_ADAPTERS
+ assert "gauge" in _CHART_TYPE_ADAPTERS
+
+ def test_chart_config_resource_uses_public_gauge_identity(
+ self, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ from unittest.mock import Mock
+
+ from superset.mcp_service.chart.resources.chart_configs import (
+ get_chart_configs_resource,
+ )
+ from superset.utils import json
+
+ monkeypatch.setattr(
+ "superset.mcp_service.auth.get_user_from_request",
+ lambda: Mock(id=1, username="admin"),
+ )
+ resource = json.loads(get_chart_configs_resource())
+ example = resource["gauge_configs"]["attainment_gauge"]["config"]
+ assert example["chart_type"] == "gauge"
+ assert "gauge_charts" in resource["best_practices"]
+
+
+class TestGaugeUpdateRoundTrip:
+ """Gauge updates preserve omissions but honor explicit clears."""
+
+ @staticmethod
+ def existing_form_data() -> dict[str, Any]:
+ return {
+ "viz_type": "gauge_chart",
+ "datasource": "1__table",
+ "metric": "saved_progress",
+ "groupby": ["team"],
+ "row_limit": 4,
+ "sort_by_metric": False,
+ "min_val": 0,
+ "max_val": 100,
+ "font_size": 19,
+ "number_format": ",.1f",
+ "currency_format": {"symbol": "%", "symbolPosition": "suffix"},
+ "value_formatter": "{value} complete",
+ "show_pointer": False,
+ "show_progress": False,
+ "intervals": "50,100",
+ "interval_color_indices": "2,4",
+ "adhoc_filters": [
+ {
+ "clause": "WHERE",
+ "expressionType": "SIMPLE",
+ "subject": "country",
+ "operator": "==",
+ "comparator": "US",
+ }
+ ],
+ }
+
+ def test_same_viz_omissions_preserve_native_controls(self) -> None:
+ config = GaugeChartConfig(
+ chart_type="gauge",
+ metric={"name": "progress", "aggregate": "AVG"},
+ )
+ merged = merge_chart_form_data(
+ self.existing_form_data(), map_gauge_config(config), config
+ )
+ assert merged["metric"]["label"] == "AVG(progress)"
+ assert merged["groupby"] == ["team"]
+ assert merged["row_limit"] == 4
+ assert merged["font_size"] == 19
+ assert merged["currency_format"]["symbol"] == "%"
+ assert merged["adhoc_filters"][0]["subject"] == "country"
+
+ def test_explicit_clears_clear_optional_controls(self) -> None:
+ config = GaugeChartConfig(
+ chart_type="gauge",
+ metric={"name": "progress", "aggregate": "AVG"},
+ groupby=[],
+ filters=[],
+ color_scheme=None,
+ currency_format=None,
+ time_range=None,
+ granularity_sqla=None,
+ intervals="",
+ interval_color_indices="",
+ )
+ merged = merge_chart_form_data(
+ self.existing_form_data(), map_gauge_config(config), config
+ )
+ assert merged["groupby"] == []
+ assert "adhoc_filters" not in merged
+ assert "color_scheme" not in merged
+ assert "currency_format" not in merged
+ assert merged["intervals"] == ""
+
+ def test_dataset_rebind_scrubs_roles_and_preserves_presentation(self) -> None:
+ config = GaugeChartConfig(
+ chart_type="gauge",
+ metric={"name": "new_progress", "aggregate": "AVG"},
+ )
+ merged = merge_chart_form_data(
+ self.existing_form_data(),
+ map_gauge_config(config),
+ config,
+ dataset_rebind=True,
+ )
+ assert merged["metric"]["label"] == "AVG(new_progress)"
+ assert "groupby" not in merged
+ assert "adhoc_filters" not in merged
+ assert merged["font_size"] == 19
+ assert merged["number_format"] == ",.1f"
+
+ def test_filter_clear_keeps_explicit_temporal_binding(self) -> None:
+ config = GaugeChartConfig(
+ chart_type="gauge",
+ metric={"name": "progress", "aggregate": "AVG"},
+ filters=[],
+ temporal_column="event_time",
+ )
+ mapped = map_config_to_form_data(config)
+ merged = merge_chart_form_data(self.existing_form_data(), mapped, config)
+ assert merged[MCP_DASHBOARD_TIME_FILTER_SUBJECT] == "event_time"
+ assert merged["adhoc_filters"] == [
+ {
+ "clause": "WHERE",
+ "expressionType": "SIMPLE",
+ "subject": "event_time",
+ "operator": "TEMPORAL_RANGE",
+ "comparator": "No filter",
+ }
+ ]
+
+ def test_cross_viz_does_not_inherit_gauge_controls(self) -> None:
+ config = GaugeChartConfig(
+ chart_type="gauge",
+ metric={"name": "progress", "aggregate": "AVG"},
+ )
+ existing = {**self.existing_form_data(), "viz_type": "pie", "donut": True}
+ merged = merge_chart_form_data(existing, map_gauge_config(config), config)
+ assert "donut" not in merged
+ assert merged["viz_type"] == "gauge_chart"
+
+
+class TestGaugeResultAndPreview:
+ """Gauge data and previews share exact frontend metric-label semantics."""
+
+ @staticmethod
+ def form_data() -> dict[str, Any]:
+ return {
+ "viz_type": "gauge_chart",
+ "metric": {
+ "expressionType": "SIMPLE",
+ "aggregate": "AVG",
+ "column": {"column_name": "progress"},
+ "label": "AVG(progress)",
+ },
+ "groupby": ["team"],
+ "min_val": 0,
+ "max_val": 100,
+ "number_format": ",.1f",
+ "value_formatter": "{value} complete",
+ "intervals": "50,100",
+ "interval_color_indices": "1,4",
+ }
+
+ @pytest.mark.parametrize(
+ ("metric", "expected"),
+ [
+ ("saved_progress", "saved_progress"),
+ (
+ {
+ "expressionType": "SIMPLE",
+ "aggregate": "AVG",
+ "column": {"columnName": "progress"},
+ "label": None,
+ },
+ "AVG(progress)",
+ ),
+ (
+ {
+ "expressionType": "SQL",
+ "sqlExpression": "AVG(progress)",
+ "label": "SLA",
+ },
+ "SLA",
+ ),
+ ],
+ )
+ def test_metric_result_aliases(self, metric: object, expected: str) -> None:
+ assert metric_result_label(metric) == expected
+
+ @pytest.mark.parametrize("bad_value", ["12", True, float("nan"), float("inf")])
+ def test_rejects_non_numeric_or_nonfinite_results(self, bad_value: object) -> None:
+ failure = validate_gauge_query_result(
+ {"queries": [{"data": [{"AVG(progress)": bad_value}]}]},
+ self.form_data(),
+ )
+ assert isinstance(failure, ChartError)
+
+ def test_rejects_errors_and_missing_metric_alias(self) -> None:
+ query_error = validate_gauge_query_result(
+ {"queries": [{"error": "database exploded", "data": []}]},
+ self.form_data(),
+ )
+ assert isinstance(query_error, ChartError)
+ assert "database exploded" in query_error.error
+ missing = validate_gauge_query_result(
+ {"queries": [{"data": [{"wrong": 12}]}]}, self.form_data()
+ )
+ assert isinstance(missing, ChartError)
+ assert "missing metric output" in missing.error
+
+ def test_ascii_preview_is_gauge_faithful_and_formatted(self) -> None:
+ preview = generate_gauge_ascii_preview(
+ [{"team": "Blue", "AVG(progress)": 67.25}], self.form_data()
+ )
+ assert isinstance(preview, str)
+ assert "Gauge Chart" in preview
+ assert "Range: 0 to 100" in preview
+ assert "Intervals: 50, 100" in preview
+ assert "Blue" in preview
+ assert "67.3 complete" in preview
+
+ def test_vega_preview_uses_radial_layers_and_controls(self) -> None:
+ form_data = {
+ **self.form_data(),
+ "show_progress": False,
+ "show_pointer": True,
+ "start_angle": 180,
+ "end_angle": 0,
+ }
+ preview = generate_gauge_vega_lite_preview(
+ [{"team": "Blue", "AVG(progress)": 67.25}], form_data
+ )
+ assert not isinstance(preview, ChartError)
+ spec = preview.specification
+ assert spec["usermeta"]["viz_type"] == "gauge_chart"
+ assert spec["columns"] == 1
+ marks = [layer["mark"]["type"] for layer in spec["spec"]["layer"]]
+ assert marks.count("arc") == 2 # two colored interval bands, no progress
+ assert "rule" in marks # pointer
+ assert "text" in marks
+
+ def test_empty_preview_is_safe(self) -> None:
+ ascii_preview = generate_gauge_ascii_preview([], self.form_data())
+ assert isinstance(ascii_preview, str)
+ assert "No data available" in ascii_preview
+ vega_preview = generate_gauge_vega_lite_preview([], self.form_data())
+ assert not isinstance(vega_preview, ChartError)
+ assert vega_preview.specification["data"]["values"] == []
+
+
+class TestGaugeDatasetValidation:
+ """Dataset resolution is exact-first and never guesses on collisions."""
+
+ @staticmethod
+ def context() -> DatasetContext:
+ return DatasetContext(
+ id=1,
+ table_name="metrics",
+ schema="public",
+ database_name="db",
+ available_columns=[
+ {"name": "Score", "type": "DOUBLE", "is_numeric": True},
+ {"name": "score", "type": "VARCHAR", "is_numeric": False},
+ {"name": "Team", "type": "VARCHAR", "is_numeric": False},
+ ],
+ available_metrics=[
+ {"name": "SLA", "expression": "AVG(Score)"},
+ {"name": "sla", "expression": "AVG(Score)"},
+ ],
+ )
+
+ def test_exact_case_wins_and_unique_casefold_normalizes(self) -> None:
+ from superset.mcp_service.chart.validation.dataset_validator import (
+ DatasetValidator,
+ )
+
+ context = self.context()
+ assert DatasetValidator.get_canonical_column_name("Score", context) == "Score"
+ assert DatasetValidator.get_canonical_column_name("team", context) == "Team"
+
+ def test_ambiguous_nonexact_column_and_metric_are_rejected(self) -> None:
+ from superset.mcp_service.chart.validation.dataset_validator import (
+ AmbiguousDatasetReferenceError,
+ DatasetValidator,
+ )
+
+ context = self.context()
+ with pytest.raises(AmbiguousDatasetReferenceError):
+ DatasetValidator.get_canonical_column_name("SCORE", context)
+ with pytest.raises(AmbiguousDatasetReferenceError):
+ DatasetValidator.get_canonical_metric_name("sLa", context)
+
+ def test_dataset_validation_surfaces_ambiguity(self) -> None:
+ from superset.mcp_service.chart.validation.dataset_validator import (
+ DatasetValidator,
+ )
+
+ config = GaugeChartConfig(
+ chart_type="gauge",
+ metric={"name": "SCORE", "aggregate": "AVG"},
+ )
+ valid, error = DatasetValidator.validate_against_dataset(
+ config, 1, self.context()
+ )
+ assert valid is False
+ assert error is not None
+ assert error.error_code == "AMBIGUOUS_DATASET_REFERENCE"
+
+ def test_non_numeric_simple_metric_rejected_but_count_allowed(
+ self, monkeypatch
+ ) -> None:
+ from superset.mcp_service.chart.plugins.gauge import GaugeChartPlugin
+ from superset.mcp_service.chart.validation.dataset_validator import (
+ DatasetValidator,
+ )
+
+ context = self.context()
+ monkeypatch.setattr(
+ DatasetValidator, "_get_dataset_context", lambda dataset_id: context
+ )
+ plugin = GaugeChartPlugin()
+ config = GaugeChartConfig(
+ chart_type="gauge",
+ metric={"name": "Team", "aggregate": "AVG"},
+ )
+ error = plugin.post_map_validate(config, map_gauge_config(config), 1)
+ assert error is not None
+ assert error.error_code == "NON_NUMERIC_GAUGE_METRIC"
+ count = GaugeChartConfig(
+ chart_type="gauge",
+ metric={"name": "Team", "aggregate": "COUNT"},
+ )
+ assert plugin.post_map_validate(count, map_gauge_config(count), 1) is None
+
+
+@pytest.mark.parametrize("value", [5, -5, 75])
+@pytest.mark.parametrize(
+ "preview_fn",
+ [
+ generate_gauge_ascii_preview,
+ generate_gauge_vega_lite_preview,
+ ],
+)
+def test_gauge_auto_bounds_accept_stable_intervals(
+ value: int,
+ preview_fn: Callable[..., Any],
+) -> None:
+ """Data-derived bounds must not invalidate stable configured thresholds."""
+ result = preview_fn(
+ [{"score": value}],
+ {
+ "metric": "score",
+ "intervals": "30,70,100",
+ },
+ )
+ assert not isinstance(result, ChartError)
+
+
+@pytest.mark.parametrize("bounds", [{"min_val": 40}, {"max_val": 90}])
+def test_gauge_explicit_bounds_still_reject_outside_intervals(
+ bounds: dict[str, int],
+) -> None:
+ """Only explicitly configured bounds constrain threshold validation."""
+ result = generate_gauge_ascii_preview(
+ [{"score": 5}],
+ {
+ "metric": "score",
+ "intervals": "30,70,100",
+ **bounds,
+ },
+ )
+ assert isinstance(result, ChartError)
+
+
+@pytest.mark.parametrize("angles", [(225, -45), (0, 180), (180, 0)])
+def test_gauge_vega_bands_span_adjacent_thresholds(angles: tuple[int, int]) -> None:
+ """Every background arc has explicit adjacent endpoints without stacking."""
+ result = generate_gauge_vega_lite_preview(
+ [{"score": 5}],
+ {
+ "metric": "score",
+ "min_val": 0,
+ "max_val": 100,
+ "intervals": "30,70,100",
+ "show_progress": False,
+ "start_angle": angles[0],
+ "end_angle": angles[1],
+ },
+ )
+ assert not isinstance(result, ChartError)
+ arcs = [
+ layer["encoding"]
+ for layer in result.specification["layer"]
+ if layer["mark"]["type"] == "arc"
+ ]
+ assert [(arc["theta"]["datum"], arc["theta2"]["datum"]) for arc in arcs] == [
+ (0, 0.3),
+ (0.3, 0.7),
+ (0.7, 1),
+ ]
+ assert all(arc["theta"]["stack"] is None for arc in arcs)
+
+
+def test_gauge_vega_clips_intervals_to_automatic_range() -> None:
+ """Off-dial bands are clipped rather than overlapping or rejecting data."""
+ result = generate_gauge_vega_lite_preview(
+ [{"score": 5}],
+ {
+ "metric": "score",
+ "intervals": "3,7,100",
+ "show_progress": False,
+ },
+ )
+ assert not isinstance(result, ChartError)
+ arcs = [
+ layer["encoding"]
+ for layer in result.specification["layer"]
+ if layer["mark"]["type"] == "arc"
+ ]
+ assert [(arc["theta"]["datum"], arc["theta2"]["datum"]) for arc in arcs] == [
+ (0, 0.3),
+ (0.3, 0.7),
+ (0.7, 1),
+ ]
+
+
+@pytest.mark.parametrize("comparator", [None, 123])
+def test_gauge_native_temporal_range_requires_comparator(comparator: Any) -> None:
+ """Native filters cannot silently discard a missing temporal restriction."""
+ with pytest.raises(ValidationError, match="requires a temporal comparator"):
+ GaugeChartConfig.model_validate(
+ {
+ "chart_type": "gauge",
+ "metric": "score",
+ "time_range": "Last week",
+ "adhoc_filters": [
+ {
+ "subject": "event_time",
+ "operator": "TEMPORAL_RANGE",
+ "comparator": comparator,
+ }
+ ],
+ }
+ )
+
+
+@pytest.mark.parametrize("temporal_column", [None, "other_time"])
+@pytest.mark.parametrize("comparator", ["No filter", "Last month"])
+def test_gauge_temporal_rebind_preserves_user_ranges(
+ temporal_column: str | None,
+ comparator: str,
+) -> None:
+ """A stale provenance subject does not make an edited range mapper-owned."""
+ original_filter = {
+ "clause": "WHERE",
+ "expressionType": "SIMPLE",
+ "subject": "event_time",
+ "operator": "TEMPORAL_RANGE",
+ "comparator": comparator,
+ }
+ user_filter = {**original_filter, "comparator": "Last week"}
+ existing = {
+ "viz_type": "gauge_chart",
+ "metric": "score",
+ MCP_DASHBOARD_TIME_FILTER_SUBJECT: "event_time",
+ "adhoc_filters": [original_filter, user_filter],
+ }
+ config = GaugeChartConfig(
+ chart_type="gauge",
+ metric={"name": "score", "saved_metric": True},
+ temporal_column=temporal_column,
+ )
+ mapped = map_config_to_form_data(config)
+ merged = merge_chart_form_data(existing, mapped, config)
+ expected = (
+ [user_filter] if comparator == "No filter" else [original_filter, user_filter]
+ )
+ if temporal_column:
+ expected += mapped["adhoc_filters"]
+ assert merged["adhoc_filters"] == expected
+ assert existing["adhoc_filters"] == [original_filter, user_filter]
+
+
+@pytest.mark.parametrize(
+ "renderer", [generate_gauge_ascii_preview, generate_gauge_vega_lite_preview]
+)
+def test_gauge_all_zero_auto_range(renderer: Callable[..., Any]) -> None:
+ """Zero-valued results remain renderable without weakening explicit bounds."""
+ result = renderer([{"score": 0}], {"viz_type": "gauge_chart", "metric": "score"})
+ assert not isinstance(result, ChartError)
+ if isinstance(result, str):
+ assert "Range: 0 to 1" in result
+ else:
+ assert result.specification["usermeta"]["min_val"] == 0
+ assert result.specification["usermeta"]["max_val"] == 1
+ for bounds in ({"min_val": 0, "max_val": 0}, {"max_val": 0}):
+ invalid = renderer(
+ [{"score": 0}], {"viz_type": "gauge_chart", "metric": "score", **bounds}
+ )
+ assert isinstance(invalid, ChartError)
+ assert invalid.error_type == "InvalidGaugeRange"
+
+
+@pytest.mark.parametrize("viz_type", [None, "table"])
+@pytest.mark.parametrize(
+ "renderer", [generate_gauge_ascii_preview, generate_gauge_vega_lite_preview]
+)
+def test_gauge_renderer_always_validates(
+ viz_type: str | None, renderer: Callable[..., Any]
+) -> None:
+ """Direct Gauge rendering validates regardless of the caller's discriminator."""
+ for data, metric, error in (
+ ([{"score": "bad"}], "score", "NonNumericGaugeMetric"),
+ ({}, "score", "InvalidGaugeResult"),
+ ([{}], None, "InvalidGaugeFormData"),
+ ):
+ result = renderer(data, {"viz_type": viz_type, "metric": metric})
+ assert isinstance(result, ChartError)
+ assert result.error_type == error
+
+
+def test_gauge_metric_label_has_no_arbitrary_size_caps() -> None:
+ """Query output labels follow getMetricLabel, not incidental metadata size."""
+ from superset.utils.core import get_metric_name
+
+ metrics: list[Any] = [
+ {"expressionType": "SQL", "sqlExpression": "x + " * 600 + "1"},
+ {
+ "expressionType": "SIMPLE",
+ "aggregate": "AVG",
+ "column": {"column_name": "score"},
+ **{f"metadata_{i}": i for i in range(30)},
+ },
+ "saved_score",
+ ]
+ for metric in metrics:
+ label = get_metric_name(metric)
+ assert metric_result_label(metric) == label
+ assert (
+ validate_gauge_query_result(
+ {"queries": [{"data": [{label: 42}]}]},
+ {"viz_type": "gauge_chart", "metric": metric},
+ )
+ is None
+ )
+
+
+@pytest.mark.parametrize(
+ "second,top_level",
+ [
+ ({"subject": "other_time"}, {}),
+ ({"comparator": "Last month"}, {}),
+ (None, {"time_range": "Last month"}),
+ (None, {"granularity_sqla": "other_time"}),
+ ],
+)
+def test_gauge_native_temporal_conflicts_reject(
+ second: dict[str, str] | None, top_level: dict[str, str]
+) -> None:
+ """Native restrictions cannot disappear during typed normalization."""
+ temporal = {
+ "expressionType": "SIMPLE",
+ "clause": "WHERE",
+ "subject": "event_time",
+ "operator": "TEMPORAL_RANGE",
+ "comparator": "Last week",
+ }
+ with pytest.raises(ValidationError, match="conflicts with another temporal range"):
+ GaugeChartConfig.model_validate(
+ {
+ "viz_type": "gauge_chart",
+ "metric": "score",
+ "adhoc_filters": [temporal, {**temporal, **second}]
+ if second
+ else [temporal],
+ **top_level,
+ }
+ )
+
+
+@pytest.mark.parametrize(
+ "operator",
+ [
+ "NOT ILIKE",
+ "IS TRUE",
+ "IS FALSE",
+ "CONTAINS_ANY",
+ "CONTAINS_ALL",
+ "IS_EMPTY",
+ "IS_NOT_EMPTY",
+ "LENGTH_EQUALS",
+ ],
+)
+def test_gauge_unsupported_native_operator_has_indexed_error(operator: str) -> None:
+ """Unsupported native operators are rejected at the adaptation boundary."""
+ with pytest.raises(
+ ValidationError, match=r"adhoc_filters\[0\] uses unsupported operator"
+ ):
+ GaugeChartConfig.model_validate(
+ {
+ "viz_type": "gauge_chart",
+ "metric": "score",
+ "adhoc_filters": [
+ {"subject": "team", "operator": operator, "comparator": "a"}
+ ],
+ }
+ )
+
+
+@pytest.mark.parametrize("groupby", [[], ["team"]])
+@pytest.mark.parametrize("show_pointer", [True, False])
+def test_gauge_pointer_uses_cartesian_endpoints(
+ groupby: list[str], show_pointer: bool
+) -> None:
+ """Vega-Lite rules need Cartesian endpoints even on a polar dial."""
+ preview = generate_gauge_vega_lite_preview(
+ [{"score": 50, "team": "Blue"}],
+ {
+ "metric": "score",
+ "groupby": groupby,
+ "show_pointer": show_pointer,
+ "min_val": 0,
+ "max_val": 100,
+ },
+ )
+ assert not isinstance(preview, ChartError)
+ specification = preview.specification
+ unit = specification["spec"] if groupby else specification
+ if groupby:
+ assert unit["width"] == 200
+ assert unit["height"] == 300
+ assert "width" not in specification
+ rules = [layer for layer in unit["layer"] if layer["mark"]["type"] == "rule"]
+ assert len(rules) == int(show_pointer)
+ if show_pointer:
+ encoding = rules[0]["encoding"]
+ assert set(encoding) == {"x", "y", "x2", "y2", "tooltip"}
+ if groupby:
+ # Numeric child-scoped origins must not depend on outer Vega signals.
+ assert float(encoding["x"]["value"]["expr"]) == unit["width"] / 2
+ assert float(encoding["y"]["value"]["expr"]) == unit["height"] / 2
+ else:
+ assert encoding["x"]["value"]["expr"] == "width / 2"
+ assert encoding["y"]["value"]["expr"] == "height / 2"
+ arc = next(
+ layer["mark"] for layer in unit["layer"] if layer["mark"]["type"] == "arc"
+ )
+ radius = (arc["innerRadius"] + arc["outerRadius"]) / 2
+ assert f"+ {radius} * sin" in encoding["x2"]["value"]["expr"]
+ assert f"- {radius} * cos" in encoding["y2"]["value"]["expr"]
+ assert "sin(datum.__mcp_gauge_angle)" in encoding["x2"]["value"]["expr"]
+ assert "cos(datum.__mcp_gauge_angle)" in encoding["y2"]["value"]["expr"]
+
+
+@pytest.mark.parametrize(
+ "renderer", [generate_gauge_ascii_preview, generate_gauge_vega_lite_preview]
+)
+def test_gauge_mixed_finite_dials_survive(renderer: Callable[..., Any]) -> None:
+ """Empty aggregates do not discard other groups, in either preview format."""
+ rows = [
+ {"team": str(i), "score": value}
+ for i, value in enumerate(
+ [None, float("nan"), float("inf"), -float("inf"), "bad", True, 0, 42]
+ )
+ ]
+ result = renderer(rows, {"metric": "score", "groupby": ["team"]})
+ assert not isinstance(result, ChartError)
+ if renderer == generate_gauge_vega_lite_preview:
+ assert [r["score"] for r in result.specification["data"]["values"]] == [0, 42]
+ else:
+ assert "42" in result
+ assert "nan" not in result.lower()
+ assert len(rows) == 8
+
+
+@pytest.mark.parametrize("temporal_column", ["omitted", "event_time", None])
+def test_gauge_duplicate_generated_binding_self_heals(
+ temporal_column: str | None,
+) -> None:
+ """Updating a legacy Gauge removes redundant neutral filters, not user ranges."""
+ binding = {
+ "expressionType": "SIMPLE",
+ "clause": "WHERE",
+ "subject": "event_time",
+ "operator": "TEMPORAL_RANGE",
+ "comparator": "No filter",
+ }
+ config = GaugeChartConfig.model_validate(
+ {
+ "metric": {"name": "score", "saved_metric": True},
+ **(
+ {"temporal_column": temporal_column}
+ if temporal_column != "omitted"
+ else {}
+ ),
+ }
+ )
+ existing = {
+ "viz_type": "gauge_chart",
+ "metric": "score",
+ MCP_DASHBOARD_TIME_FILTER_SUBJECT: "event_time",
+ "adhoc_filters": [
+ binding,
+ dict(binding),
+ {**binding, "comparator": "Last week"},
+ ],
+ }
+ mapped = map_config_to_form_data(config)
+ merged = merge_chart_form_data(existing, mapped, config)
+ assert (
+ len([f for f in merged["adhoc_filters"] if f["comparator"] == "No filter"]) <= 1
+ )
+ assert [f for f in merged["adhoc_filters"] if f["comparator"] == "Last week"] == [
+ {**binding, "comparator": "Last week"}
+ ]
+
+
+@pytest.mark.parametrize("top_range", ["Last week", "No filter", None])
+def test_gauge_native_no_filter_is_a_dashboard_binding(top_range: str | None) -> None:
+ """The neutral native sentinel must not conflict with a real time restriction."""
+ config = GaugeChartConfig.model_validate(
+ {
+ "metric": "score",
+ "time_range": top_range,
+ "granularity_sqla": "event_time",
+ "adhoc_filters": [
+ {
+ "subject": "event_time",
+ "operator": "TEMPORAL_RANGE",
+ "comparator": "No filter",
+ },
+ {
+ "subject": "event_time",
+ "operator": "TEMPORAL_RANGE",
+ "comparator": "Last week",
+ },
+ ],
+ }
+ )
+ assert config.time_range == "Last week"
+ assert config.temporal_column == "event_time"
+ mapped = map_config_to_form_data(config)
+ assert mapped["time_range"] == "Last week"
+ assert mapped["adhoc_filters"][0]["comparator"] == "No filter"
+
+
+def test_gauge_finite_normalization_does_not_mutate_cached_result() -> None:
+ """Filtering dials copies the envelope and updates the returned row count."""
+ from superset.mcp_service.chart.query_result import normalize_gauge_query_result
+
+ rows = [{"score": None}, {"score": 0}, {"score": 42}, {"score": float("inf")}]
+ original = {"queries": [{"data": rows, "rowcount": 4, "colnames": ["score"]}]}
+ normalized = normalize_gauge_query_result(
+ original, {"viz_type": "gauge_chart", "metric": "score"}
+ )
+ assert not isinstance(normalized, ChartError)
+ assert normalized["queries"][0]["data"] == [{"score": 0}, {"score": 42}]
+ assert normalized["queries"][0]["rowcount"] == 2
+ assert normalized["queries"][0]["colnames"] == ["score"]
+ assert original["queries"][0]["data"] is rows
+ assert original["queries"][0]["rowcount"] == 4
+ assert len(rows) == 4
+
+
+@pytest.mark.parametrize("top_range", ["", " ", "\t", "No filter", " No filter "])
+@pytest.mark.parametrize("comparator", ["Last week", "", " ", "No filter"])
+def test_gauge_native_time_sentinels_are_neutral(
+ top_range: str, comparator: str
+) -> None:
+ """Explicit blank sentinels are neutral; absent comparators still reject."""
+ config = GaugeChartConfig.model_validate(
+ {
+ "metric": "score",
+ "time_range": top_range,
+ "adhoc_filters": [
+ {
+ "subject": "event_time",
+ "operator": "TEMPORAL_RANGE",
+ "comparator": comparator,
+ }
+ ],
+ }
+ )
+ if comparator == "Last week":
+ assert config.time_range == "Last week"
+ assert config.granularity_sqla == "event_time"
+ else:
+ assert config.temporal_column == "event_time"
+ assert config.time_range in (None, "No filter")
diff --git a/tests/unit_tests/mcp_service/chart/test_gauge_render.py b/tests/unit_tests/mcp_service/chart/test_gauge_render.py
new file mode 100644
index 00000000000..4abe3ce1ab6
--- /dev/null
+++ b/tests/unit_tests/mcp_service/chart/test_gauge_render.py
@@ -0,0 +1,106 @@
+# 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.
+
+"""Optional renderer regression: provide Node's vega@5 and vega-lite@5 via NODE_PATH."""
+
+import os
+import shutil
+import subprocess
+
+import pytest
+
+from superset.mcp_service.chart.preview_utils import generate_gauge_vega_lite_preview
+from superset.mcp_service.chart.schemas import ChartError
+from superset.utils import json
+
+
+@pytest.mark.skipif(
+ not os.environ.get("NODE_PATH"), reason="Requires vega@5/vega-lite@5 in NODE_PATH"
+)
+def test_gauge_rendered_pointer_origin_matches_arc_center() -> None:
+ """Check actual scenegraph geometry in every facet, not only needle length."""
+ specs = []
+ for groupby in ([], ["team"]):
+ for pointer in (True, False):
+ for start, end in ((225, -45), (180, 0), (90, -90)):
+ preview = generate_gauge_vega_lite_preview(
+ [{"team": "A", "score": 0}, {"team": "B", "score": 75}]
+ if groupby
+ else [{"score": 75}],
+ {
+ "metric": "score",
+ "groupby": groupby,
+ "show_pointer": pointer,
+ "start_angle": start,
+ "end_angle": end,
+ "min_val": 0,
+ "max_val": 100,
+ "intervals": "30,70,100",
+ },
+ )
+ assert not isinstance(preview, ChartError)
+ specs.append(preview.specification)
+ script = r"""
+const assert = require('assert/strict');
+const vl = require('vega-lite');
+const vega = require('vega');
+const fs = require('fs');
+(async () => {
+ for (const spec of JSON.parse(fs.readFileSync(0, 'utf8'))) {
+ const warnings = [];
+ const compiled = vl.compile(spec, {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();
+ assert((await view.toSVG()).includes('<path'));
+ const rules = [];
+ function walk(item, type, target) {
+ if (item.mark?.marktype === type) target.push(item);
+ for (const child of item.items || []) walk(child, type, target);
+ }
+ walk(view.scenegraph().root, 'rule', rules);
+ assert.equal(rules.length, spec.usermeta.show_pointer ? (spec.facet ? 2 : 1) : 0);
+ for (const rule of rules) {
+ const arcs = [];
+ walk(rule.mark.group, 'arc', arcs);
+ assert(arcs.length > 0);
+ for (const arc of arcs) {
+ assert.equal(rule.x, arc.x, 'needle x must be at its own dial center');
+ assert.equal(rule.y, arc.y, 'needle y must be at its own dial center');
+ }
+ const radius = (arcs[0].innerRadius + arcs[0].outerRadius) / 2;
+ assert(radius > arcs[0].innerRadius && radius < arcs[0].outerRadius);
+ assert(rule.x > 0 && rule.y > 0);
+ assert([rule.x, rule.y, rule.x2, rule.y2].every(Number.isFinite));
+ assert(Math.abs(Math.hypot(rule.x2 - rule.x, rule.y2 - rule.y) - radius) < 1e-8);
+ const angle = rule.datum.__mcp_gauge_angle;
+ assert(Math.abs(rule.x2 - rule.x - radius * Math.sin(angle)) < 1e-8);
+ assert(Math.abs(rule.y2 - rule.y + radius * Math.cos(angle)) < 1e-8);
+ }
+ view.finalize();
+ }
+})().catch(error => { console.error(error); process.exit(1); });
+"""
+ node = shutil.which("node")
+ assert node is not None, "The opted-in renderer regression requires Node"
+ # Execute only this fixed test script; generated fixture data enters via stdin.
+ subprocess.run( # noqa: S603
+ [node, "-e", script], input=json.dumps(specs), text=True, check=True
+ )
diff --git a/tests/unit_tests/mcp_service/chart/test_preview_utils.py b/tests/unit_tests/mcp_service/chart/test_preview_utils.py
index fcac2b97f05..41925b85a8f 100644
--- a/tests/unit_tests/mcp_service/chart/test_preview_utils.py
+++ b/tests/unit_tests/mcp_service/chart/test_preview_utils.py
@@ -22,6 +22,7 @@ Tests for preview_utils query context column building.
import ast
import inspect
from pathlib import Path
+from unittest.mock import Mock, patch
from superset.mcp_service.chart import preview_utils
@@ -209,3 +210,73 @@ def test_build_query_columns_empty_columns_key_keeps_groupby():
assert preview_utils._build_query_columns(
{"groupby": ["country"], "columns": []}
) == ["country"]
+
+
+@patch("superset.commands.chart.data.get_data_command.ChartDataCommand")
+@patch("superset.mcp_service.chart.chart_helpers.build_query_context_from_form_data")
+@patch("superset.extensions.db.session.get")
+def test_unsaved_gauge_preview_uses_shared_builder_and_preserves_ordering(
+ mock_find_dataset, mock_build_query_context, mock_command
+):
+ """Unsaved previews execute the same Gauge QueryObject path as Explore."""
+ mock_find_dataset.return_value = Mock(id=7)
+ mock_build_query_context.return_value = Mock()
+ mock_command.return_value.validate.return_value = None
+ mock_command.return_value.run.return_value = {
+ "queries": [{"data": [{"AVG(score)": 75}]}]
+ }
+ form_data = {
+ "viz_type": "gauge_chart",
+ "metric": {
+ "expressionType": "SIMPLE",
+ "aggregate": "AVG",
+ "column": {"column_name": "score"},
+ "label": "AVG(score)",
+ },
+ "groupby": [],
+ "sort_by_metric": True,
+ "row_limit": 4,
+ "intervals": "30,70,200",
+ "datasource_id": 99,
+ "datasource_type": "query",
+ "datasource": "99__query",
+ }
+
+ result = preview_utils.generate_preview_from_form_data(
+ form_data, dataset_id=7, preview_format="ascii"
+ )
+
+ assert result.ascii_content.startswith("Gauge Chart")
+ query_form_data = mock_build_query_context.call_args.args[0]
+ assert query_form_data["sort_by_metric"] is True
+ assert query_form_data["datasource"] == "7__table"
+ assert query_form_data["datasource_id"] == 7
+ assert query_form_data["datasource_type"] == "table"
+ from superset.mcp_service.chart.chart_helpers import resolve_form_data_datasource
+
+ assert resolve_form_data_datasource(query_form_data) == (7, "table")
+ assert form_data["datasource_id"] == 99
+ mock_build_query_context.assert_called_once_with(
+ query_form_data, row_limit=4, force=False
+ )
+
+
+@patch("superset.commands.chart.data.get_data_command.ChartDataCommand")
+@patch("superset.mcp_service.chart.chart_helpers.build_query_context_from_form_data")
+@patch("superset.extensions.db.session.get")
+def test_unsaved_gauge_preview_surfaces_query_error(
+ mock_find_dataset, mock_build_query_context, mock_command
+):
+ mock_find_dataset.return_value = Mock(id=7)
+ mock_build_query_context.return_value = Mock()
+ mock_command.return_value.validate.return_value = None
+ mock_command.return_value.run.return_value = {
+ "queries": [{"status": "failed", "error": "bad metric", "data": []}]
+ }
+ result = preview_utils.generate_preview_from_form_data(
+ {"viz_type": "gauge_chart", "metric": "saved_sla"},
+ dataset_id=7,
+ preview_format="vega_lite",
+ )
+ assert result.error_type == "QueryError"
+ assert "bad metric" in result.error
diff --git a/tests/unit_tests/mcp_service/chart/tool/test_generate_chart.py b/tests/unit_tests/mcp_service/chart/tool/test_generate_chart.py
index a740970c641..7398f4b86ea 100644
--- a/tests/unit_tests/mcp_service/chart/tool/test_generate_chart.py
+++ b/tests/unit_tests/mcp_service/chart/tool/test_generate_chart.py
@@ -30,6 +30,7 @@ from superset.mcp_service.chart.schemas import (
AxisConfig,
ColumnRef,
FilterConfig,
+ GaugeChartConfig,
GenerateChartRequest,
LegendConfig,
TableChartConfig,
@@ -98,6 +99,91 @@ class TestGenerateChart:
assert result.chart_type_label == "table chart"
+ @pytest.mark.asyncio
+ @pytest.mark.parametrize("has_finite", [True, False])
+ async def test_unsaved_gauge_generate_preserves_controls_and_compiles(
+ self, has_finite: bool
+ ) -> None:
+ """Gauge generate returns native form_data and checks concrete values."""
+ request = GenerateChartRequest(
+ dataset_id=7,
+ config=GaugeChartConfig(
+ chart_type="gauge",
+ metric={"name": "score", "aggregate": "AVG"},
+ groupby=[{"name": "team"}],
+ min_val=0,
+ max_val=100,
+ number_format=",.1f",
+ value_formatter="{value}%",
+ show_pointer=False,
+ intervals="50,100",
+ interval_color_indices="1,3",
+ ),
+ preview_formats=["url"],
+ )
+ ctx = MagicMock(
+ info=AsyncMock(),
+ debug=AsyncMock(),
+ warning=AsyncMock(),
+ error=AsyncMock(),
+ report_progress=AsyncMock(),
+ )
+ validation_result = Mock(
+ is_valid=True, request=request, warnings={}, error=None
+ )
+ dataset = Mock(id=7, datasource_name="scores", table_name="scores")
+ 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=validation_result,
+ ),
+ patch(
+ "superset.mcp_service.chart.chart_utils.generate_explore_link",
+ return_value="http://localhost/explore/?form_data_key=gauge-key",
+ ),
+ 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(),
+ ) as mock_build,
+ patch(
+ "superset.commands.chart.data.get_data_command.ChartDataCommand",
+ ) as mock_command,
+ ):
+ mock_command.return_value.run.return_value = {
+ "queries": [
+ {
+ "data": [
+ {"team": "Empty", "AVG(score)": None},
+ {"team": "NaN", "AVG(score)": float("nan")},
+ ]
+ + ([{"team": "Blue", "AVG(score)": 75}] if has_finite else [])
+ }
+ ]
+ }
+ result = await generate_chart(request, ctx=ctx)
+ if not has_finite:
+ assert result.success is False
+ assert result.error is not None
+ return
+
+ assert result.success is True
+ assert result.form_data["viz_type"] == "gauge_chart"
+ assert result.form_data["metric"]["label"] == "AVG(score)"
+ assert result.form_data["groupby"] == ["team"]
+ assert result.form_data["show_pointer"] is False
+ assert result.form_data["intervals"] == "50,100"
+ assert mock_build.call_args.kwargs["row_limit"] == 10
+ mock_command.return_value.validate.assert_called_once()
+
@pytest.mark.asyncio
async def test_generate_chart_request_structure(self):
"""Test that chart generation request structures are properly formed."""
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 4bb74aaff0d..5570ad0562b 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
@@ -23,9 +23,10 @@ import importlib
from contextlib import nullcontext
from types import SimpleNamespace
from typing import Any
-from unittest.mock import MagicMock
+from unittest.mock import MagicMock, patch
import pytest
+from fastmcp import Client
from superset.mcp_service.chart.chart_helpers import (
rejected_requested_filter_columns,
@@ -513,6 +514,86 @@ class _AsyncContext:
class TestUnsavedChartDataQueryConstruction:
+ @pytest.mark.asyncio
+ async def test_gauge_preserves_sort_order_and_validates_saved_metric_output(
+ self, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ """Unsaved Gauge get-data uses buildQuery ordering and numeric checks."""
+ chart_data_module = importlib.import_module(
+ "superset.mcp_service.chart.tool.get_chart_data"
+ )
+ query_context_factory_module = importlib.import_module(
+ "superset.common.query_context_factory"
+ )
+ get_data_command_module = importlib.import_module(
+ "superset.commands.chart.data.get_data_command"
+ )
+ captured: list[dict[str, Any]] = []
+
+ class QueryContextFactory:
+ def create(self, **kwargs: Any) -> object:
+ captured.append(kwargs)
+ return object()
+
+ class ChartDataCommand:
+ def __init__(self, query_context: object) -> None:
+ self.query_context = query_context
+
+ def validate(self) -> None:
+ pass
+
+ def run(self) -> dict[str, Any]:
+ return {
+ "queries": [
+ {
+ "data": [
+ {"team": "Empty", "saved_sla": None},
+ {"team": "Blue", "saved_sla": 98.5},
+ {"team": "NaN", "saved_sla": float("nan")},
+ ],
+ "colnames": ["team", "saved_sla"],
+ "rowcount": 3,
+ }
+ ]
+ }
+
+ monkeypatch.setattr(
+ query_context_factory_module, "QueryContextFactory", QueryContextFactory
+ )
+ monkeypatch.setattr(
+ get_data_command_module, "ChartDataCommand", ChartDataCommand
+ )
+ monkeypatch.setattr(
+ chart_data_module,
+ "event_logger",
+ SimpleNamespace(log_context=lambda **kwargs: nullcontext()),
+ )
+ monkeypatch.setattr(
+ "superset.mcp_service.chart.chart_helpers.resolve_datasource_engine",
+ lambda datasource_id, datasource_type: "base",
+ )
+ result = await _query_from_form_data(
+ {
+ "datasource": "1__table",
+ "viz_type": "gauge_chart",
+ "metric": "saved_sla",
+ "groupby": ["team"],
+ "sort_by_metric": True,
+ "row_limit": 5,
+ },
+ GetChartDataRequest(form_data_key="cached-key"),
+ _AsyncContext(),
+ )
+
+ assert not isinstance(result, ChartError)
+ assert [row["team"] for row in result.data] == ["Empty", "Blue", "NaN"]
+ assert result.row_count == result.total_rows == 3
+ assert result.data_quality["completeness"] == pytest.approx(5 / 6)
+ query = captured[0]["queries"][0]
+ assert query["metrics"] == ["saved_sla"]
+ assert query["orderby"] == [("saved_sla", False)]
+ assert query["row_limit"] == 5
+
@pytest.mark.asyncio
async def test_form_data_key_adhoc_filters_become_query_filters(
self,
@@ -1673,6 +1754,169 @@ class TestSavedChartExtraFormDataFilters:
assert "does_not_exist" in data["error"]
assert "USA" not in result.content[0].text
+ @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_gauge_fastmcp_preserves_raw_groups_and_exports(
+ self,
+ mcp_server: Any,
+ mock_auth: Any,
+ data_path: str,
+ export_format: str,
+ has_finite: bool,
+ ) -> None:
+ """Raw Gauge inspection and exports retain every source group, even all-null."""
+ module = importlib.import_module(
+ "superset.mcp_service.chart.tool.get_chart_data"
+ )
+ chart = SimpleNamespace(
+ id=10,
+ slice_name="SLA",
+ viz_type="gauge_chart",
+ 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": "gauge_chart",
+ "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": "Empty", "saved_sla": None},
+ {"team": "NaN", "saved_sla": float("nan")},
+ {"team": "Infinity", "saved_sla": float("inf")},
+ {"team": "Negative infinity", "saved_sla": -float("inf")},
+ ]
+ if has_finite:
+ rows.append({"team": "Blue", "saved_sla": 42})
+ 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": "gauge_chart",
+ "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-gauge-cache"
+ result = await client.call_tool("get_chart_data", {"request": request})
+
+ data = json.loads(result.content[0].text)
+ assert "error_type" not in data, data
+ 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 - 1 / (len(rows) * 2)
+ )
+ 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:]] == [
+ None,
+ "nan",
+ "inf",
+ "-inf",
+ ] + ([42] if has_finite else [])
+ 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.
@@ -1942,6 +2186,14 @@ def test_recommend_single_numeric_suggests_kpi():
cols = [_col("total_revenue", "numeric")]
result = _recommend_visualizations("table", cols, row_count=1)
assert "big number / KPI" in result
+ assert "gauge chart" in result
+
+
+def test_recommend_single_numeric_excludes_current_gauge():
+ cols = [_col("total_revenue", "numeric")]
+ result = _recommend_visualizations("gauge_chart", cols, row_count=1)
+ assert "gauge chart" not in result
+ assert "big number / KPI" in result
def test_recommend_all_strings_falls_back():
@@ -2618,3 +2870,30 @@ async def test_query_from_form_data_refreshed_reflects_force_refresh_only(
assert isinstance(result, ChartData)
assert result.cache_status is not None
assert result.cache_status.refreshed is expected_refreshed
+
+
+def test_xlsxwriter_preserves_nonfinite_group_rows() -> None:
+ """The optional XLSX fallback preserves groups with non-finite metrics."""
+ import base64
+ from io import BytesIO
+
+ from openpyxl import load_workbook
+
+ from superset.mcp_service.chart.tool.get_chart_data import (
+ _create_excel_with_xlsxwriter,
+ )
+
+ rows: list[dict[str, Any]] = [
+ {"team": "Empty", "score": None},
+ {"team": "NaN", "score": float("nan")},
+ {"team": "Infinity", "score": float("inf")},
+ {"team": "Negative infinity", "score": -float("inf")},
+ {"team": "Finite", "score": 42},
+ ]
+ chart = MagicMock(slice_name="Gauge")
+ content = _create_excel_with_xlsxwriter(chart, rows, ["team", "score"])
+ workbook = load_workbook(BytesIO(base64.b64decode(content)))
+ assert list(workbook.active.values)[0] == ("team", "score")
+ assert [row[0] for row in list(workbook.active.values)[1:]] == [
+ row["team"] for row in rows
+ ]
diff --git a/tests/unit_tests/mcp_service/chart/tool/test_get_chart_preview.py b/tests/unit_tests/mcp_service/chart/tool/test_get_chart_preview.py
index a3127904fc3..6534e6f2bfc 100644
--- a/tests/unit_tests/mcp_service/chart/tool/test_get_chart_preview.py
+++ b/tests/unit_tests/mcp_service/chart/tool/test_get_chart_preview.py
@@ -47,10 +47,85 @@ from superset.mcp_service.chart.tool.get_chart_preview import (
ASCIIPreviewStrategy,
PreviewFormatStrategy,
TablePreviewStrategy,
+ VegaLitePreviewStrategy,
)
from superset.utils import json as utils_json
+def _gauge_chart() -> SimpleNamespace:
+ """Build a saved Gauge chart with its native form_data controls."""
+ return SimpleNamespace(
+ id=104,
+ slice_name="SLA Gauge",
+ viz_type="gauge_chart",
+ datasource_id=1,
+ datasource_type="table",
+ params=utils_json.dumps(
+ {
+ "viz_type": "gauge_chart",
+ "metric": "saved_sla",
+ "groupby": ["team"],
+ "row_limit": 3,
+ "min_val": 0,
+ "max_val": 100,
+ "value_formatter": "{value}%",
+ }
+ ),
+ )
+
+
+@patch("superset.commands.chart.data.get_data_command.ChartDataCommand")
+@patch(
+ "superset.mcp_service.chart.tool.get_chart_preview."
+ "build_query_context_from_form_data"
+)
+def test_saved_gauge_ascii_preview_uses_native_row_limit_and_renderer(
+ mock_build_query_context, mock_command
+) -> None:
+ query_context = SimpleNamespace(
+ queries=[SimpleNamespace(metrics=["saved_sla"], columns=["team"])]
+ )
+ mock_build_query_context.return_value = query_context
+ mock_command.return_value.validate.return_value = None
+ mock_command.return_value.run.return_value = {
+ "queries": [{"data": [{"team": "Blue", "saved_sla": 88}]}]
+ }
+
+ preview = ASCIIPreviewStrategy(
+ _gauge_chart(), GetChartPreviewRequest(identifier=104, format="ascii")
+ ).generate()
+
+ assert isinstance(preview, ASCIIPreview)
+ assert "Gauge Chart" in preview.ascii_content
+ assert "Blue" in preview.ascii_content
+ assert "88%" in preview.ascii_content
+ assert mock_build_query_context.call_args.kwargs["row_limit"] == 3
+
+
+@patch("superset.commands.chart.data.get_data_command.ChartDataCommand")
+@patch(
+ "superset.mcp_service.chart.tool.get_chart_preview."
+ "build_query_context_from_form_data"
+)
+def test_saved_gauge_vega_preview_surfaces_runtime_metric_error(
+ mock_build_query_context, mock_command
+) -> None:
+ mock_build_query_context.return_value = SimpleNamespace(
+ queries=[SimpleNamespace(metrics=["saved_sla"], columns=["team"])]
+ )
+ mock_command.return_value.validate.return_value = None
+ mock_command.return_value.run.return_value = {
+ "queries": [{"data": [{"team": "Blue", "saved_sla": "bad"}]}]
+ }
+
+ preview = VegaLitePreviewStrategy(
+ _gauge_chart(), GetChartPreviewRequest(identifier=104, format="vega_lite")
+ ).generate()
+
+ assert isinstance(preview, ChartError)
+ assert preview.error_type == "NonNumericGaugeMetric"
+
+
class TestPreviewXAxisInQueryContext:
"""Tests for x_axis inclusion in preview query context columns.
@@ -1439,3 +1514,80 @@ def test_authorize_guest_query_noop_for_non_guest() -> None:
strategy._authorize_guest_query(MagicMock())
mock_authorize.assert_not_called()
+
+
+@pytest.mark.parametrize("stored_viz_type", [None, "table", "gauge_chart"])
+@pytest.mark.parametrize(
+ "strategy", [ASCIIPreviewStrategy, TablePreviewStrategy, VegaLitePreviewStrategy]
+)
+def test_saved_gauge_dispatch_and_validation_agree(
+ stored_viz_type: str | None, strategy: type[PreviewFormatStrategy]
+) -> None:
+ """Saved chart identity drives query construction and numeric validation."""
+ chart = _gauge_chart()
+ form_data = utils_json.loads(chart.params)
+ form_data["viz_type"] = stored_viz_type
+ chart.params = utils_json.dumps(form_data)
+ with (
+ patch(
+ "superset.mcp_service.chart.tool.get_chart_preview.build_query_context_from_form_data"
+ ) as build,
+ patch(
+ "superset.commands.chart.data.get_data_command.ChartDataCommand"
+ ) as command,
+ ):
+ build.return_value = SimpleNamespace(
+ queries=[SimpleNamespace(metrics=["saved_sla"])]
+ )
+ command.return_value.run.return_value = {
+ "queries": [{"data": [{"team": "Blue", "saved_sla": "bad"}]}]
+ }
+ result = strategy(chart, GetChartPreviewRequest(identifier=104)).generate()
+ assert isinstance(result, ChartError)
+ assert result.error_type == "NonNumericGaugeMetric"
+ assert build.call_args.args[0]["viz_type"] == "gauge_chart"
+
+
+@pytest.mark.parametrize(
+ "strategy", [ASCIIPreviewStrategy, TablePreviewStrategy, VegaLitePreviewStrategy]
+)
+def test_saved_gauge_preview_skips_empty_aggregate_groups(
+ strategy: type[PreviewFormatStrategy],
+) -> None:
+ """Every saved preview format retains the finite dial from mixed query output."""
+ chart = _gauge_chart()
+ with (
+ patch(
+ "superset.mcp_service.chart.tool.get_chart_preview.build_query_context_from_form_data"
+ ) as build,
+ patch(
+ "superset.commands.chart.data.get_data_command.ChartDataCommand"
+ ) as command,
+ ):
+ build.return_value = SimpleNamespace(
+ queries=[SimpleNamespace(metrics=["saved_sla"])]
+ )
+ command.return_value.run.return_value = {
+ "queries": [
+ {
+ "data": [
+ {"team": "Empty", "saved_sla": None},
+ {"team": "NaN", "saved_sla": float("nan")},
+ {"team": "Blue", "saved_sla": 42},
+ ]
+ }
+ ]
+ }
+ result = strategy(chart, GetChartPreviewRequest(identifier=104)).generate()
+ assert not isinstance(result, ChartError)
+ if isinstance(result, VegaLitePreview):
+ assert [row["saved_sla"] for row in result.specification["data"]["values"]] == [
+ 42
+ ]
+ elif isinstance(result, TablePreview):
+ assert result.row_count == 1
+ assert "Blue" in result.table_data
+ assert "Empty" not in result.table_data
+ else:
+ assert "Blue" in result.ascii_content
+ assert "Empty" not in result.ascii_content
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 687bbc6f606..8a2ab50f6bf 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
@@ -74,6 +74,35 @@ class TestGetChartTypeSchema:
props = result["schema"]["properties"]
assert "metric" in props
+ def test_gauge_uses_public_identity_and_full_control_schema(self) -> None:
+ result = _call_schema("gauge")
+ assert result["chart_type"] == "gauge"
+ props = result["schema"]["properties"]
+ assert {
+ "metric",
+ "groupby",
+ "sort_by_metric",
+ "min_val",
+ "max_val",
+ "color_scheme",
+ "number_format",
+ "currency_format",
+ "value_formatter",
+ "start_angle",
+ "end_angle",
+ "show_pointer",
+ "show_progress",
+ "intervals",
+ "interval_color_indices",
+ "time_range",
+ "granularity_sqla",
+ } <= set(props)
+ assert all(example["chart_type"] == "gauge" for example in result["examples"])
+
+ def test_native_gauge_alias_returns_public_schema_identity(self) -> None:
+ result = _call_schema("gauge_chart")
+ assert result["chart_type"] == "gauge"
+
def test_include_examples_false_omits_examples(self) -> None:
result = _call_schema("xy", include_examples=False)
assert "schema" in result
diff --git a/tests/unit_tests/mcp_service/chart/tool/test_update_chart.py b/tests/unit_tests/mcp_service/chart/tool/test_update_chart.py
index 2e654fd2de1..254066711f7 100644
--- a/tests/unit_tests/mcp_service/chart/tool/test_update_chart.py
+++ b/tests/unit_tests/mcp_service/chart/tool/test_update_chart.py
@@ -34,6 +34,7 @@ from superset.mcp_service.chart.schemas import (
AxisConfig,
ColumnRef,
FilterConfig,
+ GaugeChartConfig,
GenerateChartResponse,
LegendConfig,
TableChartConfig,
@@ -1256,8 +1257,8 @@ class TestBuildPreviewFormData:
result = _build_preview_form_data(request, chart, parsed_config=config)
assert isinstance(result, dict)
- # Existing keys not touched by the new config are preserved
- assert result["custom_flag"] is True
+ # Cross-viz updates do not inherit stale controls from the prior plugin.
+ assert "custom_flag" not in result
# New config overrides existing keys
assert result["viz_type"] == "table"
# slice_id and datasource are always stamped onto the preview
@@ -1265,6 +1266,121 @@ class TestBuildPreviewFormData:
assert result["datasource"] == "7__table"
assert result["slice_name"] == "Existing"
+ def test_gauge_saved_and_preview_updates_use_identical_preserving_merge(self):
+ existing = {
+ "viz_type": "gauge_chart",
+ "metric": "saved_sla",
+ "groupby": ["region"],
+ "min_val": 0,
+ "max_val": 100,
+ "font_size": 19,
+ "number_format": ",.1f",
+ "show_pointer": False,
+ "_mcp_dashboard_time_filter_subject": "event_time",
+ "adhoc_filters": [
+ {
+ "clause": "WHERE",
+ "expressionType": "SIMPLE",
+ "subject": "event_time",
+ "operator": "TEMPORAL_RANGE",
+ "comparator": "No filter",
+ },
+ {
+ "clause": "WHERE",
+ "expressionType": "SIMPLE",
+ "subject": "event_time",
+ "operator": "TEMPORAL_RANGE",
+ "comparator": "Last week",
+ },
+ {
+ "clause": "WHERE",
+ "expressionType": "SIMPLE",
+ "subject": "country",
+ "operator": "==",
+ "comparator": "US",
+ },
+ ],
+ }
+ config = GaugeChartConfig(
+ chart_type="gauge",
+ metric={"name": "score", "aggregate": "AVG"},
+ max_val=120,
+ temporal_column=None,
+ )
+ request = UpdateChartRequest(identifier=1, config=config)
+ chart = Mock(
+ id=42,
+ datasource_id=7,
+ slice_name="Existing",
+ params=json.dumps(existing),
+ )
+
+ preview = _build_preview_form_data(request, chart, parsed_config=config)
+ payload = _build_update_payload(request, chart, parsed_config=config)
+
+ assert isinstance(preview, dict)
+ assert isinstance(payload, dict)
+ saved = json.loads(payload["params"])
+ for key in (
+ "metric",
+ "groupby",
+ "min_val",
+ "max_val",
+ "font_size",
+ "number_format",
+ "show_pointer",
+ "adhoc_filters",
+ ):
+ assert preview[key] == saved[key]
+ assert saved["groupby"] == ["region"]
+ assert saved["max_val"] == 120
+
+ assert [
+ f["comparator"]
+ for f in saved["adhoc_filters"]
+ if f["operator"] == "TEMPORAL_RANGE"
+ ] == ["Last week"]
+
+ @pytest.mark.parametrize("temporal_column", [None, "new_time"])
+ def test_gauge_dataset_rebind_scrubs_old_roles_in_both_paths(
+ self, temporal_column: str | None
+ ) -> None:
+ config = GaugeChartConfig(
+ chart_type="gauge",
+ metric={"name": "new_score", "aggregate": "AVG"},
+ temporal_column=temporal_column,
+ )
+ request = UpdateChartRequest(identifier=1, dataset_id=8, config=config)
+ chart = Mock(
+ id=42,
+ datasource_id=7,
+ slice_name="Existing",
+ params=json.dumps(
+ {
+ "viz_type": "gauge_chart",
+ "metric": "old_score",
+ "groupby": ["old_group"],
+ "font_size": 18,
+ "adhoc_filters": [{"subject": "old_filter"}],
+ }
+ ),
+ )
+
+ preview = _build_preview_form_data(request, chart, parsed_config=config)
+ payload = _build_update_payload(request, chart, parsed_config=config)
+
+ assert isinstance(preview, dict)
+ assert isinstance(payload, dict)
+ saved = json.loads(payload["params"])
+ assert preview["datasource"] == saved["datasource"] == "8__table"
+ assert "groupby" not in preview
+ assert "groupby" not in saved
+ assert preview.get("adhoc_filters", []) == saved.get("adhoc_filters", [])
+ assert [f["subject"] for f in preview.get("adhoc_filters", [])] == (
+ [temporal_column] if temporal_column else []
+ )
+ assert preview["font_size"] == saved["font_size"] == 18
+
def test_partial_column_config_merges_saved_ui_settings(self) -> None:
config = TableChartConfig.model_validate(
{
@@ -2201,6 +2317,10 @@ class TestUpdateChartDatasetIdIntegration:
@patch("superset.daos.chart.ChartDAO.find_by_id", new_callable=Mock)
@patch("superset.db.session")
@pytest.mark.asyncio
+ @pytest.mark.parametrize(
+ "viz_type,target_id",
+ [("table", 1041), ("gauge_chart", 10), ("gauge_chart", 1041)],
+ )
async def test_dataset_id_passed_to_update_command(
self,
mock_db_session: Any,
@@ -2209,13 +2329,15 @@ class TestUpdateChartDatasetIdIntegration:
mock_update_cmd_cls: Mock,
mock_check_access: Mock,
mcp_server: Any,
+ viz_type: str,
+ target_id: int,
) -> None:
"""dataset_id in request is forwarded to UpdateChartCommand payload."""
mock_chart = Mock()
mock_chart.id = 55
mock_chart.datasource_id = 10
mock_chart.slice_name = "Old Chart"
- mock_chart.viz_type = "table"
+ mock_chart.viz_type = viz_type
mock_chart.uuid = "uuid-55"
mock_find_by_id.return_value = mock_chart
@@ -2229,24 +2351,32 @@ class TestUpdateChartDatasetIdIntegration:
updated_chart = Mock()
updated_chart.id = 55
updated_chart.slice_name = "Old Chart"
- updated_chart.viz_type = "table"
+ updated_chart.viz_type = viz_type
updated_chart.uuid = "uuid-55"
mock_update_cmd_cls.return_value.run.return_value = updated_chart
request = {
"identifier": 55,
- "dataset_id": 1041,
+ "dataset_id": target_id,
"generate_preview": False,
}
async with Client(mcp) as client:
result = await client.call_tool("update_chart", {"request": request})
+ if viz_type == "gauge_chart" and target_id != 10:
+ assert result.structured_content["success"] is False
+ assert (
+ "complete Gauge config"
+ in result.structured_content["error"]["message"]
+ )
+ mock_update_cmd_cls.assert_not_called()
+ return
assert result.structured_content["success"] is True
call_args = mock_update_cmd_cls.call_args
payload = call_args[0][1]
- assert payload.get("datasource_id") == 1041
+ assert payload.get("datasource_id") == target_id
assert payload.get("datasource_type") == "table"
@patch(
@@ -2349,3 +2479,68 @@ class TestUpdateChartDatasetIdIntegration:
error_type = result.structured_content["error"]["error_type"]
assert error_type == "DatasetNotAccessible"
assert "9999" in result.structured_content["error"]["details"]
+
+
+@pytest.mark.parametrize("has_finite", [True, False])
+@pytest.mark.parametrize("preview_path", [True, False])
+def test_gauge_update_compile_keeps_finite_groups(
+ has_finite: bool,
+ preview_path: bool,
+) -> None:
+ """Both update payload paths execute the real finite-dial compile contract."""
+ config = GaugeChartConfig(metric={"name": "saved_sla", "saved_metric": True})
+ request = UpdateChartRequest(identifier=1, config=config)
+ dataset = Mock(
+ id=7,
+ table_name="scores",
+ schema=None,
+ columns=[],
+ metrics=[],
+ database=Mock(database_name="examples"),
+ )
+ chart = Mock(
+ id=1,
+ datasource_id=7,
+ datasource=dataset,
+ slice_name="Gauge",
+ params=json.dumps({"viz_type": "gauge_chart", "metric": "saved_sla"}),
+ )
+ if preview_path:
+ form_data = _build_preview_form_data(request, chart, parsed_config=config)
+ else:
+ payload = _build_update_payload(request, chart, parsed_config=config)
+ assert isinstance(payload, dict)
+ form_data = json.loads(payload["params"])
+ assert isinstance(form_data, dict)
+ with (
+ patch(
+ "superset.mcp_service.chart.compile.DatasetValidator.validate_against_dataset",
+ return_value=(True, None),
+ ),
+ 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": [{"saved_sla": None}, {"saved_sla": float("nan")}]
+ + ([{"saved_sla": 0}] if has_finite else [])
+ }
+ ]
+ }
+ result = update_chart_module._validate_update_against_dataset(
+ config, form_data, chart
+ )
+ if has_finite:
+ assert result is None
+ else:
+ assert result is not None
+ assert result.success is False
+ assert result.error.error_type == "NonNumericGaugeMetric"
+ assert build.call_args.kwargs["row_limit"] == 10
+ command.return_value.validate.assert_called_once()
diff --git a/tests/unit_tests/mcp_service/chart/tool/test_update_chart_preview.py b/tests/unit_tests/mcp_service/chart/tool/test_update_chart_preview.py
index 2c9fcbda2f0..cc04860eb82 100644
--- a/tests/unit_tests/mcp_service/chart/tool/test_update_chart_preview.py
+++ b/tests/unit_tests/mcp_service/chart/tool/test_update_chart_preview.py
@@ -20,7 +20,7 @@ Unit tests for update_chart_preview MCP tool
"""
import importlib
-from typing import Any
+from contextlib import nullcontext
from unittest.mock import Mock, patch
import pytest
@@ -28,12 +28,11 @@ from fastmcp import Client
from superset.extensions import feature_flag_manager
from superset.mcp_service.app import mcp
-from superset.mcp_service.chart.chart_utils import map_big_number_config
from superset.mcp_service.chart.schemas import (
AxisConfig,
- BigNumberChartConfig,
ColumnRef,
FilterConfig,
+ GaugeChartConfig,
InteractivePivotChartConfig,
LegendConfig,
TableChartConfig,
@@ -84,6 +83,99 @@ def _mock_dataset(id: int = 1) -> Mock:
return dataset
+@patch.object(
+ update_chart_preview_module,
+ "event_logger",
+ new=Mock(log_context=lambda **kwargs: nullcontext()),
+)
+@patch.object(update_chart_preview_module, "validate_and_compile")
+@patch.object(update_chart_preview_module, "has_dataset_access", return_value=True)
+@patch("superset.daos.dataset.DatasetDAO.find_by_id")
+@patch.object(update_chart_preview_module, "analyze_chart_semantics")
+@patch.object(update_chart_preview_module, "analyze_chart_capabilities")
+@patch.object(update_chart_preview_module, "generate_explore_link")
+@patch.object(update_chart_preview_module, "_get_previous_form_data")
+@patch.object(update_chart_preview_module, "_find_dataset")
+def test_cached_gauge_update_preserves_controls_and_compiles(
+ mock_find_dataset,
+ mock_get_previous_form_data,
+ mock_generate_explore_link,
+ mock_capabilities,
+ mock_semantics,
+ mock_find_by_id,
+ unused_access_mock,
+ mock_validate_and_compile,
+ mock_auth,
+) -> None:
+ """Cached Gauge iteration preserves omissions and validates runtime output."""
+ mock_find_dataset.return_value = _mock_dataset(id=3)
+ mock_find_by_id.return_value = _mock_dataset(id=3)
+ mock_get_previous_form_data.return_value = {
+ "viz_type": "gauge_chart",
+ "datasource": "3__table",
+ "metric": "old_sla",
+ "groupby": ["team"],
+ "font_size": 19,
+ "number_format": ",.1f",
+ "show_pointer": False,
+ "_mcp_dashboard_time_filter_subject": "event_time",
+ "adhoc_filters": [
+ {
+ "clause": "WHERE",
+ "expressionType": "SIMPLE",
+ "subject": "event_time",
+ "operator": "TEMPORAL_RANGE",
+ "comparator": "No filter",
+ },
+ {
+ "clause": "WHERE",
+ "expressionType": "SIMPLE",
+ "subject": "event_time",
+ "operator": "TEMPORAL_RANGE",
+ "comparator": "Last week",
+ },
+ ],
+ }
+ cached_filters = mock_get_previous_form_data.return_value["adhoc_filters"]
+ cached_filters.insert(0, dict(cached_filters[0]))
+ mock_generate_explore_link.return_value = (
+ "http://localhost:8088/explore/?form_data_key=new_key"
+ )
+ mock_capabilities.return_value = None
+ mock_semantics.return_value = None
+ mock_validate_and_compile.return_value = Mock(success=True, warnings=[])
+ request = UpdateChartPreviewRequest(
+ form_data_key="old_key",
+ dataset_id=3,
+ config=GaugeChartConfig(
+ chart_type="gauge",
+ metric={"name": "new_sla", "saved_metric": True},
+ max_val=120,
+ temporal_column=None,
+ ),
+ )
+
+ result = update_chart_preview_module.update_chart_preview(
+ request=request, ctx=Mock()
+ )
+
+ assert result["success"] is True, result
+ generated = mock_generate_explore_link.call_args.args[1]
+ assert generated["metric"] == "new_sla"
+ assert generated["groupby"] == ["team"]
+ assert generated["font_size"] == 19
+ assert generated["show_pointer"] is False
+ assert generated["max_val"] == 120
+ assert result["form_data"] == generated
+ assert mock_validate_and_compile.call_args.kwargs["run_compile_check"] is True
+
+ assert [
+ f["comparator"]
+ for f in generated["adhoc_filters"]
+ if f["operator"] == "TEMPORAL_RANGE"
+ ] == ["Last week"]
+
+
class TestUpdateChartPreview:
"""Tests for update_chart_preview MCP tool."""
@@ -591,268 +683,6 @@ class TestUpdateChartPreview:
assert result is None
- def test_preserves_generated_temporal_filter_with_cached_filters(self) -> None:
- """Cached filters are merged without replacing the temporal binding."""
- new_form_data = {
- "adhoc_filters": [
- {
- "clause": "WHERE",
- "comparator": "No filter",
- "expressionType": "SIMPLE",
- "operator": "TEMPORAL_RANGE",
- "subject": "ds",
- }
- ]
- }
- previous_form_data = {
- "adhoc_filters": [
- {
- "clause": "WHERE",
- "comparator": "North",
- "expressionType": "SIMPLE",
- "operator": "==",
- "subject": "region",
- }
- ]
- }
-
- update_chart_preview_module._preserve_previous_adhoc_filters(
- new_form_data,
- previous_form_data,
- )
-
- assert [filter_["subject"] for filter_ in new_form_data["adhoc_filters"]] == [
- "region",
- "ds",
- ]
-
- def test_cached_temporal_filter_takes_precedence_over_generated_default(
- self,
- ) -> None:
- """A cached chart-specific time range is not duplicated or reset."""
- new_form_data = {
- "adhoc_filters": [
- {
- "clause": "WHERE",
- "comparator": "No filter",
- "expressionType": "SIMPLE",
- "operator": "TEMPORAL_RANGE",
- "subject": "ds",
- }
- ]
- }
- cached_temporal_filter = {
- "clause": "WHERE",
- "comparator": "Last month",
- "expressionType": "SIMPLE",
- "operator": "TEMPORAL_RANGE",
- "subject": "ds",
- }
-
- update_chart_preview_module._preserve_previous_adhoc_filters(
- new_form_data,
- {"adhoc_filters": [cached_temporal_filter]},
- )
-
- assert new_form_data["adhoc_filters"] == [cached_temporal_filter]
-
- def test_replaces_cached_temporal_filter_when_column_changes(self) -> None:
- """A newly selected temporal column replaces the cached binding."""
- new_temporal_filter = {
- "clause": "WHERE",
- "comparator": "No filter",
- "expressionType": "SIMPLE",
- "operator": "TEMPORAL_RANGE",
- "subject": "created_at",
- }
- region_filter = {
- "clause": "WHERE",
- "comparator": "North",
- "expressionType": "SIMPLE",
- "operator": "==",
- "subject": "region",
- }
- previous_temporal_filter = {
- "clause": "WHERE",
- "comparator": "No filter",
- "expressionType": "SIMPLE",
- "operator": "TEMPORAL_RANGE",
- "subject": "ds",
- }
- new_form_data: dict[str, Any] = {"adhoc_filters": [new_temporal_filter]}
- new_form_data["_mcp_dashboard_time_filter_subject"] = "created_at"
-
- update_chart_preview_module._preserve_previous_adhoc_filters(
- new_form_data,
- {
- "adhoc_filters": [region_filter, previous_temporal_filter],
- "_mcp_dashboard_time_filter_subject": "ds",
- },
- )
-
- assert new_form_data["adhoc_filters"] == [
- region_filter,
- new_temporal_filter,
- ]
-
- def test_replaces_temporal_xy_binding_when_subject_changes(self) -> None:
- """A temporal XY binding does not survive rebinding to a new subject."""
- previous_binding = {
- "clause": "WHERE",
- "comparator": "No filter",
- "expressionType": "SIMPLE",
- "operator": "TEMPORAL_RANGE",
- "subject": "event_time",
- }
- new_binding = {
- **previous_binding,
- "subject": "created_at",
- }
- new_form_data = {
- "adhoc_filters": [new_binding],
- "_mcp_dashboard_time_filter_subject": "created_at",
- }
-
- update_chart_preview_module._preserve_previous_adhoc_filters(
- new_form_data,
- {
- "adhoc_filters": [previous_binding],
- "_mcp_dashboard_time_filter_subject": "event_time",
- },
- )
-
- assert new_form_data["adhoc_filters"] == [new_binding]
-
- def test_replaces_big_number_fallback_binding_when_subject_changes(self) -> None:
- """A Big Number fallback binding is replaced by a selected subject."""
- dataset = Mock(
- main_dttm_col=None,
- columns=[Mock(column_name="order_date")],
- )
- config = BigNumberChartConfig(
- chart_type="big_number",
- metric=ColumnRef(name="revenue", aggregate="SUM"),
- )
- rebound_config = config.model_copy(update={"temporal_column": "created_at"})
-
- with (
- patch(
- "superset.daos.dataset.DatasetDAO.find_by_id_or_uuid",
- return_value=dataset,
- ),
- patch(
- "superset.mcp_service.chart.chart_utils.is_column_truly_temporal",
- return_value=True,
- ),
- ):
- previous_form_data = map_big_number_config(config, dataset_id=42)
- new_form_data = map_big_number_config(rebound_config, dataset_id=42)
-
- update_chart_preview_module._preserve_previous_adhoc_filters(
- new_form_data,
- previous_form_data,
- )
-
- assert previous_form_data["_mcp_dashboard_time_filter_subject"] == "order_date"
- assert new_form_data["_mcp_dashboard_time_filter_subject"] == "created_at"
- assert [filter_["subject"] for filter_ in new_form_data["adhoc_filters"]] == [
- "created_at"
- ]
-
- def test_removes_cached_temporal_filter_without_new_binding(self) -> None:
- """A mapping without a temporal subject drops the cached binding."""
- region_filter = {
- "clause": "WHERE",
- "comparator": "North",
- "expressionType": "SIMPLE",
- "operator": "==",
- "subject": "region",
- }
- previous_temporal_filter = {
- "clause": "WHERE",
- "comparator": "No filter",
- "expressionType": "SIMPLE",
- "operator": "TEMPORAL_RANGE",
- "subject": "ds",
- }
- new_form_data: dict[str, Any] = {}
-
- update_chart_preview_module._preserve_previous_adhoc_filters(
- new_form_data,
- {
- "adhoc_filters": [region_filter, previous_temporal_filter],
- "_mcp_dashboard_time_filter_subject": "ds",
- },
- )
-
- assert new_form_data["adhoc_filters"] == [region_filter]
-
- def test_preserves_user_temporal_filter_on_generated_subject(self) -> None:
- """A user-authored range on the binding subject is not generated state."""
- generated_binding = {
- "clause": "WHERE",
- "comparator": "No filter",
- "expressionType": "SIMPLE",
- "operator": "TEMPORAL_RANGE",
- "subject": "ds",
- }
- user_filter = {
- "clause": "WHERE",
- "comparator": "Last month",
- "expressionType": "SIMPLE",
- "operator": "TEMPORAL_RANGE",
- "subject": "ds",
- }
-
- new_form_data: dict[str, Any] = {}
- update_chart_preview_module._preserve_previous_adhoc_filters(
- new_form_data,
- {
- "adhoc_filters": [generated_binding, user_filter],
- "_mcp_dashboard_time_filter_subject": "ds",
- },
- )
-
- assert new_form_data["adhoc_filters"] == [user_filter]
-
- def test_rebinding_preserves_unrelated_cached_temporal_filter(self) -> None:
- """Only the generated binding is replaced; user filters retain provenance."""
- previous_binding = {
- "expressionType": "SIMPLE",
- "clause": "WHERE",
- "operator": "TEMPORAL_RANGE",
- "subject": "ds",
- "comparator": "No filter",
- }
- unrelated_filter = {
- "expressionType": "SIMPLE",
- "clause": "WHERE",
- "operator": "TEMPORAL_RANGE",
- "subject": "processed_at",
- "comparator": "Last year",
- }
- new_binding = {
- "expressionType": "SIMPLE",
- "clause": "WHERE",
- "operator": "TEMPORAL_RANGE",
- "subject": "created_at",
- "comparator": "No filter",
- }
- new_form_data = {
- "adhoc_filters": [new_binding],
- "_mcp_dashboard_time_filter_subject": "created_at",
- }
-
- update_chart_preview_module._preserve_previous_adhoc_filters(
- new_form_data,
- {
- "adhoc_filters": [previous_binding, unrelated_filter],
- "_mcp_dashboard_time_filter_subject": "ds",
- },
- )
-
- assert new_form_data["adhoc_filters"] == [unrelated_filter, new_binding]
-
@patch.object(update_chart_preview_module, "validate_and_compile")
@patch.object(update_chart_preview_module, "has_dataset_access", return_value=True)
@patch("superset.daos.dataset.DatasetDAO.find_by_id")
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 b25f90fbcef..dba58131399 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
@@ -21,10 +21,11 @@ Comprehensive unit tests for MCP generate_explore_link tool
import importlib
import logging
+from typing import Any
from unittest.mock import Mock, patch
import pytest
-from fastmcp import Client
+from fastmcp import Client, FastMCP
from superset.mcp_service.app import mcp
from superset.mcp_service.chart.schemas import (
@@ -46,6 +47,56 @@ generate_explore_link_module = importlib.import_module(
"superset.mcp_service.explore.tool.generate_explore_link"
)
+
+@pytest.mark.parametrize("time_range", ["", " ", "No filter"])
+@pytest.mark.parametrize("comparator", ["Last week", "", " "])
+@patch.object(generate_explore_link_module, "validate_and_compile")
+@patch("superset.daos.dataset.DatasetDAO.find_by_id")
+@pytest.mark.asyncio
+async def test_gauge_fastmcp_entry_compiles_and_returns_native_form_data(
+ mock_find_dataset, mock_validate, mcp_server, time_range: str, comparator: str
+) -> None:
+ """The public Gauge 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": "gauge",
+ "metric": {"name": "num", "aggregate": "AVG"},
+ "min_val": 0,
+ "max_val": 100,
+ "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"] == "gauge_chart"
+ 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__)
@@ -1227,3 +1278,73 @@ class TestGenerateExploreLinkValidation:
assert error["error_type"] == "permission_denied"
assert "Dataset not found" in error["message"]
mock_create_permalink.assert_not_called()
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ "filter_value",
+ [
+ {"subject": "event_time", "operator": "TEMPORAL_RANGE"},
+ {"subject": "event_time", "operator": "TEMPORAL_RANGE", "comparator": None},
+ ],
+)
+async def test_gauge_fastmcp_rejects_missing_temporal_comparator(
+ mcp_server: FastMCP,
+ filter_value: dict[str, Any],
+) -> None:
+ """Transport validation rejects malformed ranges before query compilation."""
+ from fastmcp.exceptions import ToolError
+
+ with patch.object(generate_explore_link_module, "validate_and_compile") as compile_:
+ async with Client(mcp_server) as client:
+ with pytest.raises(ToolError, match="requires a temporal comparator"):
+ await client.call_tool(
+ "generate_explore_link",
+ {
+ "request": {
+ "dataset_id": "3",
+ "config": {
+ "chart_type": "gauge",
+ "metric": {"name": "num", "aggregate": "AVG"},
+ "adhoc_filters": [filter_value],
+ },
+ }
+ },
+ )
+ compile_.assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test_gauge_fastmcp_rejects_conflicting_temporal_filters(
+ mcp_server: FastMCP,
+) -> None:
+ """Public native-form input cannot silently lose the second time restriction."""
+ from fastmcp.exceptions import ToolError
+
+ temporal = {
+ "subject": "event_time",
+ "operator": "TEMPORAL_RANGE",
+ "comparator": "Last week",
+ }
+ with patch.object(generate_explore_link_module, "validate_and_compile") as compile_:
+ async with Client(mcp_server) as client:
+ with pytest.raises(
+ ToolError, match="conflicts with another temporal range"
+ ):
+ await client.call_tool(
+ "generate_explore_link",
+ {
+ "request": {
+ "dataset_id": "3",
+ "config": {
+ "chart_type": "gauge",
+ "metric": "saved_sla",
+ "adhoc_filters": [
+ temporal,
+ {**temporal, "subject": "other_time"},
+ ],
+ },
+ }
+ },
+ )
+ compile_.assert_not_called()