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/16 09:44:55 UTC
(superset) branch master updated: feat(mcp): add typed Gantt chart support (#43737)
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 0049f814072 feat(mcp): add typed Gantt chart support (#43737)
0049f814072 is described below
commit 0049f81407277e6065b90504181ce749637d3116
Author: Amin Ghadersohi <am...@gmail.com>
AuthorDate: Wed Sep 16 19:44:48 2026 +1000
feat(mcp): add typed Gantt chart support (#43737)
---
.github/actions/setup-backend/action.yml | 6 +-
.github/actions/setup-docker/action.yml | 3 +
.github/workflows/superset-python-unittest.yml | 3 +-
.../docs/using-superset/using-ai-with-superset.mdx | 43 +
superset/mcp_service/app.py | 5 +-
superset/mcp_service/chart/chart_helpers.py | 174 +-
superset/mcp_service/chart/chart_utils.py | 306 ++-
superset/mcp_service/chart/plugins/__init__.py | 3 +
superset/mcp_service/chart/plugins/gantt.py | 237 ++
superset/mcp_service/chart/preview_utils.py | 265 ++
superset/mcp_service/chart/query_result.py | 8 +-
.../mcp_service/chart/resources/chart_configs.py | 24 +
superset/mcp_service/chart/schemas.py | 803 +++++-
superset/mcp_service/chart/tool/generate_chart.py | 11 +-
superset/mcp_service/chart/tool/get_chart_data.py | 1 +
.../mcp_service/chart/tool/get_chart_preview.py | 50 +-
.../chart/tool/get_chart_type_schema.py | 15 +-
superset/mcp_service/chart/tool/update_chart.py | 37 +-
.../mcp_service/chart/tool/update_chart_preview.py | 42 +
.../chart/validation/dataset_validator.py | 27 +-
superset/mcp_service/chart/validation/pipeline.py | 34 +-
.../mcp_service/chart/test_chart_examples.py | 59 +
.../mcp_service/chart/test_chart_helpers.py | 285 ++
tests/unit_tests/mcp_service/chart/test_compile.py | 85 +
.../mcp_service/chart/test_gantt_chart.py | 2812 ++++++++++++++++++++
.../mcp_service/chart/test_preview_utils.py | 145 +-
.../mcp_service/chart/test_query_result.py | 71 +
.../mcp_service/chart/tool/test_update_chart.py | 56 +
.../chart/tool/test_update_chart_preview.py | 36 +
.../migrations/test_single_migration_head.py | 29 +
30 files changed, 5562 insertions(+), 113 deletions(-)
diff --git a/.github/actions/setup-backend/action.yml b/.github/actions/setup-backend/action.yml
index c7bff2e3c1f..53cc27a0a6b 100644
--- a/.github/actions/setup-backend/action.yml
+++ b/.github/actions/setup-backend/action.yml
@@ -51,7 +51,11 @@ runs:
# or package lookups silently resolve to an empty list.
if: inputs.install-superset == 'true'
shell: bash
- run: sudo apt-get update
+ run: |
+ # Use the runner's preinstalled Chrome; these dependencies do not need
+ # its apt repository. Mirror index failures must not block their setup.
+ sudo rm -f /etc/apt/sources.list.d/google-chrome*.list /etc/apt/sources.list.d/google-chrome*.sources
+ sudo apt-get update
- name: Install apt packages
if: inputs.install-superset == 'true'
uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3
diff --git a/.github/actions/setup-docker/action.yml b/.github/actions/setup-docker/action.yml
index f2c5b8494b2..fcd56e8460d 100644
--- a/.github/actions/setup-docker/action.yml
+++ b/.github/actions/setup-docker/action.yml
@@ -53,6 +53,9 @@ runs:
if: ${{ inputs.install-docker-compose == 'true' }}
shell: bash
run: |
+ # Use the runner's preinstalled Chrome; these dependencies do not need
+ # its apt repository. Mirror index failures must not block their setup.
+ sudo rm -f /etc/apt/sources.list.d/google-chrome*.list /etc/apt/sources.list.d/google-chrome*.sources
sudo apt-get update
sudo apt-get install -y ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
diff --git a/.github/workflows/superset-python-unittest.yml b/.github/workflows/superset-python-unittest.yml
index ae8cd2be6f1..9af2fdbaf12 100644
--- a/.github/workflows/superset-python-unittest.yml
+++ b/.github/workflows/superset-python-unittest.yml
@@ -43,7 +43,8 @@ jobs:
needs: changes
if: needs.changes.outputs.python == 'true'
runs-on: ubuntu-26.04
- timeout-minutes: 30
+ # Leave time for setup, the full suite, scoped coverage gates, and uploads.
+ timeout-minutes: 45
permissions:
id-token: write
strategy:
diff --git a/docs/docs/using-superset/using-ai-with-superset.mdx b/docs/docs/using-superset/using-ai-with-superset.mdx
index 31132143cfc..b54f9f71cb9 100644
--- a/docs/docs/using-superset/using-ai-with-superset.mdx
+++ b/docs/docs/using-superset/using-ai-with-superset.mdx
@@ -342,3 +342,46 @@ Ask your admin for the MCP server URL and any authentication tokens you need.
- Verify the connection in your AI client (e.g., the hammer icon in Claude Desktop)
- Ask the AI "What Superset tools are available?" to confirm the connection
- Restart your AI client if you recently changed the configuration
+
+
+## Gantt charts through MCP
+
+Use `chart_type: "gantt"` with temporal `start_time` and `end_time` column
+references and a `category` task column. Optional `series` colors the intervals;
+`subcategories: true` separates tasks within each series. Ask
+`get_chart_type_schema` for the complete Gantt configuration and examples.
+
+Gantt Vega-Lite previews skip rows with null or invalid bounds, reversed intervals,
+or missing category values. An empty query result produces an empty preview;
+a nonempty result with no renderable intervals returns an error. Missing result
+fields and malformed result structures still return errors. Preview specifications
+contain at most 1,000 renderable rows and only fields used by the visualization.
+
+When updating a saved or cached Gantt chart, omitting `filters` preserves existing
+non-generated adhoc filters. Supplying `filters`, including `[]`, replaces them.
+The mapper-owned temporal binding is regenerated from the supplied configuration;
+an inactive native `No filter` placeholder is replaced by that binding. Unsupported
+merged filter state is rejected rather than silently discarded. Native sort entries
+must remain valid `[column, ascending_boolean]` pairs.
+
+The shared MCP query builder preserves explicit saved `orderby` values. For
+`mixed_timeseries`, primary ordering applies only to the primary query; secondary
+ordering uses `orderby_b` when provided. Typed Gantt `order_by` continues to map
+through native `order_by_cols`, independently of these saved query keys.
+
+The default `update_chart` preview-first flow validates the complete Gantt preview
+state, including its saved-chart envelope. The chart name (`slice_name`) is
+transport metadata, not a Gantt control; unknown visualization controls still fail
+validation.
+
+For same-dataset Gantt updates, omitted `tooltip_columns`, `tooltip_metrics`,
+`order_by`, and `row_limit` keep their saved values. Explicit empty lists clear
+those controls, and an explicitly supplied row limit replaces the saved limit.
+A dataset change does not inherit the old series, tooltips, ordering, or filters.
+
+A single Explore-authored temporal filter is retained when temporal settings are
+omitted, without adding a second generated binding. Explicit `temporal_column` or
+`time_range` replaces that native time predicate; explicit `filters`, including
+`[]`, retains its replacement semantics. Unsupported multiple temporal predicates
+remain validation errors. Empty strings in saved `x_axis_time_bounds` are treated
+as unset bounds, including a fully cleared range.
diff --git a/superset/mcp_service/app.py b/superset/mcp_service/app.py
index 81cdaabcb47..4e0369bf79f 100644
--- a/superset/mcp_service/app.py
+++ b/superset/mcp_service/app.py
@@ -416,6 +416,9 @@ Chart Types You Can CREATE with generate_chart/generate_explore_link:
whisker_type: tukey | min_max | percentile)
- chart_type="waterfall": Waterfall chart of cumulative increases/decreases
(x_axis + metric required; optional single breakdown column, show_total)
+- chart_type="gantt": Gantt task intervals over time
+ (temporal start_time + end_time and category required; optional series,
+ tooltip columns/metrics, ordering, time range, and subcategories)
Time grain for temporal x-axis (time_grain parameter):
- PT1H (hourly), P1D (daily), P1W (weekly), P1M (monthly), P1Y (yearly)
@@ -425,7 +428,7 @@ Each chart returned by list_charts / get_chart_info includes a
chart_type_display_name field with a human-readable name when available.
This field is populated for chart types known to the MCP registry
(xy, pie, table, pivot_table, big_number, mixed_timeseries, handlebars,
-histogram, box_plot, waterfall, and interactive_pivot). Availability gates
+histogram, box_plot, waterfall, gantt, and interactive_pivot). Availability gates
creation and schema discovery, not display names for existing charts.
For all other viz_types (Funnel, Gauge, Heatmap, etc.) it will be null —
use the raw viz_type field instead when referring to those chart types.
diff --git a/superset/mcp_service/chart/chart_helpers.py b/superset/mcp_service/chart/chart_helpers.py
index f76e3426f24..b661ed4971b 100644
--- a/superset/mcp_service/chart/chart_helpers.py
+++ b/superset/mcp_service/chart/chart_helpers.py
@@ -548,6 +548,123 @@ def resolve_metrics_and_groupby(
return resolve_metrics(form_data, viz_type), resolve_groupby(form_data)
+def resolve_big_number_columns(form_data: dict[str, Any]) -> list[Any]:
+ """Resolve the temporal column used by Big Number with Trendline.
+
+ The frontend accepts the temporal binding through either ``x_axis`` or the
+ legacy ``granularity_sqla`` control. MCP preview and compile historically
+ used the physical granularity column when ``x_axis`` was absent; preserve
+ that contract rather than reducing every Big Number query to a total.
+
+ ``big_number_total`` intentionally does not use this helper because its
+ frontend query has no temporal dimension.
+ """
+ x_axis = form_data.get("x_axis")
+ if isinstance(x_axis, str) and x_axis:
+ return [x_axis]
+ if isinstance(x_axis, dict):
+ if (
+ isinstance(x_axis.get("sqlExpression"), str)
+ and x_axis.get("sqlExpression")
+ and isinstance(x_axis.get("label"), str)
+ and x_axis.get("label")
+ and x_axis.get("expressionType") in (None, "SQL")
+ ):
+ return [x_axis]
+ column_name = x_axis.get("column_name")
+ if isinstance(column_name, str) and column_name:
+ return [column_name]
+
+ granularity = form_data.get("granularity_sqla")
+ return [granularity] if isinstance(granularity, str) and granularity else []
+
+
+def resolve_gantt_query_fields( # noqa: C901
+ form_data: dict[str, Any],
+) -> tuple[list[Any], list[Any], list[list[Any]], list[Any]]:
+ """Mirror the ECharts Gantt ``buildQuery`` field extraction contract.
+
+ Returns ``(columns, metrics, orderby, series_columns)``. Saved form data is
+ user-editable, so malformed or oversized native ordering is rejected rather
+ than silently dropped or passed into ``QueryContextFactory``.
+ """
+ from superset.utils import json as utils_json
+
+ def require_column(value: Any, field_name: str) -> Any:
+ if isinstance(value, str) and value:
+ return value
+ if isinstance(value, dict) and 0 < len(value) <= 20:
+ # QueryFormColumn objects use column_name for physical columns or
+ # expressionType/sqlExpression/label for adhoc columns.
+ if value.get("column_name") or (
+ value.get("expressionType") and value.get("label")
+ ):
+ return value
+ raise ValueError(f"Gantt {field_name} must be a column reference")
+
+ start_time = require_column(form_data.get("start_time"), "start_time")
+ end_time = require_column(form_data.get("end_time"), "end_time")
+ category = require_column(form_data.get("y_axis"), "y_axis")
+
+ raw_series = form_data.get("series")
+ series_columns = (
+ [require_column(raw_series, "series")] if raw_series is not None else []
+ )
+
+ raw_tooltip_columns = form_data.get("tooltip_columns") or []
+ raw_tooltip_metrics = form_data.get("tooltip_metrics") or []
+ if not isinstance(raw_tooltip_columns, list) or len(raw_tooltip_columns) > 50:
+ raise ValueError("Gantt tooltip_columns must contain at most 50 entries")
+ if not isinstance(raw_tooltip_metrics, list) or len(raw_tooltip_metrics) > 50:
+ raise ValueError("Gantt tooltip_metrics must contain at most 50 entries")
+ tooltip_columns = [
+ require_column(column, f"tooltip_columns[{index}]")
+ for index, column in enumerate(raw_tooltip_columns)
+ ]
+
+ raw_order = form_data.get("order_by_cols") or []
+ if not isinstance(raw_order, list) or len(raw_order) > 100:
+ raise ValueError("Gantt order_by_cols must contain at most 100 entries")
+ orderby: list[list[Any]] = []
+ for index, entry in enumerate(raw_order):
+ if isinstance(entry, str):
+ if len(entry) > 1000:
+ raise ValueError(f"Gantt order_by_cols[{index}] is too long")
+ try:
+ entry = utils_json.loads(entry)
+ except (TypeError, ValueError) as ex:
+ raise ValueError(
+ f"Gantt order_by_cols[{index}] is not valid JSON"
+ ) from ex
+ if (
+ not isinstance(entry, (list, tuple))
+ or len(entry) != 2
+ or not isinstance(entry[0], str)
+ or not entry[0]
+ or not isinstance(entry[1], bool)
+ ):
+ raise ValueError(
+ f"Gantt order_by_cols[{index}] must be [column, ascending_boolean]"
+ )
+ orderby.append([entry[0], entry[1]])
+
+ columns: list[Any] = []
+ seen: set[str] = set()
+ for column in (
+ start_time,
+ end_time,
+ category,
+ *series_columns,
+ *tooltip_columns,
+ *(entry[0] for entry in orderby),
+ ):
+ key = utils_json.dumps(column, sort_keys=True, default=str)
+ if key not in seen:
+ seen.add(key)
+ columns.append(column)
+ return columns, list(raw_tooltip_metrics), orderby, series_columns
+
+
def extract_x_axis_col(form_data: dict[str, Any]) -> str | None:
"""Return the x_axis column name from form_data, or None if not set."""
x_axis = form_data.get("x_axis")
@@ -568,6 +685,9 @@ def _build_single_query_dict(
) -> dict[str, Any]:
"""Build one query entry for QueryContextFactory from form_data fields."""
qd: dict[str, Any] = {"columns": columns, "metrics": metrics}
+ # Saved query-shaped ordering is distinct from typed sort_by/order_by_cols.
+ if form_data.get("orderby"):
+ qd["orderby"] = form_data["orderby"]
effective_row_limit = row_limit
if effective_row_limit is None:
effective_row_limit = form_data.get("row_limit")
@@ -576,16 +696,50 @@ def _build_single_query_dict(
if order_desc is not None:
qd["order_desc"] = order_desc
# sort_by_metric charts (pie/funnel/treemap/sankey/gauge) order by the
- # metric descending. buildQuery derives this on the frontend; the MCP path
- # builds the query dict directly and never reads a top-level
- # form_data['orderby'], so translate the flag here or a row_limit truncates
+ # metric descending. buildQuery derives this on the frontend; translate
+ # the flag here when there is no explicit ordering or a row_limit truncates
# an unordered result (dropping the heaviest rows rather than the top-N).
- if form_data.get("sort_by_metric") and metrics:
+ if form_data.get("sort_by_metric") and metrics and not qd.get("orderby"):
qd["orderby"] = [(metrics[0], False)]
apply_form_data_filters_to_query(qd, form_data)
return qd
+def _build_gantt_or_big_number_query_dicts(
+ form_data: dict[str, Any],
+ viz_type: str,
+ metrics: list[Any],
+ row_limit: int | None,
+ order_desc: bool | None,
+) -> list[dict[str, Any]] | None:
+ """Build query dictionaries for the two specialized MCP chart contracts."""
+ if viz_type == "gantt_chart":
+ columns, gantt_metrics, orderby, series_columns = resolve_gantt_query_fields(
+ form_data
+ )
+ query = _build_single_query_dict(
+ form_data,
+ columns,
+ gantt_metrics,
+ row_limit=row_limit,
+ )
+ query["orderby"] = orderby
+ query["series_columns"] = series_columns
+ return [query]
+
+ if viz_type == "big_number":
+ return [
+ _build_single_query_dict(
+ form_data,
+ resolve_big_number_columns(form_data),
+ metrics,
+ row_limit=row_limit,
+ order_desc=order_desc,
+ )
+ ]
+ return None
+
+
def _build_mixed_timeseries_secondary(
form_data: dict[str, Any],
x_axis_col: str | None,
@@ -603,8 +757,9 @@ def _build_mixed_timeseries_secondary(
if x_axis_col and x_axis_col not in groupby_b:
groupby_b = [x_axis_col] + groupby_b
+ # Each series owns its ordering; primary metrics may not exist in query B.
qd = _build_single_query_dict(
- form_data,
+ {**form_data, "orderby": form_data.get("orderby_b")},
groupby_b,
metrics_b,
row_limit=row_limit,
@@ -662,6 +817,15 @@ def build_query_dicts_from_form_data(
or ""
)
+ if specialized_queries := _build_gantt_or_big_number_query_dicts(
+ form_data,
+ viz_type,
+ metrics,
+ row_limit,
+ order_desc,
+ ):
+ return specialized_queries
+
# Deck.gl charts use spatial column configs rather than the standard
# metrics / groupby fields. Extract columns from the spatial controls.
if viz_type.startswith("deck_"):
diff --git a/superset/mcp_service/chart/chart_utils.py b/superset/mcp_service/chart/chart_utils.py
index a9934d7727b..f82276a2fc7 100644
--- a/superset/mcp_service/chart/chart_utils.py
+++ b/superset/mcp_service/chart/chart_utils.py
@@ -28,6 +28,7 @@ from collections.abc import Mapping
from dataclasses import dataclass
from typing import Any, Dict, TYPE_CHECKING
+from pydantic import ValidationError
from sqlalchemy.exc import SQLAlchemyError
if TYPE_CHECKING:
@@ -43,6 +44,7 @@ from superset.mcp_service.chart.schemas import (
ColumnRef,
CurrencyFormat,
FilterConfig,
+ GanttChartConfig,
GaugeChartConfig,
HandlebarsChartConfig,
HistogramChartConfig,
@@ -58,6 +60,7 @@ from superset.mcp_service.chart.schemas import (
from superset.mcp_service.chart.validation.dataset_validator import (
is_dataset_column_temporal,
)
+from superset.mcp_service.common.error_schemas import DatasetContext
from superset.mcp_service.utils.url_utils import get_superset_base_url
from superset.utils import json
from superset.utils.core import FilterOperator
@@ -600,6 +603,29 @@ def merge_interactive_pivot_ui_config(
new_form_data["pivot_table_state"] = {**existing_state, **new_state}
+_GANTT_PRESENTATION_KEYS = frozenset(
+ {
+ "color_scheme",
+ "legendMargin",
+ "legendOrientation",
+ "legendSort",
+ "legendType",
+ "show_extra_controls",
+ "show_legend",
+ "subcategories",
+ "tooltipTimeFormat",
+ "tooltipValuesFormat",
+ "x_axis_time_bounds",
+ "x_axis_time_format",
+ "x_axis_title",
+ "x_axis_title_margin",
+ "y_axis_title",
+ "y_axis_title_margin",
+ "zoomable",
+ }
+)
+
+
_GAUGE_FORM_DATA_FIELD_MAP: dict[str, str] = {
"groupby": "groupby",
"sort_by_metric": "sort_by_metric",
@@ -652,6 +678,164 @@ _GAUGE_PRESENTATION_FORM_DATA_KEYS = frozenset(
)
+def validate_gantt_form_data(
+ form_data: Mapping[str, Any],
+ dataset_id: int | str | None = None,
+ dataset_context: DatasetContext | None = None,
+) -> GanttChartConfig | None:
+ """Adapt and validate final native Gantt state after presentation merging.
+
+ Update requests are typed before native UI state is preserved, so validating
+ only the request can miss conflicts introduced by the merge. When dataset
+ metadata is available, run the adapted final state through canonical column
+ resolution as well; this catches aliases that resolve to the same physical
+ column rather than comparing native strings in isolation.
+ """
+ if form_data.get("viz_type") != "gantt_chart":
+ return None
+
+ from superset.mcp_service.chart.validation.dataset_validator import (
+ DatasetValidator,
+ GanttSemanticNormalizationError,
+ )
+
+ try:
+ config = GanttChartConfig.model_validate(dict(form_data))
+ except ValidationError as ex:
+ reasons = "; ".join(error["msg"] for error in ex.errors()[:3])
+ raise GanttSemanticNormalizationError(
+ f"Merged Gantt form data is invalid: {reasons}"
+ ) from ex
+
+ if dataset_id is None:
+ return config
+ return DatasetValidator.normalize_column_names(
+ config,
+ dataset_id,
+ dataset_context=dataset_context,
+ )
+
+
+def _preserve_gantt_adhoc_filters(
+ new_form_data: dict[str, Any],
+ previous_form_data: dict[str, Any],
+ config: GanttChartConfig,
+) -> None:
+ """Reconcile Gantt's single time binding while preserving omitted predicates."""
+ 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", [])
+ # The marker identifies a temporal subject, not ownership of its current
+ # comparator: Explore edits and earlier MCP updates may set an active range
+ # on that same subject. Only discard inactive placeholders here; reconcile
+ # active predicates below using the fields explicitly supplied by the caller.
+ merged_filters = [
+ filter_
+ for filter_ in previous_filters
+ if not (
+ isinstance(filter_, dict)
+ and filter_.get("operator") == FilterOperator.TEMPORAL_RANGE.value
+ and new_form_data.get(MCP_DASHBOARD_TIME_FILTER_SUBJECT)
+ and filter_.get("comparator") == NO_TIME_RANGE
+ and filter_.get("expressionType") == "SIMPLE"
+ and filter_.get("clause") == "WHERE"
+ )
+ ]
+ native_temporal = [
+ filter_
+ for filter_ in merged_filters
+ if isinstance(filter_, dict)
+ and filter_.get("operator") == FilterOperator.TEMPORAL_RANGE.value
+ and filter_.get("expressionType") == "SIMPLE"
+ and filter_.get("clause") == "WHERE"
+ ]
+ if len(native_temporal) == 1:
+ if {"temporal_column", "time_range"} & config.model_fields_set:
+ # An explicit temporal update replaces the one native time predicate.
+ merged_filters.remove(native_temporal[0])
+ else:
+ # Keep an Explore-authored range instead of adding a second binding.
+ generated_filters = [
+ filter_
+ for filter_ in generated_filters
+ if not (
+ isinstance(filter_, dict)
+ and filter_.get("operator") == FilterOperator.TEMPORAL_RANGE.value
+ )
+ ]
+ # The retained predicate is user-owned, not a generated binding.
+ new_form_data.pop(MCP_DASHBOARD_TIME_FILTER_SUBJECT, None)
+
+ 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
+
+ # Replace an equivalent cached filter rather than deduplicating it: an
+ # unchanged temporal subject can still have a newly supplied comparator.
+ merged_filters = [
+ previous_filter
+ for previous_filter in merged_filters
+ if not (
+ 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")
+ )
+ ]
+ merged_filters.append(generated_filter)
+
+ new_form_data["adhoc_filters"] = merged_filters
+
+
+def merge_gantt_ui_config(
+ previous_form_data: Mapping[str, Any], new_form_data: Dict[str, Any]
+) -> GanttChartConfig | None:
+ """Preserve omitted native Gantt presentation and dependent series state.
+
+ ``subcategories`` is only meaningful with ``series``. An omitted pair is
+ preserved together, an explicit series replacement keeps an omitted valid
+ subcategory setting, and explicit series removal disables subcategories.
+ """
+ if (
+ previous_form_data.get("viz_type") != "gantt_chart"
+ or new_form_data.get("viz_type") != "gantt_chart"
+ ):
+ return None
+ series_was_supplied = "series" in new_form_data
+ subcategories_was_supplied = "subcategories" in new_form_data
+
+ if not series_was_supplied and "series" in previous_form_data:
+ new_form_data["series"] = previous_form_data["series"]
+
+ for key in _GANTT_PRESENTATION_KEYS:
+ if key not in new_form_data and key in previous_form_data:
+ new_form_data[key] = previous_form_data[key]
+
+ if not new_form_data.get("series") and new_form_data.get("subcategories"):
+ # Explicit series removal wins over an omitted or stale presentation
+ # value. Persisting subcategories=True without series cannot be rendered
+ # coherently by the frontend.
+ new_form_data["subcategories"] = False
+ elif (
+ not series_was_supplied
+ and not subcategories_was_supplied
+ and not new_form_data.get("series")
+ ):
+ # Do not carry forward an already-inconsistent native payload.
+ new_form_data.pop("subcategories", None)
+
+ # Every caller gets the same post-merge semantic gate. Product paths run
+ # this helper again with dataset metadata before query, cache, or persistence
+ # so physical aliases are resolved canonically as well.
+ return validate_gantt_form_data(new_form_data)
+
+
def _without_generated_gauge_time_filter(
form_data: dict[str, Any],
) -> list[Any]:
@@ -687,6 +871,24 @@ def merge_chart_form_data( # noqa: C901
"""
if existing_form_data.get("viz_type") != new_form_data.get("viz_type"):
return dict(new_form_data)
+ if isinstance(config, GanttChartConfig):
+ merged = dict(new_form_data)
+ if not dataset_rebind:
+ for config_field, form_key in (
+ ("tooltip_columns", "tooltip_columns"),
+ ("tooltip_metrics", "tooltip_metrics"),
+ ("order_by", "order_by_cols"),
+ ("row_limit", "row_limit"),
+ ):
+ if (
+ config_field not in config.model_fields_set
+ and form_key in existing_form_data
+ ):
+ merged[form_key] = existing_form_data[form_key]
+ merge_gantt_ui_config(existing_form_data, merged)
+ if config.filters is None:
+ _preserve_gantt_adhoc_filters(merged, existing_form_data, config)
+ return merged
if not isinstance(config, GaugeChartConfig):
if dataset_rebind:
return dict(new_form_data)
@@ -986,12 +1188,33 @@ def _ensure_generated_temporal_binding(form_data: Dict[str, Any], column: str) -
form_data[MCP_DASHBOARD_TIME_FILTER_SUBJECT] = column
-def _bind_dashboard_time_range_filter(
+def _uses_mapper_owned_temporal_binding(
+ form_data: Dict[str, Any], dataset_id: int | str | None
+) -> bool:
+ """Whether a mapper supplied a validated natural time-filter binding."""
+ existing_binding = form_data.get(MCP_DASHBOARD_TIME_FILTER_SUBJECT)
+ if not isinstance(existing_binding, str) or not any(
+ isinstance(filter_, dict)
+ and filter_.get("operator") == FilterOperator.TEMPORAL_RANGE.value
+ and filter_.get("subject") == existing_binding
+ for filter_ in form_data.get("adhoc_filters", [])
+ ):
+ return False
+ # Mappers such as Gantt own their natural time field even though it is
+ # neither x_axis nor granularity_sqla. Validate the physical type rather
+ # than trusting the internal marker alone.
+ return _is_temporal_for_dashboard_binding(existing_binding, dataset_id)
+
+
+def _bind_dashboard_time_range_filter( # noqa: C901
form_data: Dict[str, Any],
config: ChartConfig,
dataset_id: int | str | None,
) -> None:
"""Bind charts without time configuration to a temporal filter subject."""
+ if _uses_mapper_owned_temporal_binding(form_data, dataset_id):
+ return
+
if temporal_column := getattr(config, "temporal_column", None):
if _is_temporal_for_dashboard_binding(temporal_column, dataset_id):
granularity = form_data.get("granularity_sqla")
@@ -1373,6 +1596,87 @@ def map_waterfall_config(config: WaterfallChartConfig) -> Dict[str, Any]:
return form_data
+def map_gantt_config(config: GanttChartConfig) -> Dict[str, Any]:
+ """Map typed Gantt config to the exact ECharts Gantt form-data contract."""
+ form_data: Dict[str, Any] = {
+ "viz_type": "gantt_chart",
+ "start_time": config.start_time.name,
+ "end_time": config.end_time.name,
+ "y_axis": config.category.name,
+ "tooltip_columns": [column.name for column in config.tooltip_columns],
+ "tooltip_metrics": [
+ create_metric_object(metric) for metric in config.tooltip_metrics
+ ],
+ "order_by_cols": [
+ json.dumps([order.column, order.ascending]) for order in config.order_by
+ ],
+ "row_limit": config.row_limit,
+ }
+ if config.series is not None:
+ form_data["series"] = config.series.name
+ elif "series" in config.model_fields_set:
+ # Preserve explicit removal intent for update merges. Native Gantt treats
+ # null the same as an absent series control.
+ form_data["series"] = None
+ if config.time_range is not None:
+ form_data["time_range"] = config.time_range
+
+ # Only explicitly supplied presentation fields are persisted. Update paths
+ # merge native values for omitted controls; fresh charts use frontend defaults.
+ presentation_fields = {
+ "color_scheme": ("color_scheme", config.color_scheme),
+ "show_legend": ("show_legend", config.show_legend),
+ "legend_orientation": ("legendOrientation", config.legend_orientation),
+ "legend_type": ("legendType", config.legend_type),
+ "legend_margin": ("legendMargin", config.legend_margin),
+ "legend_sort": ("legendSort", config.legend_sort),
+ "zoomable": ("zoomable", config.zoomable),
+ "subcategories": ("subcategories", config.subcategories),
+ "show_extra_controls": (
+ "show_extra_controls",
+ config.show_extra_controls,
+ ),
+ "x_axis_time_bounds": (
+ "x_axis_time_bounds",
+ list(config.x_axis_time_bounds) if config.x_axis_time_bounds else None,
+ ),
+ "x_axis_time_format": ("x_axis_time_format", config.x_axis_time_format),
+ "tooltip_time_format": ("tooltipTimeFormat", config.tooltip_time_format),
+ "tooltip_values_format": (
+ "tooltipValuesFormat",
+ config.tooltip_values_format,
+ ),
+ "x_axis_title": ("x_axis_title", config.x_axis_title),
+ "x_axis_title_margin": (
+ "x_axis_title_margin",
+ config.x_axis_title_margin,
+ ),
+ "y_axis_title": ("y_axis_title", config.y_axis_title),
+ "y_axis_title_margin": (
+ "y_axis_title_margin",
+ config.y_axis_title_margin,
+ ),
+ }
+ for field_name, (form_key, value) in presentation_fields.items():
+ if field_name in config.model_fields_set:
+ form_data[form_key] = value
+
+ _add_adhoc_filters(form_data, config.filters)
+ temporal_binding = config.temporal_column or config.start_time.name
+ if temporal_binding:
+ _ensure_generated_temporal_binding(form_data, temporal_binding)
+ if config.time_range:
+ for filter_ in form_data.get("adhoc_filters", []):
+ if (
+ isinstance(filter_, dict)
+ and filter_.get("operator") == FilterOperator.TEMPORAL_RANGE.value
+ and filter_.get("subject") == temporal_binding
+ and filter_.get("comparator") == NO_TIME_RANGE
+ ):
+ filter_["comparator"] = config.time_range
+ return form_data
+
+
def map_big_number_config(
config: BigNumberChartConfig, dataset_id: int | str | None = None
) -> Dict[str, Any]:
diff --git a/superset/mcp_service/chart/plugins/__init__.py b/superset/mcp_service/chart/plugins/__init__.py
index 247d265be8f..a0c9f67cc6a 100644
--- a/superset/mcp_service/chart/plugins/__init__.py
+++ b/superset/mcp_service/chart/plugins/__init__.py
@@ -29,6 +29,7 @@ To add a new chart type:
from superset.mcp_service.chart.plugins.big_number import BigNumberChartPlugin
from superset.mcp_service.chart.plugins.box_plot import BoxPlotChartPlugin
+from superset.mcp_service.chart.plugins.gantt import GanttChartPlugin
from superset.mcp_service.chart.plugins.gauge import GaugeChartPlugin
from superset.mcp_service.chart.plugins.handlebars import HandlebarsChartPlugin
from superset.mcp_service.chart.plugins.histogram import HistogramChartPlugin
@@ -60,10 +61,12 @@ register(BigNumberChartPlugin())
register(HistogramChartPlugin())
register(BoxPlotChartPlugin())
register(WaterfallChartPlugin())
+register(GanttChartPlugin())
__all__ = [
"BigNumberChartPlugin",
"BoxPlotChartPlugin",
+ "GanttChartPlugin",
"GaugeChartPlugin",
"HandlebarsChartPlugin",
"HistogramChartPlugin",
diff --git a/superset/mcp_service/chart/plugins/gantt.py b/superset/mcp_service/chart/plugins/gantt.py
new file mode 100644
index 00000000000..188654729ca
--- /dev/null
+++ b/superset/mcp_service/chart/plugins/gantt.py
@@ -0,0 +1,237 @@
+# 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.
+
+"""ECharts Gantt chart type plugin."""
+
+from __future__ import annotations
+
+from collections.abc import Mapping
+from typing import Any, ClassVar
+
+from pydantic import ValidationError
+
+from superset.mcp_service.chart.chart_utils import _summarize_filters, map_gantt_config
+from superset.mcp_service.chart.plugin import BaseChartPlugin
+from superset.mcp_service.chart.schemas import ColumnRef, GanttChartConfig
+from superset.mcp_service.chart.validation.dataset_validator import (
+ AmbiguousDatasetReferenceError,
+ DatasetValidator,
+ GanttSemanticNormalizationError,
+ resolve_dataset_reference,
+)
+from superset.mcp_service.common.error_schemas import ChartGenerationError
+
+
+class GanttChartPlugin(BaseChartPlugin):
+ """Plugin matching ``plugin-chart-echarts/src/Gantt``."""
+
+ chart_type = "gantt"
+ display_name = "Gantt Chart"
+ native_viz_types: ClassVar[Mapping[str, str]] = {
+ "gantt_chart": "Gantt Chart",
+ }
+
+ def pre_validate(self, config: dict[str, Any]) -> ChartGenerationError | None:
+ aliases = {
+ "start_time": ("start_time", "startTime", "start"),
+ "end_time": ("end_time", "endTime", "end"),
+ "category": ("category", "task", "y_axis", "yAxis"),
+ }
+ missing = [
+ name
+ for name, keys in aliases.items()
+ if not any(key in config for key in keys)
+ ]
+ if not missing:
+ return None
+ return ChartGenerationError(
+ error_type="missing_gantt_fields",
+ message=f"Gantt chart missing required fields: {', '.join(missing)}",
+ details=(
+ "A Gantt task needs temporal start/end columns and one task/category "
+ "dimension (the frontend's y_axis control)."
+ ),
+ suggestions=[
+ "Add start_time: {'name': 'start_time'}",
+ "Add end_time: {'name': 'end_time'}",
+ "Add category: {'name': 'task_name'}",
+ ],
+ error_code="MISSING_GANTT_FIELDS",
+ )
+
+ def extract_column_refs(self, config: Any) -> list[ColumnRef]:
+ if not isinstance(config, GanttChartConfig):
+ return []
+ refs = [config.start_time, config.end_time, config.category]
+ if config.series:
+ refs.append(config.series)
+ refs.extend(config.tooltip_columns)
+ refs.extend(config.tooltip_metrics)
+ refs.extend(ColumnRef(name=order.column) for order in config.order_by)
+ refs.extend(ColumnRef(name=filter_.column) for filter_ in config.filters or [])
+ return refs
+
+ def to_form_data(
+ self, config: Any, dataset_id: int | str | None = None
+ ) -> dict[str, Any]:
+ return map_gantt_config(config)
+
+ def post_map_validate(
+ self,
+ config: Any,
+ form_data: dict[str, Any],
+ dataset_id: int | str | None = None,
+ ) -> ChartGenerationError | None:
+ """Enforce the temporal controls declared by the frontend contract."""
+ if not isinstance(config, GanttChartConfig) or dataset_id is None:
+ return None
+ dataset_context = DatasetValidator._get_dataset_context(dataset_id)
+ if dataset_context is None:
+ return None
+
+ non_temporal: list[str] = []
+ for ref in (config.start_time, config.end_time):
+ try:
+ resolved_name = resolve_dataset_reference(
+ ref.name or "",
+ (column["name"] for column in dataset_context.available_columns),
+ "physical column",
+ )
+ except AmbiguousDatasetReferenceError as ex:
+ return DatasetValidator._build_ambiguous_reference_error(ex)
+ column = next(
+ (
+ column
+ for column in dataset_context.available_columns
+ if column["name"] == resolved_name
+ ),
+ None,
+ )
+ # Existence validation owns missing columns; avoid double-reporting.
+ if column is not None and not column.get("is_temporal", False):
+ non_temporal.append(ref.name or "")
+
+ if not non_temporal:
+ return None
+ temporal_columns = sorted(
+ column["name"]
+ for column in dataset_context.available_columns
+ if column.get("is_temporal", False)
+ )
+ return ChartGenerationError(
+ error_type="non_temporal_gantt_time_column",
+ message=(
+ "Gantt start_time and end_time must be temporal columns; "
+ f"non-temporal: {', '.join(non_temporal)}"
+ ),
+ details=(
+ "The Explore control panel restricts both fields to Temporal. "
+ "Using strings or numeric IDs would produce invalid task intervals."
+ ),
+ suggestions=(
+ [f"Temporal columns in this dataset: {', '.join(temporal_columns)}"]
+ if temporal_columns
+ else ["Use get_dataset_info to inspect temporal dataset columns"]
+ ),
+ error_code="NON_TEMPORAL_GANTT_TIME_COLUMN",
+ )
+
+ def normalize_column_refs( # noqa: C901
+ self, config: Any, dataset_context: Any
+ ) -> Any:
+ explicit_fields = set(config.model_fields_set)
+ config_dict = config.model_dump()
+ column_names = [column["name"] for column in dataset_context.available_columns]
+ metric_names = [metric["name"] for metric in dataset_context.available_metrics]
+
+ def canonical_column(name: str, _context: Any) -> str:
+ try:
+ return (
+ resolve_dataset_reference(name, column_names, "physical column")
+ or name
+ )
+ except AmbiguousDatasetReferenceError as ex:
+ raise GanttSemanticNormalizationError(f"Gantt {ex}") from ex
+
+ def canonical_metric(name: str, _context: Any) -> str:
+ try:
+ return (
+ resolve_dataset_reference(name, metric_names, "saved metric")
+ or name
+ )
+ except AmbiguousDatasetReferenceError as ex:
+ raise GanttSemanticNormalizationError(f"Gantt {ex}") from ex
+
+ if temporal_column := config_dict.get("temporal_column"):
+ config_dict["temporal_column"] = canonical_column(
+ temporal_column, dataset_context
+ )
+ for key in ("start_time", "end_time", "category", "series"):
+ if ref := config_dict.get(key):
+ ref["name"] = canonical_column(ref["name"], dataset_context)
+ for ref in config_dict.get("tooltip_columns") or []:
+ ref["name"] = canonical_column(ref["name"], dataset_context)
+ for ref in config_dict.get("tooltip_metrics") or []:
+ if ref.get("sql_expression"):
+ continue
+ resolver = canonical_metric if ref.get("saved_metric") else canonical_column
+ ref["name"] = resolver(ref["name"], dataset_context)
+ for order in config_dict.get("order_by") or []:
+ order["column"] = canonical_column(order["column"], dataset_context)
+ DatasetValidator.normalize_filters(config_dict, dataset_context)
+ try:
+ normalized = GanttChartConfig.model_validate(config_dict)
+ except ValidationError as ex:
+ reasons = "; ".join(error["msg"] for error in ex.errors()[:3])
+ raise GanttSemanticNormalizationError(
+ "Gantt column normalization produced an invalid semantic "
+ f"configuration: {reasons}"
+ ) from ex
+ # ``model_dump()`` necessarily contains defaults, but mapping relies on
+ # ``model_fields_set`` to distinguish omitted presentation controls from
+ # explicitly supplied default values. Keep that request-level intent
+ # after canonicalizing column names.
+ normalized.model_fields_set.clear()
+ normalized.model_fields_set.update(explicit_fields)
+ return normalized
+
+ def generate_name(self, config: Any, dataset_name: str | None = None) -> str:
+ category = config.category.label or config.category.name
+ what = f"{category} schedule"
+ if config.series:
+ what += f" by {config.series.label or config.series.name}"
+ return self._with_context(what, _summarize_filters(config.filters))
+
+ def resolve_viz_type(self, config: Any) -> str:
+ return "gantt_chart"
+
+ def schema_error_hint(self) -> ChartGenerationError | None:
+ return ChartGenerationError(
+ error_type="gantt_validation_error",
+ message="Gantt chart configuration validation failed",
+ details=(
+ "Gantt requires distinct temporal start/end columns and one "
+ "task/category dimension."
+ ),
+ suggestions=[
+ "Use start_time and end_time ColumnRefs for temporal columns",
+ "Use category (aliases: task, y_axis) for the task row dimension",
+ "tooltip_metrics must be aggregate or saved metrics",
+ "Use series to color tasks; subcategories=True requires series",
+ ],
+ error_code="GANTT_VALIDATION_ERROR",
+ )
diff --git a/superset/mcp_service/chart/preview_utils.py b/superset/mcp_service/chart/preview_utils.py
index 09f19c6439a..db7f96010dd 100644
--- a/superset/mcp_service/chart/preview_utils.py
+++ b/superset/mcp_service/chart/preview_utils.py
@@ -25,6 +25,7 @@ from form data without requiring a saved chart object.
import logging
import math
from copy import deepcopy
+from datetime import date, datetime, time, timezone
from typing import Any, Dict, List
from superset.mcp_service.chart.query_result import (
@@ -81,6 +82,8 @@ def generate_preview_from_form_data(
error=f"Dataset {dataset_id} not found", error_type="DatasetNotFound"
)
+ # Create query context through the chart-aware shared builder used by
+ # saved/cached get_chart_data and compile validation.
from superset.mcp_service.chart.chart_helpers import (
build_query_context_from_form_data,
)
@@ -102,6 +105,7 @@ def generate_preview_from_form_data(
if query_failure := query_result_failure(result):
return query_failure
+
result = normalize_gauge_query_result(result, form_data)
if isinstance(result, ChartError):
return result
@@ -460,6 +464,265 @@ def _is_nan(value: Any) -> bool:
return False
+def _gantt_metric_label(metric: Any) -> str | None:
+ """Resolve metric labels, accepting native columnName/column_name objects."""
+ if isinstance(metric, str) and metric:
+ return metric
+ if not isinstance(metric, dict) or not 0 < len(metric) <= 20:
+ return None
+
+ label = metric.get("label")
+ if label:
+ return label if isinstance(label, str) else None
+ 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 aggregate
+ or len(aggregate) > 100
+ or not isinstance(column, dict)
+ or not 0 < len(column) <= 50
+ ):
+ return None
+ column_name = column.get("columnName") or column.get("column_name")
+ if not isinstance(column_name, str) or not column_name:
+ return None
+ return f"{aggregate}({column_name})"
+ if expression_type == "SQL":
+ sql_expression = metric.get("sqlExpression")
+ if (
+ isinstance(sql_expression, str)
+ and sql_expression
+ and len(sql_expression) <= 2000
+ ):
+ return sql_expression
+ return None
+
+
+def _gantt_result_field(field: Any, _field_name: str) -> str | None:
+ """Use frontend label/SQL precedence, then native physical column_name."""
+ if isinstance(field, str) and field:
+ return field
+ if not isinstance(field, dict) or not 0 < len(field) <= 20:
+ return None
+ label = field.get("label") or field.get("sqlExpression") or field.get("column_name")
+ return label if isinstance(label, str) and label else None
+
+
+def _gantt_temporal_value(value: Any) -> float | None: # noqa: C901
+ """Return a comparable millisecond timestamp for one bounded date value."""
+ if value is None or isinstance(value, bool):
+ return None
+ if isinstance(value, (int, float)):
+ try:
+ converted = float(value)
+ except OverflowError:
+ return None
+ return converted if math.isfinite(converted) else None
+ if isinstance(value, datetime):
+ parsed = value
+ elif isinstance(value, date):
+ parsed = datetime.combine(value, time.min)
+ elif isinstance(value, str) and 0 < len(value) <= 128:
+ try:
+ parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
+ except ValueError:
+ try:
+ parsed = datetime.combine(date.fromisoformat(value), time.min)
+ except ValueError:
+ return None
+ else:
+ return None
+ if parsed.tzinfo is None:
+ parsed = parsed.replace(tzinfo=timezone.utc)
+ try:
+ return parsed.timestamp() * 1000
+ except (OverflowError, OSError, ValueError):
+ return None
+
+
+def _is_valid_gantt_category(value: Any) -> bool:
+ """Return whether a category can produce a stable, visible nominal label."""
+ if value is None or isinstance(value, bool):
+ return False
+ if isinstance(value, str):
+ return bool(value.strip())
+ if isinstance(value, (int, float)):
+ try:
+ return math.isfinite(float(value))
+ except OverflowError:
+ return False
+ return False
+
+
+def _generate_gantt_vega_lite_preview( # noqa: C901
+ data: Any, form_data: Dict[str, Any]
+) -> VegaLitePreview | ChartError:
+ """Build a bounded preview from renderable intervals, checking result shape."""
+ if not isinstance(data, list):
+ return ChartError(
+ error="Gantt result data is not an array of rows.",
+ error_type="InvalidGanttResult",
+ )
+ start = _gantt_result_field(form_data.get("start_time"), "start_time")
+ end = _gantt_result_field(form_data.get("end_time"), "end_time")
+ category = _gantt_result_field(form_data.get("y_axis"), "y_axis")
+ if not all((start, end, category)):
+ return ChartError(
+ error=(
+ "Gantt Vega-Lite preview requires valid start_time, end_time, "
+ "and y_axis form-data column references."
+ ),
+ error_type="InvalidGanttFormData",
+ )
+ assert isinstance(start, str)
+ assert isinstance(end, str)
+ assert isinstance(category, str)
+ if len({start.casefold(), end.casefold(), category.casefold()}) != 3:
+ return ChartError(
+ error=(
+ "Gantt start_time, end_time, and y_axis must resolve to distinct "
+ "query-result fields."
+ ),
+ error_type="InvalidGanttFormData",
+ )
+
+ series_value = form_data.get("series")
+ series = (
+ _gantt_result_field(series_value, "series")
+ if series_value is not None
+ else None
+ )
+ if series_value is not None and series is None:
+ return ChartError(
+ error="Gantt series must be a valid column reference.",
+ error_type="InvalidGanttFormData",
+ )
+
+ raw_tooltip_columns = form_data.get("tooltip_columns") or []
+ raw_tooltip_metrics = form_data.get("tooltip_metrics") or []
+ if not isinstance(raw_tooltip_columns, list) or len(raw_tooltip_columns) > 50:
+ return ChartError(
+ error="Gantt tooltip_columns must contain at most 50 column references.",
+ error_type="InvalidGanttFormData",
+ )
+ if not isinstance(raw_tooltip_metrics, list) or len(raw_tooltip_metrics) > 50:
+ return ChartError(
+ error="Gantt tooltip_metrics must contain at most 50 metric references.",
+ error_type="InvalidGanttFormData",
+ )
+ tooltip_columns: list[str] = []
+ for index, field in enumerate(raw_tooltip_columns):
+ label = _gantt_result_field(field, f"tooltip_columns[{index}]")
+ if label is None:
+ return ChartError(
+ error=(
+ f"Gantt tooltip_columns[{index}] must be a valid column reference."
+ ),
+ error_type="InvalidGanttFormData",
+ )
+ tooltip_columns.append(label)
+ tooltip_metrics: list[str] = []
+ for index, metric in enumerate(raw_tooltip_metrics):
+ label = _gantt_metric_label(metric)
+ if label is None:
+ return ChartError(
+ error=(
+ f"Gantt tooltip_metrics[{index}] must expose a non-empty "
+ "result label."
+ ),
+ error_type="InvalidGanttFormData",
+ )
+ tooltip_metrics.append(label)
+
+ required = {start, end, category, *tooltip_columns, *tooltip_metrics}
+ if series:
+ required.add(series)
+ renderable_rows: list[dict[str, Any]] = []
+ for index, row in enumerate(data):
+ if not isinstance(row, dict):
+ return ChartError(
+ error=f"Gantt result row {index} is not an object.",
+ error_type="InvalidGanttResult",
+ )
+ missing = required - row.keys()
+ if missing:
+ return ChartError(
+ error=(
+ f"Gantt result row {index} is missing required output fields: "
+ f"{', '.join(sorted(missing))}."
+ ),
+ error_type="InvalidGanttResult",
+ )
+ start_value = _gantt_temporal_value(row[start])
+ end_value = _gantt_temporal_value(row[end])
+ if (
+ start_value is None
+ or end_value is None
+ or end_value < start_value
+ or not _is_valid_gantt_category(row[category])
+ ):
+ continue
+ # Limit the LLM-facing payload and omit fields unused by the preview.
+ if len(renderable_rows) < 1000:
+ renderable_rows.append({field: row[field] for field in sorted(required)})
+
+ if data and not renderable_rows:
+ return ChartError(
+ error="Gantt result contains no renderable intervals.",
+ error_type="InvalidGanttResult",
+ )
+
+ tooltip_fields: list[dict[str, str]] = [
+ {"field": category, "type": "nominal"},
+ {"field": start, "type": "temporal"},
+ {"field": end, "type": "temporal"},
+ ]
+ for field in ([series] if series else []) + tooltip_columns:
+ if isinstance(field, str) and field not in {
+ item["field"] for item in tooltip_fields
+ }:
+ tooltip_fields.append({"field": field, "type": "nominal"})
+ for label in tooltip_metrics:
+ if label not in {item["field"] for item in tooltip_fields}:
+ tooltip_fields.append({"field": label, "type": "quantitative"})
+
+ encoding: dict[str, Any] = {
+ "x": {
+ "field": start,
+ "type": "temporal",
+ "title": form_data.get("x_axis_title"),
+ },
+ "x2": {"field": end},
+ "y": {
+ "field": category,
+ "type": "nominal",
+ "title": form_data.get("y_axis_title") or category,
+ },
+ "tooltip": tooltip_fields,
+ }
+ if series:
+ encoding["color"] = {"field": series, "type": "nominal", "title": series}
+ if form_data.get("subcategories"):
+ encoding["yOffset"] = {"field": series, "type": "nominal"}
+
+ return VegaLitePreview(
+ specification={
+ "$schema": "https://vega.github.io/schema/vega-lite/v5.json",
+ "data": {"values": renderable_rows},
+ "mark": {"type": "bar", "tooltip": True},
+ "encoding": encoding,
+ "width": "container",
+ "height": 400,
+ },
+ )
+
+
_GAUGE_COLORS = (
"#1FA8C9",
"#454E7C",
@@ -933,6 +1196,8 @@ def _generate_vega_lite_preview_from_data( # noqa: C901
) -> VegaLitePreview | ChartError:
"""Generate Vega-Lite preview from raw data and form_data."""
viz_type = form_data.get("viz_type", "table")
+ if viz_type == "gantt_chart":
+ return _generate_gantt_vega_lite_preview(data, form_data)
if viz_type == "gauge_chart":
return generate_gauge_vega_lite_preview(data, form_data)
diff --git a/superset/mcp_service/chart/query_result.py b/superset/mcp_service/chart/query_result.py
index bc468be316a..16c01122d16 100644
--- a/superset/mcp_service/chart/query_result.py
+++ b/superset/mcp_service/chart/query_result.py
@@ -78,7 +78,13 @@ def _failure_for_query_payload(
def query_result_failure(result: Any) -> ChartError | None:
- """Return a structured failure embedded in a ChartDataCommand payload."""
+ """Return a structured failure embedded in a ChartDataCommand payload.
+
+ ChartDataCommand can return an HTTP-successful envelope whose top level or
+ any query reports a failure. Every query is inspected before callers accept
+ data from the result. Successful statuses may carry informational messages,
+ so ``message`` alone is not treated as an error.
+ """
if not isinstance(result, Mapping):
return None
diff --git a/superset/mcp_service/chart/resources/chart_configs.py b/superset/mcp_service/chart/resources/chart_configs.py
index c76046ae97a..4a0ac58c89b 100644
--- a/superset/mcp_service/chart/resources/chart_configs.py
+++ b/superset/mcp_service/chart/resources/chart_configs.py
@@ -230,6 +230,23 @@ def get_chart_configs_resource() -> str:
},
}
+ gantt_chart_configs = {
+ "project_schedule": {
+ "description": "Task intervals grouped by owner",
+ "config": {
+ "chart_type": "gantt",
+ "start_time": {"name": "start_time"},
+ "end_time": {"name": "end_time"},
+ "category": {"name": "task_name"},
+ "series": {"name": "owner"},
+ "tooltip_columns": [{"name": "project"}],
+ "order_by": [{"column": "start_time", "ascending": True}],
+ "subcategories": True,
+ },
+ "use_cases": ["Project schedules", "Task and phase timelines"],
+ },
+ }
+
from superset.mcp_service.chart.registry import get_registry
interactive_pivot_configs = {}
@@ -302,6 +319,12 @@ 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",
],
+ "gantt_charts": [
+ "Use temporal dataset columns for both start_time and end_time",
+ "Use category for the task/status row and series for color grouping",
+ "Set subcategories=true only when series is configured",
+ "Use tooltip_columns for dimensions and tooltip_metrics for aggregates",
+ ],
"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",
@@ -321,6 +344,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,
+ "gantt_chart_configs": gantt_chart_configs,
"gauge_configs": gauge_configs,
"best_practices": best_practices,
"usage_notes": [
diff --git a/superset/mcp_service/chart/schemas.py b/superset/mcp_service/chart/schemas.py
index b1e50cdbb1b..b910e79b1e3 100644
--- a/superset/mcp_service/chart/schemas.py
+++ b/superset/mcp_service/chart/schemas.py
@@ -25,7 +25,8 @@ import difflib
import logging
import math
import re
-from datetime import datetime
+from collections.abc import Mapping
+from datetime import datetime, time
from typing import Annotated, Any, Dict, get_args, List, Literal, Protocol
from pydantic import (
@@ -982,6 +983,35 @@ class SortByConfig(UnknownFieldCheckMixin):
)
+class GanttSortByConfig(UnknownFieldCheckMixin):
+ """One physical-column ordering applied before Gantt rendering."""
+
+ model_config = ConfigDict(extra="ignore", populate_by_name=True)
+
+ column: str = Field(
+ ...,
+ min_length=1,
+ max_length=255,
+ validation_alias=AliasChoices("column", "col"),
+ description="Physical dataset column used to order Gantt tasks",
+ )
+ ascending: bool = Field(
+ True,
+ description="Sort this column ascending when true, descending when false",
+ )
+
+ @field_validator("column")
+ @classmethod
+ def sanitize_column(cls, value: str) -> str:
+ """Sanitize the order column like every other dimension name."""
+ return sanitize_user_input(
+ value,
+ "Gantt order column",
+ max_length=255,
+ check_sql_keywords=True,
+ ) # type: ignore[return-value]
+
+
# Actual chart types
class PieChartConfig(BaseChartConfig):
model_config = ConfigDict(extra="ignore", populate_by_name=True)
@@ -2207,6 +2237,20 @@ def _reject_sql_expression_on_dimension(col: ColumnRef | None, position: str) ->
)
+def _validate_distinct_gantt_roles(roles: Mapping[str, str | None]) -> None:
+ """Require Gantt query-result roles to have distinct canonical labels."""
+ seen: dict[str, str] = {}
+ for role, name in roles.items():
+ if name is None:
+ continue
+ canonical_name = name.casefold()
+ if previous_role := seen.get(canonical_name):
+ raise ValueError(
+ f"{previous_role} and {role} must reference different columns"
+ )
+ seen[canonical_name] = role
+
+
def _metric_display_label(col: ColumnRef) -> str:
"""Return the display label for a metric column reference."""
if col.sql_expression:
@@ -2661,6 +2705,757 @@ class WaterfallChartConfig(BaseChartConfig):
return self
+class GanttChartConfig(BaseChartConfig):
+ """Typed contract for the ECharts Gantt visualization.
+
+ The field names intentionally describe Gantt semantics while validation
+ aliases accept the native keys used by ``Gantt/buildQuery.ts`` and saved
+ Explore ``form_data``.
+ """
+
+ model_config = ConfigDict(extra="ignore", populate_by_name=True)
+
+ chart_type: Literal["gantt"] = "gantt"
+ start_time: ColumnRef = Field(
+ ...,
+ description="Temporal column containing each task's start timestamp",
+ validation_alias=AliasChoices("start_time", "startTime", "start"),
+ )
+ end_time: ColumnRef = Field(
+ ...,
+ description="Temporal column containing each task's end timestamp",
+ validation_alias=AliasChoices("end_time", "endTime", "end"),
+ )
+ category: ColumnRef = Field(
+ ...,
+ description=(
+ "Task/category dimension shown as the Gantt row label (native y_axis)"
+ ),
+ validation_alias=AliasChoices("category", "task", "y_axis", "yAxis"),
+ )
+ series: ColumnRef | None = Field(
+ None,
+ description="Optional dimension used for color series and legend entries",
+ )
+ subcategories: bool = Field(
+ False,
+ description=(
+ "Split each category into separate rows for series values; requires series"
+ ),
+ )
+ tooltip_columns: List[ColumnRef] = Field(
+ default_factory=list,
+ description="Additional physical columns included in task tooltips",
+ max_length=50,
+ )
+ tooltip_metrics: List[ColumnRef] = Field(
+ default_factory=list,
+ description=("Additional aggregate or saved metrics included in task tooltips"),
+ max_length=50,
+ )
+ order_by: List[GanttSortByConfig] = Field(
+ default_factory=list,
+ description=(
+ "Stable task ordering. Native order_by_cols JSON pairs are also "
+ "accepted for saved-form-data round trips."
+ ),
+ validation_alias=AliasChoices("order_by", "order_by_cols"),
+ max_length=100,
+ )
+ filters: List[FilterConfig] | None = Field(
+ None,
+ description=(
+ "Structured filters (column/op/value). Native SIMPLE adhoc_filters "
+ "are adapted; free-form SQL filters are rejected."
+ ),
+ )
+ time_range: str | None = Field(
+ None,
+ max_length=1000,
+ description=(
+ "Optional bounded Superset time range applied to the chart, such as "
+ "'Last 30 days' or '2025-01-01 : 2025-12-31'"
+ ),
+ )
+ row_limit: int = Field(
+ 10000,
+ ge=1,
+ le=50000,
+ description="Maximum number of task rows returned",
+ )
+
+ # Presentation controls read by Gantt/transformProps.ts.
+ color_scheme: str | None = Field(None, max_length=100)
+ show_legend: bool = True
+ legend_orientation: LEGEND_POSITION_LITERAL = Field(
+ "top",
+ validation_alias=AliasChoices("legend_orientation", "legendOrientation"),
+ )
+ legend_type: Literal["plain", "scroll"] = Field(
+ "scroll",
+ validation_alias=AliasChoices("legend_type", "legendType"),
+ )
+ legend_margin: int | None = Field(
+ None,
+ ge=0,
+ le=1000,
+ validation_alias=AliasChoices("legend_margin", "legendMargin"),
+ )
+ legend_sort: Literal["asc", "desc"] | None = Field(
+ None,
+ validation_alias=AliasChoices("legend_sort", "legendSort"),
+ )
+ zoomable: bool = False
+ show_extra_controls: bool = False
+ x_axis_time_bounds: tuple[str | None, str | None] | None = Field(
+ None,
+ description="Optional daily HH:MM:SS lower/upper bounds for the time axis",
+ )
+ x_axis_time_format: str = Field("smart_date", max_length=100)
+ tooltip_time_format: str = Field(
+ "smart_date",
+ max_length=100,
+ validation_alias=AliasChoices("tooltip_time_format", "tooltipTimeFormat"),
+ )
+ tooltip_values_format: str = Field(
+ "SMART_NUMBER",
+ max_length=100,
+ validation_alias=AliasChoices("tooltip_values_format", "tooltipValuesFormat"),
+ )
+ x_axis_title: str | None = Field(None, max_length=200)
+ x_axis_title_margin: int | None = Field(None, ge=0, le=1000)
+ y_axis_title: str | None = Field(None, max_length=200)
+ y_axis_title_margin: int | None = Field(None, ge=0, le=1000)
+
+ @model_validator(mode="before")
+ @classmethod
+ def adapt_native_form_data(cls, raw: Any) -> Any: # noqa: C901
+ """Adapt bounded, recognized native Gantt form data without typo drops."""
+ if not isinstance(raw, dict):
+ return raw
+ data = dict(raw)
+
+ if "y_axis_title_position" in data:
+ raise ValueError(
+ "y_axis_title_position is unsupported by Gantt; the frontend "
+ "always centers the Y-axis title"
+ )
+
+ # Request normalization maps viz_type before this validator, while direct
+ # model validation may still carry the native discriminator.
+ if data.get("viz_type") == "gantt_chart":
+ data.setdefault("chart_type", "gantt")
+ data.pop("viz_type", None)
+ if data.get("chart_type") == "gantt_chart":
+ data["chart_type"] = "gantt"
+
+ # Saved chart transport metadata is not visualization configuration.
+ # Keep this allowlist deliberately narrow so misspelled controls still fail.
+ for key in (
+ "annotation_layers",
+ "dashboards",
+ "datasource",
+ "extra_form_data",
+ "slice_id",
+ "slice_name",
+ ):
+ data.pop(key, None)
+
+ marker_key = "_mcp_dashboard_time_filter_subject"
+ marker_present = marker_key in data
+ marker = data.pop(marker_key, None)
+ if marker_present and (not isinstance(marker, str) or not marker):
+ raise ValueError(f"{marker_key} must be a non-empty physical column name")
+
+ for key in (
+ "start_time",
+ "startTime",
+ "start",
+ "end_time",
+ "endTime",
+ "end",
+ "category",
+ "task",
+ "y_axis",
+ "yAxis",
+ "series",
+ ):
+ if isinstance(data.get(key), str):
+ data[key] = {"name": data[key]}
+
+ for key in ("tooltip_columns",):
+ if key in data and isinstance(data[key], list):
+ data[key] = [
+ {"name": item} if isinstance(item, str) else item
+ for item in data[key]
+ ]
+ if isinstance(data.get("tooltip_metrics"), list):
+ data["tooltip_metrics"] = [
+ cls._adapt_native_tooltip_metric(item, index)
+ for index, item in enumerate(data["tooltip_metrics"])
+ ]
+
+ if "adhoc_filters" in data:
+ if "filters" in data:
+ raise ValueError(
+ "Use either filters or native adhoc_filters for Gantt, not both"
+ )
+ native_filters = data.pop("adhoc_filters")
+ if not isinstance(native_filters, list):
+ raise ValueError("adhoc_filters must be an array")
+ if len(native_filters) > 100:
+ raise ValueError("adhoc_filters accepts at most 100 entries")
+ filters: list[dict[str, Any]] = []
+ temporal_filters: list[tuple[int, str, Any]] = []
+ allowed_filter_keys = {
+ "clause",
+ "comparator",
+ "datasourceWarning",
+ "expressionType",
+ "filterOptionName",
+ "isExtra",
+ "isNew",
+ "operator",
+ "operatorId",
+ "sqlExpression",
+ "subject",
+ }
+ for index, native_filter in enumerate(native_filters):
+ if not isinstance(native_filter, dict):
+ raise ValueError(f"adhoc_filters[{index}] must be an object")
+ if native_filter.get("expressionType") != "SIMPLE":
+ raise ValueError(
+ f"adhoc_filters[{index}] must use expressionType='SIMPLE'; "
+ "free-form SQL filters are not supported by typed Gantt"
+ )
+ unknown_filter_keys = set(native_filter) - allowed_filter_keys
+ if unknown_filter_keys:
+ raise ValueError(
+ f"adhoc_filters[{index}] has unsupported fields: "
+ f"{', '.join(sorted(unknown_filter_keys))}"
+ )
+ if native_filter.get("clause") not in (None, "WHERE"):
+ raise ValueError(f"adhoc_filters[{index}] must use clause='WHERE'")
+ for metadata_key in ("isExtra", "isNew", "datasourceWarning"):
+ metadata_value = native_filter.get(metadata_key)
+ if metadata_value is not None and not isinstance(
+ metadata_value, bool
+ ):
+ raise ValueError(
+ f"adhoc_filters[{index}].{metadata_key} must be boolean"
+ )
+ for metadata_key, max_length in (
+ ("filterOptionName", 500),
+ ("operatorId", 100),
+ ):
+ metadata_value = native_filter.get(metadata_key)
+ if metadata_value is not None and (
+ not isinstance(metadata_value, str)
+ or not metadata_value
+ or len(metadata_value) > max_length
+ ):
+ raise ValueError(
+ f"adhoc_filters[{index}].{metadata_key} must be a "
+ "non-empty string"
+ )
+ if native_filter.get("sqlExpression") is not None:
+ raise ValueError(
+ f"adhoc_filters[{index}].sqlExpression must be null for "
+ "expressionType='SIMPLE'"
+ )
+ subject = native_filter.get("subject")
+ operator = native_filter.get("operator")
+ operator_id = native_filter.get("operatorId")
+ comparator = native_filter.get("comparator")
+ if not isinstance(operator, str) or not operator:
+ raise ValueError(
+ f"adhoc_filters[{index}].operator must be a non-empty string"
+ )
+ operator_id_map: dict[str, str | None] = {
+ "EQUALS": "==",
+ "NOT_EQUALS": "!=",
+ "LESS_THAN": "<",
+ "LESS_THAN_OR_EQUAL": "<=",
+ "GREATER_THAN": ">",
+ "GREATER_THAN_OR_EQUAL": ">=",
+ "IN": "IN",
+ "NOT_IN": "NOT IN",
+ "LIKE": "LIKE",
+ "ILIKE": "ILIKE",
+ "IS_NOT_NULL": "IS NOT NULL",
+ "IS_NULL": "IS NULL",
+ "TEMPORAL_RANGE": "TEMPORAL_RANGE",
+ # These are real Explore operator IDs, but FilterConfig and
+ # the shared query-filter contract cannot represent them
+ # without changing their meaning. Reject them explicitly.
+ "LATEST_PARTITION": None,
+ "IS_TRUE": None,
+ "IS_FALSE": None,
+ "CONTAINS_ANY": None,
+ "CONTAINS_ALL": None,
+ "IS_EMPTY": None,
+ "IS_NOT_EMPTY": None,
+ "LENGTH_EQUALS": None,
+ "LENGTH_GREATER_THAN": None,
+ "LENGTH_LESS_THAN": None,
+ "LENGTH_GREATER_THAN_OR_EQUALS": None,
+ "LENGTH_LESS_THAN_OR_EQUALS": None,
+ }
+ if operator_id is not None:
+ if operator_id not in operator_id_map:
+ raise ValueError(
+ f"adhoc_filters[{index}].operatorId {operator_id!r} "
+ "is not a recognized Explore operator ID"
+ )
+ expected_operator = operator_id_map[operator_id]
+ if expected_operator is None:
+ raise ValueError(
+ f"adhoc_filters[{index}].operatorId {operator_id!r} "
+ "is not supported by typed Gantt filters"
+ )
+ if operator != expected_operator:
+ raise ValueError(
+ f"adhoc_filters[{index}] has contradictory operator "
+ f"{operator!r} for operatorId {operator_id!r}; expected "
+ f"{expected_operator!r}"
+ )
+
+ supported_operators = {
+ expected
+ for expected in operator_id_map.values()
+ if expected is not None
+ } | {"NOT LIKE"}
+ if operator not in supported_operators:
+ raise ValueError(
+ f"adhoc_filters[{index}].operator {operator!r} is not "
+ "supported by typed Gantt filters"
+ )
+ if operator == "TEMPORAL_RANGE":
+ if not isinstance(subject, str) or not subject:
+ raise ValueError(
+ f"adhoc_filters[{index}] temporal filter needs subject"
+ )
+ if not isinstance(comparator, str) or not comparator:
+ raise ValueError(
+ f"adhoc_filters[{index}] temporal comparator must "
+ "be a non-empty string"
+ )
+ temporal_filters.append((index, subject, comparator))
+ continue
+ if not isinstance(subject, str) or not subject:
+ raise ValueError(f"adhoc_filters[{index}] needs subject")
+ if operator in {"IS NULL", "IS NOT NULL"}:
+ if comparator is not None:
+ raise ValueError(
+ f"adhoc_filters[{index}] operator {operator!r} must not "
+ "define comparator"
+ )
+ elif operator in {"IN", "NOT IN"}:
+ if not isinstance(comparator, list) or not comparator:
+ raise ValueError(
+ f"adhoc_filters[{index}] operator {operator!r} requires "
+ "a non-empty comparator array"
+ )
+ elif comparator is None or isinstance(comparator, (dict, list)):
+ raise ValueError(
+ f"adhoc_filters[{index}] operator {operator!r} requires "
+ "a scalar comparator"
+ )
+ filters.append(
+ {
+ "column": subject,
+ "op": "=" if operator == "==" else operator,
+ "value": comparator,
+ }
+ )
+ data["filters"] = filters or None
+
+ if len(temporal_filters) > 1:
+ indexes = ", ".join(str(item[0]) for item in temporal_filters)
+ raise ValueError(
+ "Typed Gantt supports at most one TEMPORAL_RANGE adhoc filter; "
+ f"found filters at indexes {indexes}"
+ )
+ if temporal_filters:
+ index, subject, comparator = temporal_filters[0]
+ configured_subject = data.get("temporal_column")
+ if configured_subject is not None and configured_subject != subject:
+ raise ValueError(
+ f"temporal_column conflicts with adhoc_filters[{index}].subject"
+ )
+ data["temporal_column"] = subject
+ if marker_present and marker != subject:
+ raise ValueError(
+ f"{marker_key} conflicts with adhoc_filters[{index}].subject"
+ )
+ if comparator not in (None, "", "No filter"):
+ configured_range = data.get("time_range")
+ if configured_range is not None and configured_range != comparator:
+ raise ValueError(
+ "time_range conflicts with "
+ f"adhoc_filters[{index}].comparator"
+ )
+ data["time_range"] = comparator
+ elif marker_present:
+ raise ValueError(
+ f"{marker_key} requires a matching TEMPORAL_RANGE adhoc filter"
+ )
+ elif marker_present:
+ raise ValueError(
+ f"{marker_key} requires a matching TEMPORAL_RANGE adhoc filter"
+ )
+ return data
+
+ @staticmethod
+ def _adapt_native_tooltip_metric(metric: Any, index: int) -> Any: # noqa: C901
+ """Convert one bounded native QueryFormMetric into a typed metric ref."""
+ if isinstance(metric, str):
+ return {"name": metric, "saved_metric": True}
+ if not isinstance(metric, dict):
+ raise ValueError(
+ f"tooltip_metrics[{index}] must be a saved metric name or object"
+ )
+ # Typed ColumnRef objects do not carry the frontend discriminator and
+ # must continue through normal Pydantic validation unchanged.
+ if "expressionType" not in metric:
+ return metric
+
+ allowed_keys = {
+ "aggregate",
+ "column",
+ "datasourceWarning",
+ "expressionType",
+ "hasCustomLabel",
+ "isNew",
+ "label",
+ "optionName",
+ "sqlExpression",
+ }
+ if unknown := set(metric) - allowed_keys:
+ raise ValueError(
+ f"tooltip_metrics[{index}] has unsupported fields: "
+ f"{', '.join(sorted(unknown))}"
+ )
+ option_name = metric.get("optionName")
+ if option_name is not None and (
+ not isinstance(option_name, str)
+ or not option_name
+ or len(option_name) > 500
+ ):
+ raise ValueError(
+ f"tooltip_metrics[{index}].optionName must be a non-empty string"
+ )
+ datasource_warning = metric.get("datasourceWarning")
+ if datasource_warning is not None and not isinstance(datasource_warning, bool):
+ raise ValueError(
+ f"tooltip_metrics[{index}].datasourceWarning must be boolean"
+ )
+ custom_label = metric.get("hasCustomLabel")
+ if custom_label is not None and not isinstance(custom_label, bool):
+ raise ValueError(f"tooltip_metrics[{index}].hasCustomLabel must be boolean")
+ is_new = metric.get("isNew")
+ if is_new is not None and not isinstance(is_new, bool):
+ raise ValueError(f"tooltip_metrics[{index}].isNew must be boolean")
+
+ expression_type = metric.get("expressionType")
+ label = metric.get("label")
+ if label is not None and (not isinstance(label, str) or not label):
+ raise ValueError(
+ f"tooltip_metrics[{index}].label must be a non-empty string"
+ )
+ if expression_type == "SIMPLE":
+ aggregate = metric.get("aggregate")
+ column = metric.get("column")
+ if not isinstance(aggregate, str) or not aggregate:
+ raise ValueError(
+ f"tooltip_metrics[{index}] SIMPLE metric needs aggregate"
+ )
+ if not isinstance(column, dict):
+ raise ValueError(f"tooltip_metrics[{index}] SIMPLE metric needs column")
+ # QueryFormMetric embeds the selected frontend Column metadata.
+ # Keep this allowlist aligned with the frontend Column contract and
+ # the additional fields emitted by TableColumn.data.
+ allowed_column_keys = {
+ "advanced_data_type",
+ "certification_details",
+ "certified_by",
+ "columnName",
+ "column_name",
+ "database_expression",
+ "description",
+ "expression",
+ "filterBy",
+ "filterable",
+ "groupby",
+ "id",
+ "is_certified",
+ "is_dttm",
+ "optionName",
+ "python_date_format",
+ "type",
+ "type_generic",
+ "uuid",
+ "value",
+ "verbose_name",
+ "warning_markdown",
+ }
+ unknown_column_keys = set(column) - allowed_column_keys
+ if unknown_column_keys:
+ raise ValueError(
+ f"tooltip_metrics[{index}].column has unsupported fields: "
+ f"{', '.join(sorted(unknown_column_keys))}"
+ )
+ boolean_fields = {"filterable", "groupby", "is_certified", "is_dttm"}
+ for metadata_key in boolean_fields & column.keys():
+ if not isinstance(column[metadata_key], bool):
+ raise ValueError(
+ f"tooltip_metrics[{index}].column.{metadata_key} "
+ "must be boolean"
+ )
+ if "id" in column and (
+ isinstance(column["id"], bool)
+ or not isinstance(column["id"], int)
+ or column["id"] < 0
+ ):
+ raise ValueError(
+ f"tooltip_metrics[{index}].column.id must be a non-negative integer"
+ )
+ if "type_generic" in column:
+ type_generic = column["type_generic"]
+ if type_generic is not None and (
+ isinstance(type_generic, bool)
+ or not isinstance(type_generic, int)
+ or type_generic not in range(5)
+ ):
+ raise ValueError(
+ f"tooltip_metrics[{index}].column.type_generic must be "
+ "null or a GenericDataType value from 0 through 4"
+ )
+
+ nullable_string_fields = {
+ "advanced_data_type",
+ "certification_details",
+ "certified_by",
+ "database_expression",
+ "description",
+ "expression",
+ "python_date_format",
+ "type",
+ "uuid",
+ "verbose_name",
+ "warning_markdown",
+ }
+ for metadata_key in nullable_string_fields & column.keys():
+ metadata_value = column[metadata_key]
+ if metadata_value is not None and (
+ not isinstance(metadata_value, str) or len(metadata_value) > 2000
+ ):
+ raise ValueError(
+ f"tooltip_metrics[{index}].column.{metadata_key} must be "
+ "null or a string of at most 2000 characters"
+ )
+
+ string_fields = {
+ "columnName",
+ "column_name",
+ "filterBy",
+ "optionName",
+ "value",
+ }
+ for metadata_key in string_fields & column.keys():
+ metadata_value = column[metadata_key]
+ if (
+ not isinstance(metadata_value, str)
+ or not metadata_value
+ or len(metadata_value) > 2000
+ ):
+ raise ValueError(
+ f"tooltip_metrics[{index}].column.{metadata_key} must be "
+ "a non-empty string of at most 2000 characters"
+ )
+ column_name = column.get("column_name") or column.get("columnName")
+ if not isinstance(column_name, str) or not column_name:
+ raise ValueError(f"tooltip_metrics[{index}].column needs column_name")
+ if (
+ column.get("column_name") is not None
+ and column.get("columnName") is not None
+ and column["column_name"] != column["columnName"]
+ ):
+ raise ValueError(
+ f"tooltip_metrics[{index}].column has conflicting column names"
+ )
+ column_type = column.get("type")
+ if isinstance(column_type, str) and len(column_type) > 255:
+ raise ValueError(
+ f"tooltip_metrics[{index}].column.type must be at most 255 "
+ "characters"
+ )
+ if metric.get("sqlExpression") is not None:
+ raise ValueError(
+ f"tooltip_metrics[{index}] SIMPLE metric cannot set sqlExpression"
+ )
+ if custom_label is True and label is None:
+ raise ValueError(
+ f"tooltip_metrics[{index}] custom label requires label"
+ )
+ default_label = f"{aggregate.upper()}({column_name})"
+ typed_label = label if custom_label or label != default_label else None
+ return {
+ "name": column_name,
+ "aggregate": aggregate,
+ "label": typed_label,
+ }
+ if expression_type == "SQL":
+ sql_expression = metric.get("sqlExpression")
+ if not isinstance(sql_expression, str) or not sql_expression:
+ raise ValueError(
+ f"tooltip_metrics[{index}] SQL metric needs sqlExpression"
+ )
+ if metric.get("aggregate") is not None or metric.get("column") is not None:
+ raise ValueError(
+ f"tooltip_metrics[{index}] SQL metric cannot set "
+ "aggregate or column"
+ )
+ if custom_label is True and label is None:
+ raise ValueError(
+ f"tooltip_metrics[{index}] custom label requires label"
+ )
+ # ColumnRef requires an output alias for SQL metrics. The frontend's
+ # getMetricLabel uses sqlExpression when no custom label is present,
+ # so using that exact fallback keeps the query-result key stable.
+ return {"sql_expression": sql_expression, "label": label or sql_expression}
+ raise ValueError(
+ f"tooltip_metrics[{index}].expressionType must be 'SIMPLE' or 'SQL'"
+ )
+
+ @field_validator("start_time", "end_time", "category", "series", mode="before")
+ @classmethod
+ def coerce_single_column(cls, value: Any) -> Any:
+ """Accept native bare physical-column names."""
+ return {"name": value} if isinstance(value, str) else value
+
+ @field_validator("tooltip_columns", mode="before")
+ @classmethod
+ def coerce_tooltip_columns(cls, value: Any) -> Any:
+ if not isinstance(value, list):
+ return value
+ return [{"name": item} if isinstance(item, str) else item for item in value]
+
+ @field_validator("order_by", mode="before")
+ @classmethod
+ def parse_native_order_by(cls, value: Any) -> Any:
+ """Parse the frontend's JSON ``[column, ascending]`` strings safely."""
+ if value is None:
+ return []
+ if not isinstance(value, list):
+ raise ValueError("order_by must be an array")
+ if len(value) > 100:
+ raise ValueError("order_by accepts at most 100 entries")
+
+ from superset.utils import json as utils_json
+
+ parsed: list[Any] = []
+ for index, item in enumerate(value):
+ if isinstance(item, str):
+ if len(item) > 1000:
+ raise ValueError(f"order_by[{index}] is too long")
+ try:
+ item = utils_json.loads(item)
+ except (TypeError, ValueError) as ex:
+ raise ValueError(f"order_by[{index}] is not valid JSON") from ex
+ if isinstance(item, (list, tuple)):
+ if (
+ len(item) != 2
+ or not isinstance(item[0], str)
+ or not item[0]
+ or not isinstance(item[1], bool)
+ ):
+ raise ValueError(
+ f"order_by[{index}] must be [column, ascending_boolean]"
+ )
+ item = {"column": item[0], "ascending": item[1]}
+ parsed.append(item)
+ return parsed
+
+ @field_validator("time_range")
+ @classmethod
+ def validate_gantt_time_range(cls, value: str | None) -> str | None:
+ return validate_time_range(value)
+
+ @field_validator("x_axis_time_bounds")
+ @classmethod
+ def validate_time_bounds(
+ cls, value: tuple[str | None, str | None] | None
+ ) -> tuple[str | None, str | None] | None:
+ if value is None:
+ return None
+ value = (value[0] or None, value[1] or None)
+ for bound in value:
+ if bound is None:
+ continue
+ try:
+ time.fromisoformat(bound)
+ except ValueError as ex:
+ raise ValueError("x_axis_time_bounds values must use HH:MM:SS") from ex
+ if len(bound) != 8:
+ raise ValueError("x_axis_time_bounds values must use HH:MM:SS")
+ return value
+
+ @model_validator(mode="after")
+ def validate_gantt_roles(self) -> "GanttChartConfig":
+ """Keep metric and dimension roles aligned with the frontend controls."""
+ dimension_fields: list[tuple[str, ColumnRef | None]] = [
+ ("start_time", self.start_time),
+ ("end_time", self.end_time),
+ ("category", self.category),
+ ("series", self.series),
+ ]
+ dimension_fields.extend(
+ (f"tooltip_columns[{index}]", column)
+ for index, column in enumerate(self.tooltip_columns)
+ )
+ for field_name, column in dimension_fields:
+ _reject_sql_expression_on_dimension(column, field_name)
+ if column is not None and column.saved_metric:
+ raise ValueError(
+ f"{field_name} cannot use saved_metric=True; it is a dimension"
+ )
+ if column is not None and column.aggregate:
+ raise ValueError(
+ f"{field_name} cannot define aggregate; it is a dimension"
+ )
+
+ for index, metric in enumerate(self.tooltip_metrics):
+ if not metric.is_metric:
+ raise ValueError(
+ f"tooltip_metrics[{index}] must define aggregate, "
+ "saved_metric=True, or sql_expression"
+ )
+
+ _validate_distinct_gantt_roles(
+ {
+ "start_time": self.start_time.name,
+ "end_time": self.end_time.name,
+ "category": self.category.name,
+ }
+ )
+
+ if self.subcategories and self.series is None:
+ raise ValueError("subcategories=True requires series")
+ if (
+ self.series is not None
+ and self.series.name is not None
+ and self.category.name is not None
+ and self.series.name.casefold() == self.category.name.casefold()
+ ):
+ raise ValueError("series and category must reference different columns")
+
+ order_names = [item.column.casefold() for item in self.order_by]
+ if len(order_names) != len(set(order_names)):
+ raise ValueError("order_by cannot contain duplicate columns")
+ return self
+
+
# Discriminated union for runtime validation (not exposed in JSON Schema)
ChartConfig = Annotated[
XYChartConfig
@@ -2675,14 +3470,15 @@ ChartConfig = Annotated[
| BigNumberChartConfig
| HistogramChartConfig
| BoxPlotChartConfig
- | WaterfallChartConfig,
+ | WaterfallChartConfig
+ | GanttChartConfig,
Field(
discriminator="chart_type",
description=(
"Chart configuration - specify chart_type as 'xy', 'table', "
"'pie', 'gauge', 'treemap_v2', 'pivot_table', 'interactive_pivot', "
"'mixed_timeseries', 'handlebars', "
- "'big_number', 'histogram', 'box_plot', or 'waterfall'"
+ "'big_number', 'histogram', 'box_plot', 'waterfall', or 'gantt'"
),
),
]
@@ -2712,6 +3508,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),
+ "gantt_chart": ("gantt", 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 bee231145a1..47bd90f24e1 100644
--- a/superset/mcp_service/chart/tool/generate_chart.py
+++ b/superset/mcp_service/chart/tool/generate_chart.py
@@ -86,9 +86,8 @@ 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', 'treemap_v2', 'pivot_table', 'mixed_timeseries',
- 'handlebars',
- 'big_number', 'histogram', 'box_plot', 'waterfall', plus host-gated
+ 'gauge', 'treemap_v2', 'pivot_table', 'mixed_timeseries', 'handlebars',
+ 'big_number', 'histogram', 'box_plot', 'waterfall', 'gantt', plus host-gated
types returned by get_chart_type_schema such as 'interactive_pivot')
IMPORTANT: The 'chart_type' field in the config is a DISCRIMINATOR that determines
@@ -144,6 +143,11 @@ async def generate_chart( # noqa: C901
Required fields: x_axis, metric; optional: breakdown (single category
column, alias: groupby), show_total
+ - Use chart_type='gantt' for task intervals over time.
+ Required fields: start_time, end_time (both temporal), category;
+ optional: series, tooltip_columns, tooltip_metrics, order_by, filters,
+ time_range, subcategories, and presentation controls
+
Quick lookup — natural-language ask -> chart_type (+ kind if applicable):
- "bar chart" / "line chart" / "area chart" / "scatter plot"
-> chart_type='xy', kind='bar'/'line'/'area'/'scatter'
@@ -159,6 +163,7 @@ async def generate_chart( # noqa: C901
- "custom HTML template" -> chart_type='handlebars'
- "histogram" / "distribution" -> chart_type='histogram'
- "box plot" / "box and whisker" -> chart_type='box_plot'
+ - "gantt" / "project schedule" / "task timeline" -> chart_type='gantt'
Example usage for XY chart (bar/line/area/scatter):
```json
diff --git a/superset/mcp_service/chart/tool/get_chart_data.py b/superset/mcp_service/chart/tool/get_chart_data.py
index 8448a79b9f7..cfe190fda2b 100644
--- a/superset/mcp_service/chart/tool/get_chart_data.py
+++ b/superset/mcp_service/chart/tool/get_chart_data.py
@@ -125,6 +125,7 @@ _VIZ_CATEGORY: dict[str, str] = {
# Own category: cumulative-flow semantics differ from a plain bar, like
# funnel/gauge carry distinct categories.
"waterfall": "waterfall",
+ "gantt_chart": "gantt",
}
_MAX_RECOMMENDATIONS = 4
diff --git a/superset/mcp_service/chart/tool/get_chart_preview.py b/superset/mcp_service/chart/tool/get_chart_preview.py
index 9ff3e89c653..f40d9a3bc2b 100644
--- a/superset/mcp_service/chart/tool/get_chart_preview.py
+++ b/superset/mcp_service/chart/tool/get_chart_preview.py
@@ -40,6 +40,7 @@ from superset.mcp_service.chart.chart_helpers import (
)
from superset.mcp_service.chart.chart_utils import validate_chart_dataset
from superset.mcp_service.chart.preview_utils import (
+ _generate_gantt_vega_lite_preview,
generate_gauge_ascii_preview,
generate_gauge_vega_lite_preview,
)
@@ -405,6 +406,25 @@ class VegaLitePreviewStrategy(PreviewFormatStrategy):
except (ValueError, TypeError):
return None
+ def _create_gantt_preview(
+ self, data: Any, form_data: Dict[str, Any]
+ ) -> VegaLitePreview | ChartError:
+ """Build the saved-chart wrapper around the shared Gantt preview."""
+ preview = _generate_gantt_vega_lite_preview(data, form_data)
+ if isinstance(preview, ChartError):
+ return preview
+ preview.specification.update(
+ {
+ "description": (
+ "Chart preview for "
+ f"{getattr(self.chart, 'slice_name', 'Untitled Chart')}"
+ ),
+ "width": self.request.width or 400,
+ "height": self.request.height or 300,
+ }
+ )
+ return preview
+
def generate(self) -> VegaLitePreview | ChartError: # noqa: C901
"""Generate Vega-Lite JSON specification from chart data."""
try:
@@ -472,7 +492,20 @@ class VegaLitePreviewStrategy(PreviewFormatStrategy):
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):
+ viz_type = getattr(self.chart, "viz_type", None) or form_data.get(
+ "viz_type"
+ )
+ if viz_type == "gantt_chart":
+ return self._create_gantt_preview(chart_data, form_data)
+ if not isinstance(chart_data, list):
+ return ChartError(
+ error="Chart result data is not an array of rows",
+ error_type="InvalidResultData",
+ )
+ # An empty Gantt query is still a valid interval chart and has a
+ # useful, typed Vega-Lite spec. Other chart types retain the existing
+ # explicit no-data response.
+ if not chart_data and viz_type != "gantt_chart":
return ChartError(
error="No data available for Vega-Lite visualization",
error_type="NoDataError",
@@ -505,8 +538,17 @@ class VegaLitePreviewStrategy(PreviewFormatStrategy):
def _create_vega_lite_spec(self, data: List[Any]) -> Dict[str, Any]:
"""Create Vega-Lite specification from chart data."""
- if not data:
- return {"data": {"values": []}, "mark": "point"}
+ form_data = self._get_form_data() or {}
+ viz_type = (
+ getattr(self.chart, "viz_type", None)
+ or form_data.get("viz_type")
+ or "table"
+ )
+ if viz_type == "gantt_chart":
+ preview = self._create_gantt_preview(data, form_data)
+ if isinstance(preview, ChartError):
+ raise ValueError(preview.error)
+ return preview.specification
# Get data fields and analyze types
first_row = data[0] if data else {}
@@ -514,8 +556,6 @@ class VegaLitePreviewStrategy(PreviewFormatStrategy):
field_types = self._analyze_field_types(data, fields)
# Determine chart type based on Superset viz_type
- viz_type = getattr(self.chart, "viz_type", "table") or "table"
-
# Basic Vega-Lite specification
spec = {
"$schema": "https://vega.github.io/schema/vega-lite/v5.json",
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 19f40392545..4b4c00aae75 100644
--- a/superset/mcp_service/chart/tool/get_chart_type_schema.py
+++ b/superset/mcp_service/chart/tool/get_chart_type_schema.py
@@ -32,6 +32,7 @@ from superset.extensions import event_logger
from superset.mcp_service.chart.schemas import (
BigNumberChartConfig,
BoxPlotChartConfig,
+ GanttChartConfig,
GaugeChartConfig,
HandlebarsChartConfig,
HistogramChartConfig,
@@ -73,6 +74,7 @@ _CHART_TYPE_ADAPTERS: Dict[str, TypeAdapter[Any]] = {
"histogram": TypeAdapter(HistogramChartConfig),
"box_plot": TypeAdapter(BoxPlotChartConfig),
"waterfall": TypeAdapter(WaterfallChartConfig),
+ "gantt": TypeAdapter(GanttChartConfig),
}
VALID_CHART_TYPES = sorted(_CHART_TYPE_ADAPTERS.keys())
@@ -220,6 +222,17 @@ _CHART_EXAMPLES: Dict[str, list[Dict[str, Any]]] = {
"show_total": True,
},
],
+ "gantt": [
+ {
+ "chart_type": "gantt",
+ "start_time": {"name": "start_time"},
+ "end_time": {"name": "end_time"},
+ "category": {"name": "task_name"},
+ "series": {"name": "owner"},
+ "tooltip_columns": [{"name": "project"}],
+ "order_by": [{"column": "start_time", "ascending": True}],
+ },
+ ],
"gauge": [
{
"chart_type": "gauge",
@@ -323,7 +336,7 @@ def get_chart_type_schema(
Valid chart_type values depend on the host deployment. Core types are xy,
table, pie, gauge, treemap_v2, pivot_table, mixed_timeseries, handlebars,
- big_number, histogram, box_plot, and waterfall. Deployments that enable an
+ big_number, histogram, box_plot, waterfall, and gantt. 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 b011895f120..43d08f99a0d 100644
--- a/superset/mcp_service/chart/tool/update_chart.py
+++ b/superset/mcp_service/chart/tool/update_chart.py
@@ -43,18 +43,23 @@ from superset.mcp_service.chart.chart_utils import (
merge_chart_form_data,
merge_interactive_pivot_ui_config,
merge_table_column_config,
+ validate_gantt_form_data,
)
from superset.mcp_service.chart.compile import validate_and_compile
from superset.mcp_service.chart.schemas import (
AccessibilityMetadata,
ChartConfig,
ColumnRef,
+ GanttChartConfig,
GaugeChartConfig,
GenerateChartResponse,
PerformanceMetadata,
TableChartConfig,
UpdateChartRequest,
)
+from superset.mcp_service.chart.validation.dataset_validator import (
+ GanttSemanticNormalizationError,
+)
from superset.mcp_service.utils.oauth2_utils import (
build_oauth2_redirect_message,
OAUTH2_CONFIG_ERROR_MESSAGE,
@@ -370,11 +375,11 @@ def _build_replacement_form_data(
new_form_data.pop("_mcp_warnings", None)
dataset_rebind = replacement_dataset_id is not None
if replacement_dataset_id is not None and not isinstance(
- parsed_config, GaugeChartConfig
+ parsed_config, (GanttChartConfig, GaugeChartConfig)
):
# Drop only the inherited state the replacement dataset cannot
- # resolve, then merge as a same-dataset update. Gauge keeps the
- # stricter presentation-only rebind handled downstream.
+ # resolve, then merge as a same-dataset update. Gantt and Gauge keep the
+ # stricter rebind contracts handled downstream.
invalid_keys = _inherited_state_invalid_keys(
existing_form_data,
new_form_data,
@@ -580,6 +585,22 @@ def _validate_update_against_dataset(
}
)
+ try:
+ merged_gantt_config = validate_gantt_form_data(
+ form_data,
+ dataset.id,
+ )
+ except GanttSemanticNormalizationError as ex:
+ return _validation_error_response(
+ message="Gantt chart column roles are invalid",
+ details=str(ex),
+ )
+ if merged_gantt_config is not None:
+ # Validation must describe the state that will actually be queried or
+ # persisted, including any omitted series/subcategory values restored
+ # from the saved chart.
+ parsed_config = merged_gantt_config
+
compile_result = validate_and_compile(
parsed_config, form_data, dataset, run_compile_check=run_compile_check
)
@@ -869,6 +890,11 @@ async def update_chart( # noqa: C901
request = request.model_copy(
update={"add_columns": validation_config.columns}
)
+ except GanttSemanticNormalizationError as ex:
+ return _validation_error_response(
+ message="Gantt chart column roles are invalid",
+ details=str(ex),
+ )
except NORMALIZATION_EXCEPTIONS as e:
logger.warning(
"Column normalization failed for chart %s: %s", chart.id, e
@@ -1071,6 +1097,11 @@ async def update_chart( # noqa: C901
}
return GenerateChartResponse.model_validate(result)
+ except GanttSemanticNormalizationError as ex:
+ return _validation_error_response(
+ message="Gantt chart column roles are invalid",
+ details=str(ex),
+ )
except OAuth2RedirectError as ex:
await ctx.warning(
"Chart update requires OAuth authentication: identifier=%s"
diff --git a/superset/mcp_service/chart/tool/update_chart_preview.py b/superset/mcp_service/chart/tool/update_chart_preview.py
index 1c52d1fdb9f..3b50eb95e4b 100644
--- a/superset/mcp_service/chart/tool/update_chart_preview.py
+++ b/superset/mcp_service/chart/tool/update_chart_preview.py
@@ -41,6 +41,7 @@ from superset.mcp_service.chart.chart_utils import (
merge_chart_form_data,
merge_interactive_pivot_ui_config,
merge_table_column_config,
+ validate_gantt_form_data,
)
from superset.mcp_service.chart.compile import validate_and_compile
from superset.mcp_service.chart.preview_utils import (
@@ -54,6 +55,9 @@ from superset.mcp_service.chart.schemas import (
UpdateChartPreviewRequest,
UpdateChartPreviewResponse,
)
+from superset.mcp_service.chart.validation.dataset_validator import (
+ GanttSemanticNormalizationError,
+)
from superset.mcp_service.utils.oauth2_utils import (
build_oauth2_redirect_message,
OAUTH2_CONFIG_ERROR_MESSAGE,
@@ -222,6 +226,20 @@ def update_chart_preview( # noqa: C901
dataset_rebind=dataset_rebind,
)
+ merged_gantt_config = validate_gantt_form_data(
+ new_form_data,
+ request.dataset_id,
+ dataset_context=(
+ build_dataset_context_from_orm(dataset)
+ if new_form_data.get("viz_type") == "gantt_chart"
+ else None
+ ),
+ )
+ if merged_gantt_config is not None:
+ # Compile the final cached state rather than the pre-merge
+ # request, so preserved native fields cannot bypass semantics.
+ config = merged_gantt_config
+
# Tier-1 schema validation against the dataset (no DB roundtrip).
# Runs AFTER the filter merge so filter columns are also validated.
from superset.daos.dataset import DatasetDAO
@@ -409,6 +427,30 @@ def update_chart_preview( # noqa: C901
"schema_version": "2.0",
"api_version": "v1",
}
+ except GanttSemanticNormalizationError as ex:
+ execution_time = int((time.time() - start_time) * 1000)
+ return {
+ "chart": None,
+ "error": {
+ "error_type": "gantt_semantic_validation_error",
+ "message": "Gantt chart column roles are invalid",
+ "details": str(ex),
+ "suggestions": [
+ "Use different physical columns for start_time and end_time",
+ "Use different physical columns for category and series",
+ "Use exact dataset column casing when names differ only by case",
+ ],
+ "error_code": "GANTT_SEMANTIC_VALIDATION_ERROR",
+ },
+ "performance": {
+ "query_duration_ms": execution_time,
+ "cache_status": "error",
+ "optimization_suggestions": [],
+ },
+ "success": False,
+ "schema_version": "2.0",
+ "api_version": "v1",
+ }
except (
SupersetException,
CommandException,
diff --git a/superset/mcp_service/chart/validation/dataset_validator.py b/superset/mcp_service/chart/validation/dataset_validator.py
index 7747dca4c0d..d7cb7158b56 100644
--- a/superset/mcp_service/chart/validation/dataset_validator.py
+++ b/superset/mcp_service/chart/validation/dataset_validator.py
@@ -46,6 +46,10 @@ _NUMERIC_TYPE_PATTERN = re.compile(
)
+class GanttSemanticNormalizationError(ValueError):
+ """A Gantt canonicalization result violates its typed semantic contract."""
+
+
class AmbiguousDatasetReferenceError(ValueError):
"""A non-exact reference matches multiple names that differ only by case."""
@@ -64,7 +68,12 @@ class AmbiguousDatasetReferenceError(ValueError):
def resolve_dataset_reference(
name: str, candidates: Iterable[str], reference_kind: str
) -> str | None:
- """Resolve exact names first and reject ambiguous case-insensitive matches."""
+ """Resolve a dataset reference deterministically.
+
+ Exact spelling wins regardless of metadata order. A single case-insensitive
+ match is canonicalized, while multiple case-insensitive matches require the
+ caller to provide exact casing.
+ """
candidate_names = list(candidates)
if name in candidate_names:
return name
@@ -336,8 +345,9 @@ class DatasetValidator:
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.
+ # The ref did not opt into saved-metric lookup, so an ambiguous
+ # metric-only spelling is still best explained by the tailored
+ # saved_metric hint below.
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
@@ -375,7 +385,7 @@ class DatasetValidator:
def _build_ambiguous_reference_error(
error: AmbiguousDatasetReferenceError,
) -> ChartGenerationError:
- """Build an actionable error for case-colliding dataset metadata."""
+ """Build an actionable validation error for case-colliding metadata."""
return ChartGenerationError(
error_type="ambiguous_dataset_reference",
message=(
@@ -478,7 +488,7 @@ class DatasetValidator:
@staticmethod
def _column_exists(column_name: str, dataset_context: DatasetContext) -> bool:
- """Check if a physical column or saved metric resolves unambiguously."""
+ """Check if a column or metric resolves without ambiguity."""
for candidates, reference_kind in (
(dataset_context.available_columns, "physical column"),
(dataset_context.available_metrics, "saved metric"),
@@ -501,9 +511,10 @@ class DatasetValidator:
"""
Get the canonical column name from the dataset.
- Performs case-insensitive matching and returns the actual column name
- as stored in the dataset. This ensures column names in form_data match
- exactly with what the frontend expects.
+ Exact spelling wins; a unique case-insensitive match returns the actual
+ physical column name as stored in the dataset. For backward-compatible
+ generic callers, saved metrics are a fallback when no column matches.
+ Ambiguous spellings fail within either namespace.
Args:
column_name: The column name to normalize
diff --git a/superset/mcp_service/chart/validation/pipeline.py b/superset/mcp_service/chart/validation/pipeline.py
index 62badb5e744..c42737fdf67 100644
--- a/superset/mcp_service/chart/validation/pipeline.py
+++ b/superset/mcp_service/chart/validation/pipeline.py
@@ -133,9 +133,30 @@ class ValidationPipeline:
# Runtime validation always returns True now, warnings are informational
# Layer 4: Column name normalization (reuses context)
- normalized_request = ValidationPipeline._normalize_column_names(
- request, dataset_context, typed_config=typed_config
- )
+ from .dataset_validator import GanttSemanticNormalizationError
+
+ try:
+ normalized_request = ValidationPipeline._normalize_column_names(
+ request, dataset_context, typed_config=typed_config
+ )
+ except GanttSemanticNormalizationError as ex:
+ return ValidationResult(
+ is_valid=False,
+ request=request,
+ error=ChartGenerationError(
+ error_type="gantt_semantic_validation_error",
+ message="Gantt chart column roles are invalid",
+ details=str(ex),
+ suggestions=[
+ "Use different physical columns for start_time and "
+ "end_time",
+ "Use different physical columns for category and series",
+ "Use exact dataset column casing when names differ only "
+ "by case",
+ ],
+ error_code="GANTT_SEMANTIC_VALIDATION_ERROR",
+ ),
+ )
return ValidationResult(
is_valid=True,
@@ -250,7 +271,10 @@ class ValidationPipeline:
A new request with normalized column names
"""
try:
- from .dataset_validator import DatasetValidator
+ from .dataset_validator import (
+ DatasetValidator,
+ GanttSemanticNormalizationError,
+ )
config = typed_config or request.config
normalized_config = DatasetValidator.normalize_column_names(
@@ -265,6 +289,8 @@ class ValidationPipeline:
return GenerateChartRequest.model_validate(request_dict)
+ except GanttSemanticNormalizationError:
+ raise
except (ImportError, AttributeError, KeyError, ValueError, TypeError) as e:
# If normalization fails, return the original request
# Validation has already passed, so this is a non-critical failure
diff --git a/tests/unit_tests/mcp_service/chart/test_chart_examples.py b/tests/unit_tests/mcp_service/chart/test_chart_examples.py
new file mode 100644
index 00000000000..a6d5c33125d
--- /dev/null
+++ b/tests/unit_tests/mcp_service/chart/test_chart_examples.py
@@ -0,0 +1,59 @@
+# 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.
+
+"""Ensure published MCP chart examples remain parseable."""
+
+from unittest.mock import Mock, patch
+
+import pytest
+from pydantic import TypeAdapter
+
+from superset.mcp_service.chart.schemas import ChartConfig
+from superset.mcp_service.chart.tool.get_chart_type_schema import _CHART_EXAMPLES
+from superset.utils import json
+
+
+@pytest.mark.parametrize(
+ "example",
+ [example for examples in _CHART_EXAMPLES.values() for example in examples],
+)
+def test_chart_type_schema_example_parses(example: dict[str, object]) -> None:
+ """Validate every schema-tool example against the discriminated union."""
+ TypeAdapter(ChartConfig).validate_python(example)
+
+
+def test_chart_configs_resource_examples_parse() -> None:
+ """Validate published resource examples with authentication isolated."""
+ from superset.mcp_service.chart.resources.chart_configs import (
+ get_chart_configs_resource,
+ )
+
+ with patch(
+ "superset.mcp_service.auth.get_user_from_request",
+ return_value=Mock(id=1, username="admin"),
+ ):
+ resource = json.loads(get_chart_configs_resource())
+ count = 0
+ for examples in resource.values():
+ if not isinstance(examples, dict):
+ continue
+ for example in examples.values():
+ if isinstance(example, dict) and "config" in example:
+ TypeAdapter(ChartConfig).validate_python(example["config"])
+ count += 1
+ assert count > 0
+ assert "project_schedule" in resource["gantt_chart_configs"]
diff --git a/tests/unit_tests/mcp_service/chart/test_chart_helpers.py b/tests/unit_tests/mcp_service/chart/test_chart_helpers.py
index dda6cb12136..42c4ec2e72e 100644
--- a/tests/unit_tests/mcp_service/chart/test_chart_helpers.py
+++ b/tests/unit_tests/mcp_service/chart/test_chart_helpers.py
@@ -17,6 +17,8 @@
from unittest.mock import MagicMock, patch
+import pytest
+
from superset.mcp_service.chart.chart_helpers import (
_deck_gl_null_filters,
_is_metric_ref,
@@ -29,6 +31,7 @@ from superset.mcp_service.chart.chart_helpers import (
merge_extra_form_data_filters_into_query,
merge_form_data_filters_into_query,
prepare_form_data_for_query,
+ resolve_big_number_columns,
resolve_deck_gl_columns,
resolve_metrics,
resolve_metrics_and_groupby,
@@ -1040,3 +1043,285 @@ def test_resolve_metrics_and_groupby_non_singular_viz_type_uses_standard_resolut
)
assert metrics == ["count"]
assert groupby == ["region"]
+
+
+@pytest.mark.parametrize(
+ ("form_data", "expected_columns"),
+ [
+ (
+ {
+ "viz_type": "big_number",
+ "granularity_sqla": "event_time",
+ "metric": "count",
+ },
+ ["event_time"],
+ ),
+ (
+ {
+ "viz_type": "big_number",
+ "x_axis": "recorded_at",
+ "granularity_sqla": "event_time",
+ "metric": "count",
+ },
+ ["recorded_at"],
+ ),
+ (
+ {
+ "viz_type": "big_number",
+ "x_axis": {"column_name": "created_at"},
+ "granularity_sqla": "event_time",
+ "metric": "count",
+ },
+ ["created_at"],
+ ),
+ (
+ {
+ "viz_type": "big_number",
+ "x_axis": {
+ "expressionType": "SQL",
+ "sqlExpression": "DATE_TRUNC('day', event_time)",
+ "label": "event_day",
+ },
+ "granularity_sqla": "event_time",
+ "metric": "count",
+ },
+ [
+ {
+ "expressionType": "SQL",
+ "sqlExpression": "DATE_TRUNC('day', event_time)",
+ "label": "event_day",
+ }
+ ],
+ ),
+ ],
+)
+def test_big_number_trendline_resolves_supported_time_column_aliases(
+ form_data, expected_columns
+):
+ assert resolve_big_number_columns(form_data) == expected_columns
+
+
+def test_big_number_total_does_not_add_temporal_dimension(monkeypatch):
+ monkeypatch.setattr(
+ "superset.mcp_service.chart.chart_helpers.resolve_datasource_engine",
+ lambda datasource_id, datasource_type: "base",
+ )
+ query = build_query_dicts_from_form_data(
+ {
+ "viz_type": "big_number_total",
+ "granularity_sqla": "event_time",
+ "metric": "count",
+ },
+ 1,
+ "table",
+ )[0]
+
+ assert query["columns"] == []
+ assert query["metrics"] == ["count"]
+
+
+def test_big_number_trendline_query_preserves_time_filter_and_aggregation(
+ monkeypatch,
+):
+ monkeypatch.setattr(
+ "superset.mcp_service.chart.chart_helpers.resolve_datasource_engine",
+ lambda datasource_id, datasource_type: "base",
+ )
+ query = build_query_dicts_from_form_data(
+ {
+ "viz_type": "big_number",
+ "granularity_sqla": "event_time",
+ "metric": "count",
+ "aggregation": "raw",
+ "time_range": "Last week",
+ "adhoc_filters": [
+ {
+ "clause": "WHERE",
+ "expressionType": "SIMPLE",
+ "subject": "region",
+ "operator": "==",
+ "comparator": "EMEA",
+ }
+ ],
+ },
+ 1,
+ "table",
+ )[0]
+
+ assert query == {
+ "columns": ["event_time"],
+ "metrics": ["count"],
+ "filters": [{"col": "region", "op": "==", "val": "EMEA"}],
+ "time_range": "Last week",
+ }
+
+
+@pytest.mark.parametrize(
+ ("form_data", "expected_queries"),
+ [
+ (
+ {
+ "viz_type": "echarts_timeseries_line",
+ "x_axis": "event_time",
+ "groupby": ["region"],
+ "metrics": ["count"],
+ },
+ [
+ {
+ "columns": ["event_time", "region"],
+ "metrics": ["count"],
+ "filters": [],
+ }
+ ],
+ ),
+ (
+ {
+ "viz_type": "table",
+ "query_mode": "aggregate",
+ "groupby": ["region"],
+ "columns": ["stale_raw_column"],
+ "metrics": ["count"],
+ },
+ [{"columns": ["region"], "metrics": ["count"], "filters": []}],
+ ),
+ (
+ {"viz_type": "pie", "groupby": ["region"], "metric": "count"},
+ [{"columns": ["region"], "metrics": ["count"], "filters": []}],
+ ),
+ (
+ {
+ "viz_type": "pivot_table_v2",
+ "groupby": ["region", "product"],
+ "metrics": ["count"],
+ },
+ [
+ {
+ "columns": ["region", "product"],
+ "metrics": ["count"],
+ "filters": [],
+ }
+ ],
+ ),
+ (
+ {
+ "viz_type": "mixed_timeseries",
+ "x_axis": "event_time",
+ "groupby": ["region"],
+ "metrics": ["count"],
+ "groupby_b": ["product"],
+ "metrics_b": ["sum_sales"],
+ },
+ [
+ {
+ "columns": ["event_time", "region"],
+ "metrics": ["count"],
+ "filters": [],
+ },
+ {
+ "columns": ["event_time", "product"],
+ "metrics": ["sum_sales"],
+ "filters": [],
+ },
+ ],
+ ),
+ (
+ {
+ "viz_type": "handlebars",
+ "query_mode": "raw",
+ "all_columns": ["region", "product"],
+ },
+ [
+ {
+ "columns": ["region", "product"],
+ "metrics": [],
+ "filters": [],
+ }
+ ],
+ ),
+ (
+ {
+ "viz_type": "bubble",
+ "entity": "country",
+ "x": "sum_sales",
+ "y": "count",
+ "size": "avg_price",
+ },
+ [
+ {
+ "columns": ["country"],
+ "metrics": ["sum_sales", "count", "avg_price"],
+ "filters": [],
+ }
+ ],
+ ),
+ ],
+)
+def test_shared_query_builder_preserves_pre_gantt_chart_contracts(
+ monkeypatch, form_data, expected_queries
+):
+ """Adding the Gantt branch must not alter established chart query shapes."""
+ monkeypatch.setattr(
+ "superset.mcp_service.chart.chart_helpers.resolve_datasource_engine",
+ lambda datasource_id, datasource_type: "base",
+ )
+
+ assert (
+ build_query_dicts_from_form_data(dict(form_data), 1, "table")
+ == expected_queries
+ )
+
+
+def test_shared_query_builder_preserves_explicit_orderby() -> None:
+ """Keep explicit saved ordering through the shared preview/compile builder."""
+ form_data = {
+ "viz_type": "pie",
+ "groupby": ["region"],
+ "metrics": ["count"],
+ "orderby": [["count", True]],
+ "sort_by_metric": True,
+ }
+ with patch(
+ "superset.mcp_service.chart.chart_helpers.resolve_datasource_engine",
+ return_value="base",
+ ):
+ query = build_query_dicts_from_form_data(form_data, 1, "table")[0]
+ assert query["orderby"] == [["count", True]]
+
+
+@pytest.mark.parametrize("secondary_orderby", [None, [["sum_sales", False]]])
+def test_shared_query_builder_keeps_mixed_timeseries_ordering_per_query(
+ secondary_orderby: list[list[str | bool]] | None,
+) -> None:
+ """Primary-only ordering must not leak into the secondary series query."""
+ form_data = {
+ "viz_type": "mixed_timeseries",
+ "x_axis": "event_time",
+ "groupby": ["region"],
+ "metrics": ["count"],
+ "orderby": [["count", True]],
+ "groupby_b": ["product"],
+ "metrics_b": ["sum_sales"],
+ }
+ if secondary_orderby is not None:
+ form_data["orderby_b"] = secondary_orderby
+ with patch(
+ "superset.mcp_service.chart.chart_helpers.resolve_datasource_engine",
+ return_value="base",
+ ):
+ primary, secondary = build_query_dicts_from_form_data(form_data, 1, "table")
+
+ assert primary == {
+ "columns": ["event_time", "region"],
+ "metrics": ["count"],
+ "orderby": [["count", True]],
+ "filters": [],
+ }
+ expected_secondary: dict[str, object] = {
+ "columns": ["event_time", "product"],
+ "metrics": ["sum_sales"],
+ "filters": [],
+ }
+ if secondary_orderby is not None:
+ expected_secondary["orderby"] = secondary_orderby
+ assert secondary == expected_secondary
+ assert form_data["orderby"] == [["count", True]]
diff --git a/tests/unit_tests/mcp_service/chart/test_compile.py b/tests/unit_tests/mcp_service/chart/test_compile.py
index 65440b8353d..16eeeb8a673 100644
--- a/tests/unit_tests/mcp_service/chart/test_compile.py
+++ b/tests/unit_tests/mcp_service/chart/test_compile.py
@@ -28,6 +28,7 @@ from unittest.mock import Mock, patch
import pytest
from superset.mcp_service.chart.compile import (
+ _compile_chart,
CompileResult,
validate_and_compile,
)
@@ -620,6 +621,90 @@ def test_compile_chart_returns_database_error_on_raw_sqlalchemy_error(
assert result.error_obj.error_code == "DATABASE_CONNECTION_ERROR"
+@pytest.mark.parametrize(
+ "query_payload",
+ [
+ {"status": "FAILED", "message": "top-level failure", "queries": []},
+ {"error_message": "top-level failure", "queries": []},
+ {"queries": [{"status": "failed", "message": "boom", "data": []}]},
+ {
+ "queries": [
+ {"status": "success", "data": [{"value": 1}]},
+ {"status": "ERROR", "error_message": "second failed", "data": []},
+ ]
+ },
+ ],
+)
+@patch("superset.commands.chart.data.get_data_command.ChartDataCommand")
+@patch("superset.common.query_context_factory.QueryContextFactory")
+def test_compile_chart_rejects_embedded_query_failures(
+ mock_factory, mock_cmd_cls, query_payload
+):
+ mock_factory.return_value.create.return_value = Mock()
+ mock_cmd_cls.return_value.run.return_value = query_payload
+
+ result = _compile_chart({"viz_type": "table", "columns": ["value"]}, 1)
+
+ assert not result.success
+ assert result.error_code == "CHART_COMPILE_FAILED"
+ assert result.tier == "compile"
+ assert result.error_obj is not None
+ assert result.error_obj.error_type == "compile_error"
+
+
+@patch("superset.commands.chart.data.get_data_command.ChartDataCommand")
+@patch("superset.common.query_context_factory.QueryContextFactory")
+def test_compile_chart_allows_success_message_with_valid_empty_data(
+ mock_factory, mock_cmd_cls
+):
+ mock_factory.return_value.create.return_value = Mock()
+ mock_cmd_cls.return_value.run.return_value = {
+ "status": "success",
+ "message": "served from cache",
+ "queries": [{"status": "SUCCESS", "message": "no rows", "data": []}],
+ }
+
+ result = _compile_chart({"viz_type": "table", "columns": ["value"]}, 1)
+
+ assert result.success
+ assert result.row_count == 0
+
+
+@patch("superset.commands.chart.data.get_data_command.ChartDataCommand")
+@patch("superset.common.query_context_factory.QueryContextFactory")
+def test_compile_chart_big_number_uses_temporal_query_contract(
+ mock_factory, mock_cmd_cls
+):
+ mock_factory.return_value.create.return_value = Mock()
+ mock_cmd_cls.return_value.run.return_value = {"queries": [{"data": []}]}
+ form_data = {
+ "viz_type": "big_number",
+ "granularity_sqla": "event_time",
+ "metric": "count",
+ "aggregation": "LAST_VALUE",
+ "time_range": "Last week",
+ "adhoc_filters": [
+ {
+ "clause": "WHERE",
+ "expressionType": "SIMPLE",
+ "subject": "region",
+ "operator": "==",
+ "comparator": "EMEA",
+ }
+ ],
+ }
+
+ result = _compile_chart(form_data, 1)
+
+ assert result.success
+ query = mock_factory.return_value.create.call_args.kwargs["queries"][0]
+ assert query["columns"] == ["event_time"]
+ assert query["metrics"] == ["count"]
+ assert query["time_range"] == "Last week"
+ assert query["filters"] == [{"col": "region", "op": "==", "val": "EMEA"}]
+ assert query["row_limit"] == 2
+
+
@pytest.mark.parametrize(
"config_factory",
[
diff --git a/tests/unit_tests/mcp_service/chart/test_gantt_chart.py b/tests/unit_tests/mcp_service/chart/test_gantt_chart.py
new file mode 100644
index 00000000000..e71a494c690
--- /dev/null
+++ b/tests/unit_tests/mcp_service/chart/test_gantt_chart.py
@@ -0,0 +1,2812 @@
+# 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.
+
+"""Typed MCP coverage for the existing ECharts Gantt visualization."""
+
+import importlib
+from contextlib import nullcontext
+from pathlib import Path
+from types import SimpleNamespace
+from typing import Any
+from unittest.mock import AsyncMock, Mock, patch
+
+import pytest
+import yaml
+from pydantic import TypeAdapter, ValidationError
+
+from superset.mcp_service.chart.chart_helpers import (
+ build_query_dicts_from_form_data,
+ resolve_gantt_query_fields,
+)
+from superset.mcp_service.chart.chart_utils import (
+ map_gantt_config,
+ merge_chart_form_data,
+ merge_gantt_ui_config,
+ validate_gantt_form_data,
+)
+from superset.mcp_service.chart.preview_utils import (
+ _generate_gantt_vega_lite_preview,
+ generate_preview_from_form_data,
+)
+from superset.mcp_service.chart.query_result import query_result_failure
+from superset.mcp_service.chart.schemas import (
+ ChartConfig,
+ ChartError,
+ GanttChartConfig,
+ GenerateChartRequest,
+ GetChartPreviewRequest,
+ TableChartConfig,
+ TablePreview,
+ UpdateChartPreviewRequest,
+ UpdateChartRequest,
+ VegaLitePreview,
+)
+from superset.mcp_service.chart.tool.get_chart_preview import (
+ VegaLitePreviewStrategy,
+)
+from superset.mcp_service.chart.tool.get_chart_type_schema import (
+ _get_chart_type_schema_impl,
+)
+from superset.mcp_service.chart.tool.update_chart import (
+ _build_preview_form_data,
+ _build_update_payload,
+)
+from superset.mcp_service.chart.validation.dataset_validator import (
+ DatasetValidator,
+ GanttSemanticNormalizationError,
+)
+from superset.mcp_service.chart.validation.pipeline import ValidationPipeline
+from superset.mcp_service.chart.validation.schema_validator import SchemaValidator
+from superset.mcp_service.common.error_schemas import DatasetContext
+
+update_chart_module = importlib.import_module(
+ "superset.mcp_service.chart.tool.update_chart"
+)
+generate_chart_module = importlib.import_module(
+ "superset.mcp_service.chart.tool.generate_chart"
+)
+update_chart_preview_module = importlib.import_module(
+ "superset.mcp_service.chart.tool.update_chart_preview"
+)
+
+
+def _config(**overrides: object) -> GanttChartConfig:
+ values: dict[str, object] = {
+ "chart_type": "gantt",
+ "start_time": {"name": "start_time"},
+ "end_time": {"name": "end_time"},
+ "category": {"name": "task"},
+ }
+ values.update(overrides)
+ return GanttChartConfig.model_validate(values)
+
+
+def _native_example() -> dict[str, object]:
+ document = yaml.safe_load(
+ Path("superset/examples/featured_charts/charts/Gantt.yaml").read_text()
+ )
+ return document["params"]
+
+
+def _dataset_context() -> DatasetContext:
+ return DatasetContext(
+ id=1,
+ table_name="tasks",
+ database_name="main",
+ available_columns=[
+ {"name": "Start_Time", "type": "TIMESTAMP", "is_temporal": True},
+ {"name": "End_Time", "type": "TIMESTAMP", "is_temporal": True},
+ {"name": "Task", "type": "VARCHAR", "is_temporal": False},
+ {"name": "Owner", "type": "VARCHAR", "is_temporal": False},
+ {"name": "Project", "type": "VARCHAR", "is_temporal": False},
+ {"name": "Priority", "type": "INTEGER", "is_temporal": False},
+ ],
+ available_metrics=[
+ {"name": "Completion", "expression": "AVG(progress)"},
+ ],
+ )
+
+
+def _ambiguous_dataset_context() -> DatasetContext:
+ return DatasetContext(
+ id=1,
+ table_name="ambiguous_tasks",
+ database_name="main",
+ available_columns=[
+ {"name": "StartedAt", "type": "VARCHAR", "is_temporal": False},
+ {"name": "startedat", "type": "TIMESTAMP", "is_temporal": True},
+ {"name": "EndedAt", "type": "TIMESTAMP", "is_temporal": True},
+ {"name": "Task", "type": "VARCHAR", "is_temporal": False},
+ ],
+ available_metrics=[],
+ )
+
+
+def test_gantt_discriminator_is_exposed_in_every_request_model() -> None:
+ payload = {
+ "chart_type": "gantt",
+ "start_time": {"name": "start_time"},
+ "end_time": {"name": "end_time"},
+ "category": {"name": "task"},
+ }
+ generated = GenerateChartRequest(dataset_id=1, config=payload)
+ updated = UpdateChartRequest(identifier=7, config=payload)
+ previewed = UpdateChartPreviewRequest(dataset_id=1, config=payload)
+ assert isinstance(generated.config, GanttChartConfig)
+ assert isinstance(updated.config, GanttChartConfig)
+ assert isinstance(previewed.config, GanttChartConfig)
+ union_config = TypeAdapter(ChartConfig).validate_python(payload)
+ assert isinstance(union_config, GanttChartConfig)
+
+
+def test_native_viz_type_alias_and_existing_type_regression() -> None:
+ request = GenerateChartRequest(
+ dataset_id=1,
+ config={
+ "viz_type": "gantt_chart",
+ "start_time": "start_time",
+ "end_time": "end_time",
+ "y_axis": "task",
+ },
+ )
+ assert isinstance(request.config, GanttChartConfig)
+ assert request.config.category.name == "task"
+ table = GenerateChartRequest(
+ dataset_id=1,
+ config={"chart_type": "table", "columns": [{"name": "task"}]},
+ )
+ assert isinstance(table.config, TableChartConfig)
+
+
+def test_schema_and_typo_protection() -> None:
+ schema_result = _get_chart_type_schema_impl("gantt")
+ assert schema_result["chart_type"] == "gantt"
+ schema = schema_result["schema"]
+ assert set(schema["required"]) >= {"start_time", "end_time", "category"}
+ assert schema["properties"]["chart_type"]["const"] == "gantt"
+ assert schema_result["examples"][0]["chart_type"] == "gantt"
+
+ with pytest.raises(ValidationError, match="did you mean 'start_time'"):
+ _config(start_tiem={"name": "wrong"})
+
+
+def test_role_and_cross_field_validation_is_conservative() -> None:
+ with pytest.raises(ValidationError, match="start_time.*dimension"):
+ _config(start_time={"name": "start_time", "aggregate": "MIN"})
+ with pytest.raises(ValidationError, match=r"tooltip_metrics\[0\]"):
+ _config(tooltip_metrics=[{"name": "progress"}])
+ with pytest.raises(ValidationError, match="requires series"):
+ _config(subcategories=True)
+ with pytest.raises(ValidationError, match="different columns"):
+ _config(end_time={"name": "start_time"})
+
+ # These roles resolve labels case-insensitively in dataset canonicalization.
+ with pytest.raises(ValidationError, match="start_time and end_time"):
+ _config(end_time={"name": "START_TIME"})
+ with pytest.raises(ValidationError, match="start_time and category"):
+ _config(category={"name": "START_TIME"})
+ with pytest.raises(ValidationError, match="end_time and category"):
+ _config(category={"name": "END_TIME"})
+ with pytest.raises(ValidationError, match="series and category"):
+ _config(series={"name": "task"})
+ with pytest.raises(ValidationError, match="series and category"):
+ _config(series={"name": "TASK"}, subcategories=True)
+
+ with pytest.raises(ValidationError, match=r"tooltip_columns\[0\].*dimension"):
+ _config(tooltip_columns=[{"name": "Task", "aggregate": "MAX"}])
+ with pytest.raises(ValidationError, match=r"tooltip_metrics\[0\]"):
+ _config(tooltip_metrics=[{"name": "TASK"}])
+
+ # The frontend permits a physical column in a tooltip as either a repeated
+ # dimension or an aggregated metric; only the role shape must stay distinct.
+ config = _config(
+ tooltip_columns=[{"name": "TASK"}],
+ tooltip_metrics=[{"name": "task", "aggregate": "COUNT"}],
+ )
+ assert config.tooltip_columns[0].name == "TASK"
+ assert config.tooltip_metrics[0].aggregate == "COUNT"
+
+
+@pytest.mark.parametrize("request_model", [GenerateChartRequest, UpdateChartRequest])
+@pytest.mark.parametrize("generate_preview", [False, True])
+def test_product_request_models_reject_case_insensitive_required_role_collisions(
+ request_model: type[GenerateChartRequest] | type[UpdateChartRequest],
+ generate_preview: bool,
+) -> None:
+ config = {
+ "chart_type": "gantt",
+ "start_time": {"name": "StartedAt"},
+ "end_time": {"name": "EndedAt"},
+ "category": {"name": "STARTEDAT"},
+ }
+ kwargs: dict[str, object] = {
+ "config": config,
+ "generate_preview": generate_preview,
+ }
+ if request_model is GenerateChartRequest:
+ kwargs["dataset_id"] = 1
+ else:
+ kwargs["identifier"] = 1495
+
+ with pytest.raises(ValidationError, match="start_time and category"):
+ request_model(**kwargs)
+
+
+@pytest.mark.parametrize("generate_preview", [False, True])
+def test_update_preview_request_rejects_case_insensitive_role_collisions(
+ generate_preview: bool,
+) -> None:
+ with pytest.raises(ValidationError, match="end_time and category"):
+ UpdateChartPreviewRequest(
+ dataset_id=1,
+ generate_preview=generate_preview,
+ config={
+ "chart_type": "gantt",
+ "start_time": {"name": "StartedAt"},
+ "end_time": {"name": "EndedAt"},
+ "category": {"name": "ENDEDAT"},
+ },
+ )
+
+
+def test_preview_required_role_uniqueness_is_case_insensitive() -> None:
+ result = _generate_gantt_vega_lite_preview(
+ [],
+ {
+ "viz_type": "gantt_chart",
+ "start_time": "StartedAt",
+ "end_time": "EndedAt",
+ "y_axis": "STARTEDAT",
+ },
+ )
+ assert isinstance(result, ChartError)
+ assert result.error_type == "InvalidGanttFormData"
+ assert "distinct" in result.error
+
+
+def test_native_input_rejects_malformed_filters_order_and_bounds() -> None:
+ with pytest.raises(ValidationError, match="free-form SQL"):
+ GanttChartConfig.model_validate(
+ {
+ **_config().model_dump(exclude={"filters"}),
+ "adhoc_filters": [
+ {"expressionType": "SQL", "sqlExpression": "task IS NOT NULL"}
+ ],
+ }
+ )
+ with pytest.raises(ValidationError, match="not valid JSON"):
+ _config(order_by_cols=["not-json"])
+ with pytest.raises(ValidationError, match="HH:MM:SS"):
+ _config(x_axis_time_bounds=["8am", None])
+ with pytest.raises(ValidationError, match="Unrecognized time_range"):
+ _config(time_range="whenever")
+
+
+def test_real_saved_example_adapts_and_round_trips_presentation() -> None:
+ config = GanttChartConfig.model_validate(_native_example())
+ assert isinstance(
+ GenerateChartRequest(dataset_id=61, config=_native_example()).config,
+ GanttChartConfig,
+ )
+ assert isinstance(
+ UpdateChartRequest(identifier=1495, config=_native_example()).config,
+ GanttChartConfig,
+ )
+ assert isinstance(
+ UpdateChartPreviewRequest(dataset_id=61, config=_native_example()).config,
+ GanttChartConfig,
+ )
+ assert config.temporal_column == "start_time"
+ assert config.category.name == "status"
+ assert config.series is not None
+ assert config.series.name == "priority"
+ assert config.order_by[0].column == "status"
+ assert config.order_by[0].ascending is False
+
+ form_data = map_gantt_config(config)
+ assert form_data["viz_type"] == "gantt_chart"
+ assert form_data["start_time"] == "start_time"
+ assert form_data["end_time"] == "end_time"
+ assert form_data["y_axis"] == "status"
+ assert form_data["series"] == "priority"
+ assert form_data["tooltip_columns"] == ["project", "phase"]
+ assert form_data["order_by_cols"] == ['["status", false]']
+ assert form_data["legendOrientation"] == "right"
+ assert form_data["legendMargin"] == 100
+ assert form_data["x_axis_time_bounds"] == ["08:00:00", "19:00:00"]
+ assert form_data["tooltipTimeFormat"] == "smart_date"
+ assert form_data["adhoc_filters"] == [
+ {
+ "clause": "WHERE",
+ "expressionType": "SIMPLE",
+ "subject": "start_time",
+ "operator": "TEMPORAL_RANGE",
+ "comparator": "No filter",
+ }
+ ]
+
+
+def test_mapper_output_round_trips_saved_adhoc_and_sql_tooltip_metrics() -> None:
+ original = map_gantt_config(
+ _config(
+ tooltip_metrics=[
+ {"name": "Completion", "saved_metric": True},
+ {"name": "cost", "aggregate": "SUM"},
+ {"name": "hours", "aggregate": "AVG", "label": "Avg hours"},
+ {"sql_expression": "SUM(cost) / COUNT(*)", "label": "Unit cost"},
+ ],
+ time_range="Last 30 days",
+ )
+ )
+
+ adapted = GanttChartConfig.model_validate(original)
+ assert adapted.tooltip_metrics[0].saved_metric is True
+ assert adapted.tooltip_metrics[1].aggregate == "SUM"
+ assert adapted.tooltip_metrics[1].label is None
+ assert adapted.tooltip_metrics[2].label == "Avg hours"
+ assert adapted.tooltip_metrics[3].sql_expression == "SUM(cost) / COUNT(*)"
+ assert map_gantt_config(adapted) == original
+
+ cached = dict(original)
+ cached_metric = dict(original["tooltip_metrics"][1])
+ cached_metric["column"] = {
+ "advanced_data_type": None,
+ "certification_details": None,
+ "certified_by": None,
+ "column_name": "cost",
+ "description": "Task cost",
+ "expression": None,
+ "filterable": True,
+ "groupby": True,
+ "id": 734,
+ "is_certified": False,
+ "is_dttm": False,
+ "python_date_format": None,
+ "type": "DOUBLE PRECISION",
+ "type_generic": 0,
+ "verbose_name": None,
+ "warning_markdown": None,
+ }
+ cached["tooltip_metrics"] = [cached_metric]
+ normalized_cached = map_gantt_config(GanttChartConfig.model_validate(cached))
+ assert normalized_cached["tooltip_metrics"] == [original["tooltip_metrics"][1]]
+ GanttChartConfig.model_validate(normalized_cached)
+
+ malformed = dict(original)
+ malformed_metric = dict(original["tooltip_metrics"][1])
+ malformed_metric["expressonType"] = malformed_metric.pop("expressionType")
+ malformed["tooltip_metrics"] = [malformed_metric]
+ with pytest.raises(ValidationError, match="expressonType"):
+ GanttChartConfig.model_validate(malformed)
+
+
+def test_native_adapter_accepts_bounded_standard_explore_metadata() -> None:
+ native = map_gantt_config(_config())
+ native["adhoc_filters"] = [
+ {
+ "clause": "WHERE",
+ "comparator": "Build",
+ "datasourceWarning": False,
+ "expressionType": "SIMPLE",
+ "filterOptionName": "filter_123",
+ "isExtra": False,
+ "isNew": False,
+ "operator": "==",
+ "operatorId": "EQUALS",
+ "sqlExpression": None,
+ "subject": "task",
+ },
+ {
+ "clause": "WHERE",
+ "comparator": "Last 30 days",
+ "datasourceWarning": False,
+ "expressionType": "SIMPLE",
+ "filterOptionName": "filter_456",
+ "isExtra": True,
+ "isNew": False,
+ "operator": "TEMPORAL_RANGE",
+ "operatorId": "TEMPORAL_RANGE",
+ "sqlExpression": None,
+ "subject": "start_time",
+ },
+ ]
+ native["tooltip_metrics"] = [
+ {
+ "aggregate": "SUM",
+ "column": {
+ "advanced_data_type": None,
+ "certification_details": None,
+ "certified_by": "Data team",
+ "column_name": "cost",
+ "database_expression": None,
+ "description": "Task cost",
+ "expression": None,
+ "filterBy": "cost",
+ "filterable": True,
+ "groupby": False,
+ "id": 734,
+ "is_certified": True,
+ "is_dttm": False,
+ "optionName": "cost",
+ "python_date_format": None,
+ "type": "DOUBLE PRECISION",
+ "type_generic": 0,
+ "uuid": None,
+ "value": "cost",
+ "verbose_name": None,
+ "warning_markdown": "Certified source",
+ },
+ "datasourceWarning": False,
+ "expressionType": "SIMPLE",
+ "hasCustomLabel": False,
+ "label": "SUM(cost)",
+ "optionName": "metric_cost",
+ "sqlExpression": None,
+ }
+ ]
+
+ request = UpdateChartRequest(identifier=1495, config=native)
+ assert isinstance(request.config, GanttChartConfig)
+ adapted = request.config
+
+ assert adapted.filters is not None
+ assert adapted.filters[0].column == "task"
+ assert adapted.time_range == "Last 30 days"
+ assert adapted.tooltip_metrics[0].name == "cost"
+ remapped = map_gantt_config(adapted)
+ assert remapped["tooltip_metrics"] == [
+ {
+ "aggregate": "SUM",
+ "column": {"column_name": "cost"},
+ "datasourceWarning": False,
+ "expressionType": "SIMPLE",
+ "hasCustomLabel": False,
+ "label": "SUM(cost)",
+ "optionName": "metric_cost",
+ "sqlExpression": None,
+ }
+ ]
+
+
+@pytest.mark.parametrize(
+ ("operator_id", "operator", "comparator", "expected_op"),
+ [
+ ("EQUALS", "==", "Build", "="),
+ ("NOT_EQUALS", "!=", "Build", "!="),
+ ("LESS_THAN", "<", 5, "<"),
+ ("LESS_THAN_OR_EQUAL", "<=", 5, "<="),
+ ("GREATER_THAN", ">", 5, ">"),
+ ("GREATER_THAN_OR_EQUAL", ">=", 5, ">="),
+ ("IN", "IN", ["Build", "Review"], "IN"),
+ ("NOT_IN", "NOT IN", ["Deferred"], "NOT IN"),
+ ("LIKE", "LIKE", "Build%", "LIKE"),
+ ("ILIKE", "ILIKE", "build%", "ILIKE"),
+ ("IS_NULL", "IS NULL", None, "IS NULL"),
+ ("IS_NOT_NULL", "IS NOT NULL", None, "IS NOT NULL"),
+ ],
+)
+def test_native_adapter_maps_supported_frontend_filter_operators(
+ operator_id: str,
+ operator: str,
+ comparator: object,
+ expected_op: str,
+) -> None:
+ native = map_gantt_config(_config())
+ native.pop("_mcp_dashboard_time_filter_subject")
+ native["adhoc_filters"] = [
+ {
+ "clause": "WHERE",
+ "comparator": comparator,
+ "expressionType": "SIMPLE",
+ "operator": operator,
+ "operatorId": operator_id,
+ "sqlExpression": None,
+ "subject": "task",
+ }
+ ]
+
+ request = UpdateChartRequest(identifier=1495, config=native)
+
+ assert isinstance(request.config, GanttChartConfig)
+ assert request.config.filters is not None
+ assert request.config.filters[0].op == expected_op
+ assert request.config.filters[0].value == comparator
+
+
+def test_native_adapter_maps_valid_temporal_operator_id() -> None:
+ native = map_gantt_config(_config())
+ native["adhoc_filters"] = [
+ {
+ "clause": "WHERE",
+ "comparator": "Last 30 days",
+ "expressionType": "SIMPLE",
+ "operator": "TEMPORAL_RANGE",
+ "operatorId": "TEMPORAL_RANGE",
+ "sqlExpression": None,
+ "subject": "start_time",
+ }
+ ]
+
+ request = UpdateChartRequest(identifier=1495, config=native)
+
+ assert isinstance(request.config, GanttChartConfig)
+ assert request.config.filters is None
+ assert request.config.temporal_column == "start_time"
+ assert request.config.time_range == "Last 30 days"
+
+
+@pytest.mark.parametrize(
+ ("operator_id", "operator", "comparator", "message"),
+ [
+ ("EQAULS", "==", "Build", "not a recognized Explore operator ID"),
+ ("EQUALS", "!=", "Build", "contradictory operator"),
+ ("IS_NULL", "==", "Build", "contradictory operator"),
+ ("IS_NULL", "IS NULL", "Build", "must not define comparator"),
+ ("LATEST_PARTITION", "LATEST PARTITION", None, "not supported"),
+ ("IN", "IN", "Build", "non-empty comparator array"),
+ ],
+)
+def test_native_adapter_rejects_malformed_or_unsupported_operator_shapes(
+ operator_id: str,
+ operator: str,
+ comparator: object,
+ message: str,
+) -> None:
+ native = map_gantt_config(_config())
+ native.pop("_mcp_dashboard_time_filter_subject")
+ native["adhoc_filters"] = [
+ {
+ "clause": "WHERE",
+ "comparator": comparator,
+ "expressionType": "SIMPLE",
+ "operator": operator,
+ "operatorId": operator_id,
+ "sqlExpression": None,
+ "subject": "task",
+ }
+ ]
+
+ with pytest.raises(ValidationError, match=message):
+ UpdateChartRequest(identifier=1495, config=native)
+
+
+@pytest.mark.parametrize(
+ ("field", "value", "message"),
+ [
+ ("operator", {}, "operator must be a non-empty string"),
+ ("operator", [], "operator must be a non-empty string"),
+ ("operator", 7, "operator must be a non-empty string"),
+ ("operator", None, "operator must be a non-empty string"),
+ ("operatorId", {}, "operatorId must be a non-empty string"),
+ ("operatorId", [], "operatorId must be a non-empty string"),
+ ("operatorId", 7, "operatorId must be a non-empty string"),
+ ("comparator", {}, "requires a scalar comparator"),
+ ("comparator", [], "requires a scalar comparator"),
+ ("comparator", None, "requires a scalar comparator"),
+ ],
+)
+def test_native_filter_unhashable_values_are_structured_validation_errors(
+ field: str, value: object, message: str
+) -> None:
+ native = map_gantt_config(_config())
+ native["chart_type"] = "gantt"
+ native.pop("_mcp_dashboard_time_filter_subject")
+ native_filter: dict[str, object] = {
+ "clause": "WHERE",
+ "comparator": "Build",
+ "expressionType": "SIMPLE",
+ "operator": "==",
+ "operatorId": "EQUALS",
+ "sqlExpression": None,
+ "subject": "task",
+ }
+ native_filter[field] = value
+ native["adhoc_filters"] = [native_filter]
+
+ valid, _request, error = SchemaValidator.validate_request(
+ {"dataset_id": 1, "config": native}
+ )
+
+ assert valid is False
+ assert error is not None
+ assert error.error_type != "validation_system_error"
+ assert message in f"{error.message} {error.details}"
+
+
+@pytest.mark.parametrize(
+ ("field", "value"),
+ [
+ ("advanced_data_type", []),
+ ("certification_details", {}),
+ ("certified_by", 3),
+ ("description", []),
+ ("filterable", "true"),
+ ("groupby", "definitely-not-bool"),
+ ("id", True),
+ ("is_certified", None),
+ ("is_dttm", 0),
+ ("python_date_format", False),
+ ("type", 3),
+ ("type_generic", "0"),
+ ("type_generic", 5),
+ ("uuid", 123),
+ ("verbose_name", []),
+ ("warning_markdown", {}),
+ ],
+)
+def test_update_request_rejects_wrongly_typed_native_column_metadata(
+ field: str, value: object
+) -> None:
+ native = map_gantt_config(_config())
+ native["tooltip_metrics"] = [
+ {
+ "aggregate": "SUM",
+ "column": {"column_name": "cost", field: value},
+ "expressionType": "SIMPLE",
+ "label": "SUM(cost)",
+ "sqlExpression": None,
+ }
+ ]
+
+ with pytest.raises(ValidationError, match=field):
+ UpdateChartRequest(identifier=1495, config=native)
+
+
+def test_native_adapter_uses_frontend_fallback_for_unlabeled_sql_metric() -> None:
+ native = map_gantt_config(_config())
+ native["tooltip_metrics"] = [
+ {
+ "aggregate": None,
+ "column": None,
+ "datasourceWarning": False,
+ "expressionType": "SQL",
+ "hasCustomLabel": False,
+ "isNew": False,
+ "label": None,
+ "optionName": "metric_sql_123",
+ "sqlExpression": "SUM(cost) / COUNT(*)",
+ }
+ ]
+
+ adapted = GanttChartConfig.model_validate(native)
+ metric = adapted.tooltip_metrics[0]
+
+ assert metric.sql_expression == "SUM(cost) / COUNT(*)"
+ assert metric.label == "SUM(cost) / COUNT(*)"
+ assert map_gantt_config(adapted)["tooltip_metrics"][0]["label"] == metric.label
+
+
+@pytest.mark.parametrize(
+ ("container", "field", "value", "message"),
+ [
+ ("filter", "filterOptionName", 123, "filterOptionName"),
+ ("filter", "isExtra", "false", "isExtra"),
+ ("filter", "sqlExpression", "task = 'x'", "must be null"),
+ ("filter", "filterOptionNmae", "typo", "filterOptionNmae"),
+ ("metric", "isNew", "false", "isNew"),
+ ("metric", "isNwe", False, "isNwe"),
+ ],
+)
+def test_native_adapter_rejects_malformed_or_unknown_explore_metadata(
+ container: str, field: str, value: object, message: str
+) -> None:
+ native = map_gantt_config(_config())
+ if container == "filter":
+ target = native["adhoc_filters"][0]
+ else:
+ native["tooltip_metrics"] = [
+ {
+ "aggregate": "SUM",
+ "column": {"column_name": "cost"},
+ "expressionType": "SIMPLE",
+ "label": "SUM(cost)",
+ "sqlExpression": None,
+ }
+ ]
+ target = native["tooltip_metrics"][0]
+ target[field] = value
+
+ with pytest.raises(ValidationError, match=message):
+ GanttChartConfig.model_validate(native)
+
+
+def test_native_temporal_filters_reject_multiple_or_conflicting_bindings() -> None:
+ base = map_gantt_config(_config())
+ second = {
+ "clause": "WHERE",
+ "expressionType": "SIMPLE",
+ "subject": "end_time",
+ "operator": "TEMPORAL_RANGE",
+ "comparator": "No filter",
+ }
+ for filters in (
+ [*base["adhoc_filters"], second],
+ [*base["adhoc_filters"], dict(base["adhoc_filters"][0])],
+ ):
+ with pytest.raises(ValidationError, match="at most one TEMPORAL_RANGE"):
+ GanttChartConfig.model_validate({**base, "adhoc_filters": filters})
+
+ with pytest.raises(
+ ValidationError, match="_mcp_dashboard_time_filter_subject conflicts"
+ ):
+ GanttChartConfig.model_validate(
+ {**base, "_mcp_dashboard_time_filter_subject": "end_time"}
+ )
+ with pytest.raises(ValidationError, match="time_range conflicts"):
+ GanttChartConfig.model_validate(
+ {
+ **base,
+ "time_range": "Last 7 days",
+ "adhoc_filters": [
+ {**base["adhoc_filters"][0], "comparator": "Last 30 days"}
+ ],
+ }
+ )
+
+ one_filter = GanttChartConfig.model_validate(base)
+ assert map_gantt_config(one_filter) == base
+
+
+def test_typed_mapping_matches_frontend_build_query_contract() -> None:
+ form_data = map_gantt_config(
+ _config(
+ series={"name": "owner"},
+ subcategories=True,
+ tooltip_columns=[{"name": "project"}],
+ tooltip_metrics=[{"name": "Completion", "saved_metric": True}],
+ order_by=[
+ {"column": "start_time", "ascending": True},
+ {"column": "priority", "ascending": False},
+ ],
+ filters=[{"column": "owner", "op": "=", "value": "Amin"}],
+ time_range="Last 30 days",
+ color_scheme="supersetColors",
+ )
+ )
+ assert form_data["tooltip_metrics"] == ["Completion"]
+ assert form_data["adhoc_filters"][0] == {
+ "clause": "WHERE",
+ "expressionType": "SIMPLE",
+ "subject": "owner",
+ "operator": "==",
+ "comparator": "Amin",
+ }
+ assert form_data["time_range"] == "Last 30 days"
+ assert form_data["subcategories"] is True
+ temporal_filter = next(
+ filter_
+ for filter_ in form_data["adhoc_filters"]
+ if filter_["operator"] == "TEMPORAL_RANGE"
+ )
+ assert temporal_filter["subject"] == "start_time"
+ assert temporal_filter["comparator"] == "Last 30 days"
+
+
+def test_query_fields_and_query_context_shape_match_frontend() -> None:
+ form_data = {
+ "viz_type": "gantt_chart",
+ "start_time": "start_time",
+ "end_time": "end_time",
+ "y_axis": "task",
+ "series": "owner",
+ "tooltip_columns": ["project"],
+ "tooltip_metrics": ["Completion"],
+ "order_by_cols": [
+ '["start_time", true]',
+ '["priority", false]',
+ ],
+ "row_limit": 321,
+ }
+ columns, metrics, orderby, series_columns = resolve_gantt_query_fields(form_data)
+ assert columns == [
+ "start_time",
+ "end_time",
+ "task",
+ "owner",
+ "project",
+ "priority",
+ ]
+ assert metrics == ["Completion"]
+ assert orderby == [["start_time", True], ["priority", False]]
+ assert series_columns == ["owner"]
+
+ with patch(
+ "superset.mcp_service.chart.chart_helpers.resolve_datasource_engine",
+ return_value="base",
+ ):
+ query = build_query_dicts_from_form_data(form_data, 1, "table")[0]
+ assert query["columns"] == columns
+ assert query["metrics"] == metrics
+ assert query["orderby"] == orderby
+ assert query["series_columns"] == series_columns
+ assert query["row_limit"] == 321
+
+
+def test_saved_query_fallback_rejects_malformed_native_order() -> None:
+ form_data = {
+ "viz_type": "gantt_chart",
+ "start_time": "start_time",
+ "end_time": "end_time",
+ "y_axis": "task",
+ "order_by_cols": [["start_time", "yes"]],
+ }
+ with pytest.raises(ValueError, match="ascending_boolean"):
+ resolve_gantt_query_fields(form_data)
+
+
+def test_dataset_validation_and_case_normalization() -> None:
+ config = _config(
+ series={"name": "owner"},
+ tooltip_columns=[{"name": "project"}],
+ tooltip_metrics=[{"name": "completion", "saved_metric": True}],
+ order_by=[{"column": "priority", "ascending": False}],
+ )
+ valid, error = DatasetValidator.validate_against_dataset(
+ config, 1, dataset_context=_dataset_context()
+ )
+ assert valid is True
+ assert error is None
+
+ normalized = DatasetValidator.normalize_column_names(
+ config, 1, dataset_context=_dataset_context()
+ )
+ assert normalized.start_time.name == "Start_Time"
+ assert normalized.end_time.name == "End_Time"
+ assert normalized.category.name == "Task"
+ assert normalized.series is not None
+ assert normalized.series.name == "Owner"
+ assert normalized.tooltip_metrics[0].name == "Completion"
+ assert normalized.order_by[0].column == "Priority"
+
+ missing = _config(category={"name": "tasx"})
+ valid, error = DatasetValidator.validate_against_dataset(
+ missing, 1, dataset_context=_dataset_context()
+ )
+ assert valid is False
+ assert error is not None
+ assert any("Task" in suggestion for suggestion in error.suggestions)
+
+
+def test_gantt_normalization_rejects_ambiguous_case_insensitive_dataset_names() -> None:
+ config = _config(
+ start_time={"name": "STARTEDAT"},
+ end_time={"name": "endedat"},
+ category={"name": "task"},
+ )
+
+ with pytest.raises(
+ GanttSemanticNormalizationError,
+ match="ambiguous.*StartedAt.*startedat.*exact physical column name",
+ ):
+ DatasetValidator.normalize_column_names(
+ config, 1, dataset_context=_ambiguous_dataset_context()
+ )
+
+
+@pytest.mark.parametrize("reverse_metadata", [False, True])
+def test_exact_case_wins_for_every_gantt_reference_and_temporal_check(
+ reverse_metadata: bool,
+) -> None:
+ """Metadata order cannot redirect exact refs to a case-colliding column."""
+ context = _ambiguous_dataset_context()
+ if reverse_metadata:
+ context.available_columns[0:2] = reversed(context.available_columns[0:2])
+ context.available_metrics = [
+ {"name": "Budget", "expression": "SUM(cost)"},
+ {"name": "budget", "expression": "AVG(cost)"},
+ ]
+ config = _config(
+ start_time={"name": "startedat"},
+ end_time={"name": "EndedAt"},
+ category={"name": "Task"},
+ series={"name": "StartedAt"},
+ tooltip_columns=[{"name": "startedat"}],
+ tooltip_metrics=[{"name": "budget", "saved_metric": True}],
+ temporal_column="startedat",
+ filters=[{"column": "startedat", "op": ">=", "value": "2026-01-01"}],
+ order_by=[{"column": "startedat", "ascending": True}],
+ )
+
+ valid, error = DatasetValidator.validate_against_dataset(
+ config, 1, dataset_context=context
+ )
+ assert valid is True
+ assert error is None
+ normalized = DatasetValidator.normalize_column_names(
+ config, 1, dataset_context=context
+ )
+ assert normalized.start_time.name == "startedat"
+ assert normalized.series is not None
+ assert normalized.series.name == "StartedAt"
+ assert normalized.tooltip_columns[0].name == "startedat"
+ assert normalized.tooltip_metrics[0].name == "budget"
+ assert normalized.temporal_column == "startedat"
+ assert normalized.filters is not None
+ assert normalized.filters[0].column == "startedat"
+ assert normalized.order_by[0].column == "startedat"
+
+ plugin = __import__("superset.mcp_service.chart.registry", fromlist=["get"]).get(
+ "gantt"
+ )
+ assert plugin is not None
+ with patch.object(DatasetValidator, "_get_dataset_context", return_value=context):
+ assert plugin.post_map_validate(normalized, {}, dataset_id=1) is None
+ wrong_case_error = plugin.post_map_validate(
+ _config(start_time={"name": "StartedAt"}), {}, dataset_id=1
+ )
+ assert wrong_case_error is not None
+ assert wrong_case_error.error_code == "NON_TEMPORAL_GANTT_TIME_COLUMN"
+
+
+@pytest.mark.parametrize("reverse_metadata", [False, True])
+def test_ambiguous_nonexact_reference_fails_validation_actionably(
+ reverse_metadata: bool,
+) -> None:
+ context = _ambiguous_dataset_context()
+ if reverse_metadata:
+ context.available_columns[0:2] = reversed(context.available_columns[0:2])
+ config = _config(start_time={"name": "STARTEDAT"})
+
+ valid, error = DatasetValidator.validate_against_dataset(
+ config, 1, dataset_context=context
+ )
+
+ assert valid is False
+ assert error is not None
+ assert error.error_code == "AMBIGUOUS_DATASET_REFERENCE"
+ assert "StartedAt" in error.details
+ assert "startedat" in error.details
+ assert "exact physical column name" in error.details
+
+
+def test_validation_pipeline_fails_closed_on_ambiguous_gantt_reference() -> None:
+ request_data = {
+ "dataset_id": 1,
+ "config": {
+ "chart_type": "gantt",
+ "start_time": {"name": "STARTEDAT"},
+ "end_time": {"name": "endedat"},
+ "category": {"name": "task"},
+ },
+ }
+ with (
+ patch.object(
+ ValidationPipeline,
+ "_get_dataset_context",
+ return_value=_ambiguous_dataset_context(),
+ ),
+ patch.object(
+ ValidationPipeline, "_validate_runtime", return_value=(True, None)
+ ),
+ ):
+ result = ValidationPipeline.validate_request_with_warnings(request_data)
+
+ assert result.is_valid is False
+ assert result.request is not None
+ assert result.error is not None
+ assert result.error.error_type == "ambiguous_dataset_reference"
+ assert result.error.error_code == "AMBIGUOUS_DATASET_REFERENCE"
+ assert "ambiguous" in result.error.details
+ assert result.error.suggestions
+
+
+def test_nonsemantic_normalization_error_remains_a_warning_for_other_charts(
+ caplog: pytest.LogCaptureFixture,
+) -> None:
+ request = GenerateChartRequest(
+ dataset_id=1,
+ config={"chart_type": "table", "columns": [{"name": "Task"}]},
+ )
+ with patch.object(
+ DatasetValidator,
+ "normalize_column_names",
+ side_effect=ValueError("temporary canonicalization issue"),
+ ):
+ normalized = ValidationPipeline._normalize_column_names(request)
+
+ assert normalized is request
+ assert "Column name normalization failed" in caplog.text
+
+
+@pytest.mark.asyncio
+async def test_generate_chart_maps_ambiguous_gantt_reference_to_error() -> None:
+ request = GenerateChartRequest(
+ dataset_id=1,
+ config={
+ "chart_type": "gantt",
+ "start_time": {"name": "STARTEDAT"},
+ "end_time": {"name": "endedat"},
+ "category": {"name": "task"},
+ },
+ preview_formats=[],
+ )
+ ctx = Mock(
+ info=AsyncMock(),
+ debug=AsyncMock(),
+ warning=AsyncMock(),
+ error=AsyncMock(),
+ report_progress=AsyncMock(),
+ )
+ with (
+ patch(
+ "superset.mcp_service.auth.get_user_from_request",
+ return_value=Mock(id=1),
+ ),
+ patch.object(
+ ValidationPipeline,
+ "_get_dataset_context",
+ return_value=_ambiguous_dataset_context(),
+ ),
+ patch.object(
+ ValidationPipeline, "_validate_runtime", return_value=(True, None)
+ ),
+ patch.object(generate_chart_module, "map_config_to_form_data") as mapper,
+ ):
+ result = await generate_chart_module.generate_chart(request=request, ctx=ctx)
+
+ assert result.success is False
+ assert result.error is not None
+ assert result.error.error_type == "ambiguous_dataset_reference"
+ assert "ambiguous" in result.error.details
+ mapper.assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test_update_chart_maps_gantt_semantic_normalization_to_error() -> None:
+ request = UpdateChartRequest(
+ identifier=1495,
+ config={
+ "chart_type": "gantt",
+ "start_time": {"name": "STARTEDAT"},
+ "end_time": {"name": "endedat"},
+ "category": {"name": "task"},
+ },
+ generate_preview=False,
+ preview_formats=[],
+ )
+ chart = SimpleNamespace(
+ id=1495,
+ datasource_id=1,
+ datasource=object(),
+ params=__import__("json").dumps(_native_example()),
+ slice_name="Gantt",
+ uuid="chart-uuid",
+ viz_type="gantt_chart",
+ )
+ with (
+ patch(
+ "superset.mcp_service.auth.get_user_from_request",
+ return_value=Mock(id=1),
+ ),
+ patch.object(
+ update_chart_module, "find_chart_by_identifier", return_value=chart
+ ),
+ patch(
+ "superset.mcp_service.auth.check_chart_data_access",
+ return_value=SimpleNamespace(is_valid=True, error=None),
+ ),
+ patch.object(
+ DatasetValidator,
+ "_get_dataset_context",
+ return_value=_ambiguous_dataset_context(),
+ ),
+ patch("superset.commands.chart.update.UpdateChartCommand") as command,
+ ):
+ result = await update_chart_module.update_chart(request=request, ctx=Mock())
+
+ assert result.success is False
+ assert result.error is not None
+ assert result.error.error_type == "ValidationError"
+ assert "ambiguous" in result.error.details
+ command.assert_not_called()
+
+
+def test_update_chart_preview_maps_gantt_semantic_normalization_to_error() -> None:
+ context = _ambiguous_dataset_context()
+ columns = [
+ SimpleNamespace(
+ column_name=column["name"],
+ type=column["type"],
+ is_temporal=column["is_temporal"],
+ is_numeric=False,
+ )
+ for column in context.available_columns
+ ]
+ dataset = SimpleNamespace(
+ id=1,
+ table_name=context.table_name,
+ schema=None,
+ columns=columns,
+ metrics=[],
+ database=SimpleNamespace(database_name="main", db_engine_spec=None),
+ )
+ request = UpdateChartPreviewRequest(
+ dataset_id=1,
+ config={
+ "chart_type": "gantt",
+ "start_time": {"name": "STARTEDAT"},
+ "end_time": {"name": "endedat"},
+ "category": {"name": "task"},
+ },
+ preview_formats=[],
+ )
+ with (
+ patch(
+ "superset.mcp_service.auth.get_user_from_request",
+ return_value=Mock(id=1),
+ ),
+ patch.object(
+ update_chart_preview_module, "_find_dataset", return_value=dataset
+ ),
+ patch.object(update_chart_preview_module, "generate_explore_link") as link,
+ ):
+ result = update_chart_preview_module.update_chart_preview(request, ctx=Mock())
+
+ assert result["success"] is False
+ assert result["error"]["error_type"] == "gantt_semantic_validation_error"
+ assert "ambiguous" in result["error"]["details"]
+ link.assert_not_called()
+
+
+@pytest.mark.parametrize("generate_query_preview", [False, True])
+def test_update_chart_preview_normalizes_all_gantt_refs_before_url_and_query_preview(
+ generate_query_preview: bool,
+) -> None:
+ columns = [
+ SimpleNamespace(
+ column_name=column["name"],
+ type=column["type"],
+ is_temporal=column["is_temporal"],
+ is_numeric=False,
+ )
+ for column in _dataset_context().available_columns
+ ]
+ metrics = [
+ SimpleNamespace(
+ metric_name="Completion",
+ expression="AVG(progress)",
+ description=None,
+ )
+ ]
+ dataset = SimpleNamespace(
+ id=1,
+ table_name="tasks",
+ schema=None,
+ columns=columns,
+ metrics=metrics,
+ database=SimpleNamespace(database_name="main", db_engine_spec=None),
+ )
+ request = UpdateChartPreviewRequest(
+ dataset_id=1,
+ config=_config(
+ start_time={"name": "start_time"},
+ end_time={"name": "end_time"},
+ category={"name": "task"},
+ series={"name": "owner"},
+ temporal_column="end_time",
+ tooltip_columns=[{"name": "project"}],
+ tooltip_metrics=[{"name": "completion", "saved_metric": True}],
+ filters=[{"column": "owner", "op": "=", "value": "Amin"}],
+ order_by=[{"column": "priority", "ascending": False}],
+ time_range="Last 7 days",
+ ),
+ generate_preview=generate_query_preview,
+ preview_formats=["vega_lite"],
+ )
+
+ with (
+ patch(
+ "superset.mcp_service.auth.get_user_from_request",
+ return_value=Mock(id=1),
+ ),
+ patch.object(
+ update_chart_preview_module, "_find_dataset", return_value=dataset
+ ),
+ patch("superset.daos.dataset.DatasetDAO.find_by_id", return_value=dataset),
+ patch.object(
+ update_chart_preview_module, "has_dataset_access", return_value=True
+ ),
+ patch.object(update_chart_preview_module, "validate_and_compile") as validate,
+ patch.object(
+ update_chart_preview_module,
+ "generate_explore_link",
+ return_value="http://superset/explore/?form_data_key=canonical",
+ ) as generate_link,
+ patch.object(
+ update_chart_preview_module, "generate_preview_from_form_data"
+ ) as generate_preview,
+ patch.object(
+ update_chart_preview_module, "analyze_chart_capabilities", return_value=None
+ ),
+ patch.object(
+ update_chart_preview_module, "analyze_chart_semantics", return_value=None
+ ),
+ ):
+ validate.return_value = SimpleNamespace(success=True)
+ generate_preview.return_value = VegaLitePreview(
+ specification={"mark": "bar"},
+ data_url=None,
+ supports_streaming=False,
+ )
+ result = update_chart_preview_module.update_chart_preview(
+ request=request, ctx=Mock()
+ )
+
+ assert result["success"] is True
+ form_data = generate_link.call_args.args[1]
+ assert form_data["start_time"] == "Start_Time"
+ assert form_data["end_time"] == "End_Time"
+ assert form_data["y_axis"] == "Task"
+ assert form_data["series"] == "Owner"
+ assert form_data["tooltip_columns"] == ["Project"]
+ assert form_data["tooltip_metrics"] == ["Completion"]
+ assert form_data["order_by_cols"] == ['["Priority", false]']
+ assert form_data["adhoc_filters"][0]["subject"] == "Owner"
+ temporal = [
+ filter_
+ for filter_ in form_data["adhoc_filters"]
+ if filter_["operator"] == "TEMPORAL_RANGE"
+ ]
+ assert temporal == [
+ {
+ "clause": "WHERE",
+ "expressionType": "SIMPLE",
+ "subject": "End_Time",
+ "operator": "TEMPORAL_RANGE",
+ "comparator": "Last 7 days",
+ }
+ ]
+ if generate_query_preview:
+ preview_form_data = generate_preview.call_args.kwargs["form_data"]
+ assert preview_form_data == form_data
+ else:
+ generate_preview.assert_not_called()
+
+
+@pytest.mark.parametrize(
+ (
+ "overrides",
+ "expected_subject",
+ "expected_range",
+ "keeps_unrelated",
+ "expected_series",
+ "expected_subcategories",
+ ),
+ [
+ (
+ {"temporal_column": "end_time", "time_range": "Last 7 days"},
+ "End_Time",
+ "Last 7 days",
+ True,
+ "Owner",
+ True,
+ ),
+ (
+ {"temporal_column": "start_time", "time_range": "Last 7 days"},
+ "Start_Time",
+ "Last 7 days",
+ True,
+ "Owner",
+ True,
+ ),
+ ({}, "Start_Time", "Last 30 days", True, "Owner", True),
+ ({"filters": []}, "Start_Time", "No filter", False, "Owner", True),
+ (
+ {"subcategories": False},
+ "Start_Time",
+ "Last 30 days",
+ True,
+ "Owner",
+ False,
+ ),
+ ({"series": None}, "Start_Time", "Last 30 days", True, None, False),
+ ],
+)
+def test_cached_update_chart_preview_preserves_or_replaces_temporal_binding(
+ overrides: dict[str, object],
+ expected_subject: str,
+ expected_range: str,
+ keeps_unrelated: bool,
+ expected_series: str | None,
+ expected_subcategories: bool,
+) -> None:
+ columns = [
+ SimpleNamespace(
+ column_name=column["name"],
+ type=column["type"],
+ is_temporal=column["is_temporal"],
+ is_numeric=False,
+ )
+ for column in _dataset_context().available_columns
+ ]
+ dataset = SimpleNamespace(
+ id=1,
+ table_name="tasks",
+ schema=None,
+ columns=columns,
+ metrics=[],
+ database=SimpleNamespace(database_name="main", db_engine_spec=None),
+ )
+ old_binding = {
+ "clause": "WHERE",
+ "comparator": "Last 30 days",
+ "expressionType": "SIMPLE",
+ "operator": "TEMPORAL_RANGE",
+ "subject": "Start_Time",
+ }
+ unrelated = {
+ "clause": "WHERE",
+ "comparator": "MCP",
+ "expressionType": "SIMPLE",
+ "operator": "==",
+ "subject": "Project",
+ }
+ previous = {
+ "viz_type": "gantt_chart",
+ "adhoc_filters": [old_binding, unrelated],
+ "_mcp_dashboard_time_filter_subject": "Start_Time",
+ "series": "Owner",
+ "subcategories": True,
+ }
+ request = UpdateChartPreviewRequest(
+ form_data_key="previous",
+ dataset_id=1,
+ config=_config(**overrides),
+ generate_preview=False,
+ )
+
+ with (
+ patch(
+ "superset.mcp_service.auth.get_user_from_request",
+ return_value=Mock(id=1),
+ ),
+ patch.object(
+ update_chart_preview_module, "_find_dataset", return_value=dataset
+ ),
+ patch.object(
+ update_chart_preview_module,
+ "_get_previous_form_data",
+ return_value=previous,
+ ),
+ patch("superset.daos.dataset.DatasetDAO.find_by_id", return_value=dataset),
+ patch.object(
+ update_chart_preview_module, "has_dataset_access", return_value=True
+ ),
+ patch.object(update_chart_preview_module, "validate_and_compile") as validate,
+ patch.object(
+ update_chart_preview_module,
+ "generate_explore_link",
+ return_value="http://superset/explore/?form_data_key=current",
+ ) as generate_link,
+ patch.object(
+ update_chart_preview_module, "analyze_chart_capabilities", return_value=None
+ ),
+ patch.object(
+ update_chart_preview_module, "analyze_chart_semantics", return_value=None
+ ),
+ ):
+ validate.return_value = SimpleNamespace(success=True)
+ result = update_chart_preview_module.update_chart_preview(
+ request=request, ctx=Mock()
+ )
+
+ assert result["success"] is True
+ form_data = generate_link.call_args.args[1]
+ temporal = [
+ filter_
+ for filter_ in form_data["adhoc_filters"]
+ if filter_["operator"] == "TEMPORAL_RANGE"
+ ]
+ assert len(temporal) == 1
+ assert temporal[0]["subject"] == expected_subject
+ assert temporal[0]["comparator"] == expected_range
+ if {"temporal_column", "time_range", "filters"} & overrides.keys():
+ assert form_data["_mcp_dashboard_time_filter_subject"] == expected_subject
+ else:
+ assert "_mcp_dashboard_time_filter_subject" not in form_data
+ assert (unrelated in form_data["adhoc_filters"]) is keeps_unrelated
+ assert form_data.get("series") == expected_series
+ assert form_data.get("subcategories", False) is expected_subcategories
+ assert not form_data.get("subcategories") or form_data.get("series")
+ GanttChartConfig.model_validate(form_data)
+
+
+def test_temporal_dataset_semantics() -> None:
+ from superset.mcp_service.chart import registry
+
+ plugin = registry.get("gantt")
+ assert plugin is not None
+ config = _config()
+ context = _dataset_context()
+ next(
+ column for column in context.available_columns if column["name"] == "Start_Time"
+ )["is_temporal"] = False
+ with patch.object(DatasetValidator, "_get_dataset_context", return_value=context):
+ error = plugin.post_map_validate(config, {}, dataset_id=1)
+ assert error is not None
+ assert error.error_code == "NON_TEMPORAL_GANTT_TIME_COLUMN"
+
+
+def test_unsaved_gantt_vega_preview_representative_empty_and_invalid_data() -> None:
+ form_data = {
+ "viz_type": "gantt_chart",
+ "start_time": "start_time",
+ "end_time": "end_time",
+ "y_axis": "task",
+ "series": "owner",
+ "subcategories": True,
+ "tooltip_columns": ["project"],
+ }
+ data = [
+ {
+ "start_time": "2026-01-01T00:00:00",
+ "end_time": "2026-01-03T00:00:00",
+ "task": "Build",
+ "owner": "Amin",
+ "project": "MCP",
+ }
+ ]
+ preview = _generate_gantt_vega_lite_preview(data, form_data)
+ assert isinstance(preview, VegaLitePreview)
+ assert preview.specification["mark"]["type"] == "bar"
+ assert preview.specification["encoding"]["x2"] == {"field": "end_time"}
+ assert preview.specification["encoding"]["yOffset"]["field"] == "owner"
+
+ empty = _generate_gantt_vega_lite_preview([], form_data)
+ assert isinstance(empty, VegaLitePreview)
+ assert empty.specification["data"]["values"] == []
+
+ missing = _generate_gantt_vega_lite_preview([{"task": "Build"}], form_data)
+ assert isinstance(missing, ChartError)
+ assert missing.error_type == "InvalidGanttResult"
+
+
+@pytest.mark.parametrize(
+ ("bad_row", "message"),
+ [
+ (
+ {"start_time": value, "end_time": "2026-01-02", "task": "Build"},
+ "invalid start",
+ )
+ for value in (None, True, "not-a-date", float("inf"))
+ ]
+ + [
+ (
+ {"start_time": "2026-01-01", "end_time": value, "task": "Build"},
+ "invalid end",
+ )
+ for value in (None, False, "not-a-date", float("nan"), "2025-01-01")
+ ]
+ + [
+ (
+ {"start_time": "2026-01-01", "end_time": "2026-01-02", "task": value},
+ "invalid category",
+ )
+ for value in (None, True, "", " ")
+ ],
+)
+def test_gantt_preview_skips_invalid_values_in_mixed_results(
+ bad_row: dict[str, object], message: str
+) -> None:
+ form_data = {
+ "viz_type": "gantt_chart",
+ "start_time": "start_time",
+ "end_time": "end_time",
+ "y_axis": "task",
+ }
+ valid = {
+ "start_time": 1767225600000,
+ "end_time": 1767312000000,
+ "task": "Plan",
+ }
+ result = _generate_gantt_vega_lite_preview([valid, bad_row], form_data)
+ assert isinstance(result, VegaLitePreview), message
+ assert result.specification["data"]["values"] == [valid]
+ invalid = _generate_gantt_vega_lite_preview([bad_row], form_data)
+ assert isinstance(invalid, ChartError)
+ assert invalid.error_type == "InvalidGanttResult"
+ assert "no renderable intervals" in invalid.error
+
+
+def test_gantt_preview_resolves_custom_column_and_metric_aliases() -> None:
+ form_data = {
+ "viz_type": "gantt_chart",
+ "start_time": {
+ "expressionType": "SQL",
+ "sqlExpression": "MIN(started_at)",
+ "label": "Start alias",
+ },
+ "end_time": {
+ "expressionType": "SQL",
+ "sqlExpression": "MAX(ended_at)",
+ "label": "End alias",
+ },
+ "y_axis": {
+ "expressionType": "SQL",
+ "sqlExpression": "task",
+ "label": "Task alias",
+ },
+ "tooltip_columns": [
+ {
+ "expressionType": "SQL",
+ "sqlExpression": "owner",
+ "label": "Owner alias",
+ }
+ ],
+ "tooltip_metrics": [{"label": "Total cost"}],
+ }
+ data = [
+ {
+ "Start alias": "2026-01-01T00:00:00Z",
+ "End alias": "2026-01-02T00:00:00Z",
+ "Task alias": "Build",
+ "Owner alias": "Amin",
+ "Total cost": 42,
+ }
+ ]
+ preview = _generate_gantt_vega_lite_preview(data, form_data)
+ assert isinstance(preview, VegaLitePreview)
+ assert preview.specification["encoding"]["x"]["field"] == "Start alias"
+ assert {item["field"] for item in preview.specification["encoding"]["tooltip"]} >= {
+ "Owner alias",
+ "Total cost",
+ }
+
+ missing_later_alias = _generate_gantt_vega_lite_preview(
+ [
+ data[0],
+ {key: value for key, value in data[0].items() if key != "Owner alias"},
+ ],
+ form_data,
+ )
+ assert isinstance(missing_later_alias, ChartError)
+ assert "row 1" in missing_later_alias.error
+ assert "Owner alias" in missing_later_alias.error
+
+
+@pytest.mark.parametrize(
+ ("metric", "result_label"),
+ [
+ ("Completion", "Completion"),
+ (
+ {
+ "expressionType": "SIMPLE",
+ "aggregate": "SUM",
+ "column": {"column_name": "cost"},
+ "label": "Total cost",
+ },
+ "Total cost",
+ ),
+ (
+ {
+ "expressionType": "SIMPLE",
+ "aggregate": "AVG",
+ "column": {"columnName": "hours"},
+ },
+ "AVG(hours)",
+ ),
+ (
+ {
+ "expressionType": "SQL",
+ "sqlExpression": "SUM(cost) / COUNT(*)",
+ "label": "Unit cost",
+ },
+ "Unit cost",
+ ),
+ (
+ {
+ "expressionType": "SQL",
+ "sqlExpression": "SUM(cost) / COUNT(*)",
+ },
+ "SUM(cost) / COUNT(*)",
+ ),
+ ],
+)
+def test_saved_and_unsaved_preview_resolve_metric_result_labels(
+ metric: object, result_label: str
+) -> None:
+ form_data = {
+ "viz_type": "gantt_chart",
+ "start_time": "start_time",
+ "end_time": "end_time",
+ "y_axis": "task",
+ "tooltip_metrics": [metric],
+ }
+ data = [
+ {
+ "start_time": "2026-01-01",
+ "end_time": "2026-01-02",
+ "task": "Build",
+ result_label: 42,
+ }
+ ]
+
+ unsaved = _generate_gantt_vega_lite_preview(data, form_data)
+ assert isinstance(unsaved, VegaLitePreview)
+ assert result_label in {
+ item["field"] for item in unsaved.specification["encoding"]["tooltip"]
+ }
+
+ chart = SimpleNamespace(
+ id=1,
+ slice_name="Schedule",
+ viz_type="gantt_chart",
+ params=__import__("json").dumps(form_data),
+ )
+ strategy = VegaLitePreviewStrategy(
+ chart,
+ GetChartPreviewRequest(identifier=1, format="vega_lite"),
+ )
+ saved = strategy._create_vega_lite_spec(data)
+ assert result_label in {item["field"] for item in saved["encoding"]["tooltip"]}
+
+
+@pytest.mark.parametrize(
+ "metric",
+ [
+ {},
+ {"expressionType": "SIMPLE", "aggregate": "SUM", "column": {}},
+ {"expressionType": "SIMPLE", "aggregate": 1, "column": {}},
+ {"expressionType": "SQL", "sqlExpression": ""},
+ {"expressionType": "SQL", "sqlExpression": 123},
+ {"expressionType": "UNKNOWN", "sqlExpression": "SUM(cost)"},
+ ],
+)
+def test_gantt_preview_rejects_malformed_metric_objects(metric: object) -> None:
+ result = _generate_gantt_vega_lite_preview(
+ [
+ {
+ "start_time": "2026-01-01",
+ "end_time": "2026-01-02",
+ "task": "Build",
+ }
+ ],
+ {
+ "viz_type": "gantt_chart",
+ "start_time": "start_time",
+ "end_time": "end_time",
+ "y_axis": "task",
+ "tooltip_metrics": [metric],
+ },
+ )
+
+ assert isinstance(result, ChartError)
+ assert result.error_type == "InvalidGanttFormData"
+
+
+def test_saved_gantt_vega_preview_supports_valid_empty_result() -> None:
+ form_data = {
+ "start_time": "start_time",
+ "end_time": "end_time",
+ "y_axis": "task",
+ }
+ chart = SimpleNamespace(
+ id=1,
+ slice_name="Schedule",
+ viz_type="gantt_chart",
+ params=__import__("json").dumps(form_data),
+ )
+ request = GetChartPreviewRequest(
+ identifier=1,
+ format="vega_lite",
+ width=640,
+ height=320,
+ )
+ strategy = VegaLitePreviewStrategy(chart, request)
+ spec = strategy._create_vega_lite_spec([])
+ assert spec["data"]["values"] == []
+ assert spec["mark"]["type"] == "bar"
+ assert spec["encoding"]["x"]["field"] == "start_time"
+
+ representative = strategy._create_vega_lite_spec(
+ [
+ {
+ "start_time": "2026-01-01T00:00:00",
+ "end_time": "2026-01-02T00:00:00",
+ "task": "Review",
+ }
+ ]
+ )
+ assert representative["encoding"]["x2"] == {"field": "end_time"}
+ with pytest.raises(ValueError, match="missing required output fields: end_time"):
+ strategy._create_vega_lite_spec(
+ [{"start_time": "2026-01-01T00:00:00", "task": "Review"}]
+ )
+
+
+def test_saved_and_unsaved_gantt_preview_return_the_same_structured_error() -> None:
+ form_data = {
+ "viz_type": "gantt_chart",
+ "start_time": "start_time",
+ "end_time": "end_time",
+ "y_axis": "task",
+ }
+ data = [
+ {
+ "start_time": "2026-01-03",
+ "end_time": "2026-01-02",
+ "task": "Review",
+ }
+ ]
+ unsaved = _generate_gantt_vega_lite_preview(data, form_data)
+ assert isinstance(unsaved, ChartError)
+
+ chart = SimpleNamespace(
+ id=1,
+ slice_name="Schedule",
+ viz_type="gantt_chart",
+ params=__import__("json").dumps(form_data),
+ )
+ strategy = VegaLitePreviewStrategy(
+ chart,
+ GetChartPreviewRequest(identifier=1, format="vega_lite"),
+ )
+ with (
+ patch(
+ "superset.mcp_service.chart.tool.get_chart_preview."
+ "build_query_context_from_form_data",
+ return_value=object(),
+ ),
+ patch(
+ "superset.commands.chart.data.get_data_command.ChartDataCommand"
+ ) as command,
+ patch.object(strategy, "_authorize_guest_query"),
+ ):
+ command.return_value.run.return_value = {"queries": [{"data": data}]}
+ saved = strategy.generate()
+
+ assert isinstance(saved, ChartError)
+ assert saved.error_type == unsaved.error_type
+ assert saved.error == unsaved.error
+
+
+@pytest.mark.parametrize(
+ "query_payload",
+ [
+ {"queries": [{"error": "empty query failure", "data": []}]},
+ {
+ "queries": [
+ {
+ "error": "nonempty query failure",
+ "data": [
+ {
+ "start_time": "2026-01-01",
+ "end_time": "2026-01-02",
+ "task": "Build",
+ }
+ ],
+ }
+ ]
+ },
+ {"error": "top-level failure", "queries": [{"data": []}]},
+ {"errors": ["top-level failure"], "queries": [{"data": []}]},
+ {
+ "queries": [
+ {"data": []},
+ {"status": "failed", "message": "second query failure", "data": []},
+ ]
+ },
+ ],
+)
+def test_saved_and_unsaved_gantt_previews_fail_on_embedded_query_errors(
+ query_payload: dict[str, object],
+) -> None:
+ form_data = {
+ "viz_type": "gantt_chart",
+ "start_time": "start_time",
+ "end_time": "end_time",
+ "y_axis": "task",
+ }
+ chart = SimpleNamespace(
+ id=1,
+ slice_name="Schedule",
+ viz_type="gantt_chart",
+ datasource_id=1,
+ datasource_type="table",
+ params=__import__("json").dumps(form_data),
+ )
+ strategy = VegaLitePreviewStrategy(
+ chart,
+ GetChartPreviewRequest(identifier=1, format="vega_lite"),
+ )
+ with (
+ patch(
+ "superset.mcp_service.chart.tool.get_chart_preview."
+ "build_query_context_from_form_data",
+ return_value=object(),
+ ),
+ patch(
+ "superset.commands.chart.data.get_data_command.ChartDataCommand"
+ ) as command,
+ patch.object(strategy, "_authorize_guest_query"),
+ ):
+ command.return_value.run.return_value = query_payload
+ saved = strategy.generate()
+
+ with (
+ patch("superset.extensions.db.session.get", return_value=object()),
+ patch(
+ "superset.mcp_service.chart.chart_helpers."
+ "build_query_context_from_form_data",
+ return_value=object(),
+ ),
+ patch(
+ "superset.commands.chart.data.get_data_command.ChartDataCommand"
+ ) as command,
+ ):
+ command.return_value.run.return_value = query_payload
+ unsaved = generate_preview_from_form_data(form_data, 1, "vega_lite")
+
+ assert isinstance(saved, ChartError)
+ assert saved.error_type == "QueryError"
+ assert isinstance(unsaved, ChartError)
+ assert unsaved.error_type == "QueryError"
+
+
+def test_valid_empty_query_result_stays_a_successful_empty_gantt_preview() -> None:
+ payload = {"queries": [{"status": "success", "data": []}]}
+ assert query_result_failure(payload) is None
+ form_data = {
+ "viz_type": "gantt_chart",
+ "start_time": "start_time",
+ "end_time": "end_time",
+ "y_axis": "task",
+ }
+ with (
+ patch("superset.extensions.db.session.get", return_value=object()),
+ patch(
+ "superset.mcp_service.chart.chart_helpers."
+ "build_query_context_from_form_data",
+ return_value=object(),
+ ),
+ patch(
+ "superset.commands.chart.data.get_data_command.ChartDataCommand"
+ ) as command,
+ ):
+ command.return_value.run.return_value = payload
+ preview = generate_preview_from_form_data(form_data, 1, "vega_lite")
+
+ assert isinstance(preview, VegaLitePreview)
+ assert preview.specification["data"]["values"] == []
+
+ chart = SimpleNamespace(
+ id=1,
+ slice_name="Schedule",
+ viz_type="gantt_chart",
+ datasource_id=1,
+ datasource_type="table",
+ params=__import__("json").dumps(form_data),
+ )
+ strategy = VegaLitePreviewStrategy(
+ chart, GetChartPreviewRequest(identifier=1, format="vega_lite")
+ )
+ with (
+ patch(
+ "superset.mcp_service.chart.tool.get_chart_preview."
+ "build_query_context_from_form_data",
+ return_value=object(),
+ ),
+ patch(
+ "superset.commands.chart.data.get_data_command.ChartDataCommand"
+ ) as command,
+ patch.object(strategy, "_authorize_guest_query"),
+ ):
+ command.return_value.run.return_value = payload
+ saved_preview = strategy.generate()
+
+ assert isinstance(saved_preview, VegaLitePreview)
+ assert saved_preview.specification["data"]["values"] == []
+
+
+def test_query_failure_detection_applies_without_changing_valid_table_preview() -> None:
+ failure = query_result_failure(
+ {"queries": [{"status": "timed_out", "message": "timeout", "data": []}]}
+ )
+ assert isinstance(failure, ChartError)
+ assert failure.error_type == "QueryError"
+
+ with (
+ patch("superset.extensions.db.session.get", return_value=object()),
+ patch(
+ "superset.mcp_service.chart.chart_helpers."
+ "build_query_context_from_form_data",
+ return_value=object(),
+ ),
+ patch(
+ "superset.commands.chart.data.get_data_command.ChartDataCommand"
+ ) as command,
+ ):
+ command.return_value.run.return_value = {"queries": [{"data": []}]}
+ preview = generate_preview_from_form_data(
+ {"viz_type": "table", "all_columns": ["task"]}, 1, "table"
+ )
+
+ assert isinstance(preview, TablePreview)
+
+
+def test_saved_update_and_preview_first_paths_preserve_native_gantt_state() -> None:
+ existing = _native_example()
+ chart = SimpleNamespace(
+ id=1495,
+ datasource_id=None,
+ slice_name="Gantt",
+ params=__import__("json").dumps(existing),
+ )
+ config = _config(series={"name": "priority"})
+ request = UpdateChartRequest(identifier=1495, config=config)
+
+ payload = _build_update_payload(request, chart, parsed_config=config)
+ assert isinstance(payload, dict)
+ saved_form_data = __import__("json").loads(payload["params"])
+ assert saved_form_data["viz_type"] == "gantt_chart"
+ assert saved_form_data["legendOrientation"] == "right"
+ assert saved_form_data["legendMargin"] == 100
+ assert saved_form_data["x_axis_time_bounds"] == ["08:00:00", "19:00:00"]
+ assert payload["query_context"] is None
+
+ preview_form_data = _build_preview_form_data(request, chart, parsed_config=config)
+ assert isinstance(preview_form_data, dict)
+ assert preview_form_data["legendOrientation"] == "right"
+ assert preview_form_data["zoomable"] is False
+ assert preview_form_data["slice_id"] == 1495
+
+
+@pytest.mark.parametrize(
+ ("overrides", "expected_series", "expected_subcategories"),
+ [
+ ({}, "owner", True),
+ ({"subcategories": False}, "owner", False),
+ ({"series": {"name": "project"}}, "project", True),
+ ({"series": None}, None, False),
+ ({"series": None, "subcategories": False}, None, False),
+ ],
+)
+def test_saved_and_cached_update_merges_keep_gantt_series_dependency_coherent(
+ overrides: dict[str, object],
+ expected_series: str | None,
+ expected_subcategories: bool,
+) -> None:
+ existing = {
+ **_native_example(),
+ "series": "owner",
+ "subcategories": True,
+ }
+ chart = SimpleNamespace(
+ id=1495,
+ datasource_id=None,
+ slice_name="Gantt",
+ params=__import__("json").dumps(existing),
+ )
+ config = _config(**overrides)
+ request = UpdateChartRequest(identifier=1495, config=config)
+
+ payload = _build_update_payload(request, chart, parsed_config=config)
+ cached_preview = _build_preview_form_data(request, chart, parsed_config=config)
+
+ assert isinstance(payload, dict)
+ assert isinstance(cached_preview, dict)
+ persisted = __import__("json").loads(payload["params"])
+ assert persisted.get("series") == expected_series
+ assert persisted.get("subcategories", False) is expected_subcategories
+ assert cached_preview.get("series") == expected_series
+ assert cached_preview.get("subcategories", False) is expected_subcategories
+ assert not persisted.get("subcategories") or persisted.get("series")
+ round_trip = GanttChartConfig.model_validate(persisted)
+ assert round_trip.subcategories is expected_subcategories
+ assert (round_trip.series.name if round_trip.series else None) == expected_series
+
+
+def test_merge_repairs_stale_subcategories_without_series() -> None:
+ replacement = map_gantt_config(_config())
+ merge_gantt_ui_config(
+ {"viz_type": "gantt_chart", "subcategories": True}, replacement
+ )
+
+ assert replacement.get("subcategories", False) is False
+ assert not replacement.get("series")
+ GanttChartConfig.model_validate(replacement)
+
+
+@pytest.mark.parametrize("generate_preview", [True, False], ids=["preview", "save"])
+@pytest.mark.parametrize(
+ "category",
+ ["Owner", "owner", "OwNeR"],
+ ids=["exact", "casefold", "canonical_alias"],
+)
+@pytest.mark.asyncio
+async def test_update_chart_rejects_preserved_series_colliding_with_new_category(
+ generate_preview: bool,
+ category: str,
+) -> None:
+ """Preview and save reject conflicts introduced by native-state merging."""
+ existing = {
+ **_native_example(),
+ "y_axis": "Task",
+ "series": "Owner",
+ "subcategories": True,
+ }
+ chart = SimpleNamespace(
+ id=1495,
+ datasource_id=1,
+ datasource=object(),
+ slice_name="Gantt",
+ params=__import__("json").dumps(existing),
+ uuid="chart-uuid",
+ viz_type="gantt_chart",
+ )
+ request = UpdateChartRequest(
+ identifier=1495,
+ config=_config(category={"name": category}),
+ generate_preview=generate_preview,
+ preview_formats=[],
+ )
+
+ with (
+ patch(
+ "superset.mcp_service.auth.get_user_from_request",
+ return_value=Mock(id=1),
+ ),
+ patch.object(
+ update_chart_module, "find_chart_by_identifier", return_value=chart
+ ),
+ patch(
+ "superset.mcp_service.auth.check_chart_data_access",
+ return_value=SimpleNamespace(is_valid=True, error=None),
+ ),
+ patch.object(
+ DatasetValidator,
+ "_get_dataset_context",
+ return_value=_dataset_context(),
+ ),
+ patch("superset.commands.chart.update.UpdateChartCommand") as command,
+ patch.object(update_chart_module, "_create_preview_url") as create_preview,
+ ):
+ result = await update_chart_module.update_chart(request=request, ctx=Mock())
+
+ assert result.success is False
+ assert result.error is not None
+ assert result.error.error_type == "ValidationError"
+ assert (
+ "series and category must reference different columns" in result.error.details
+ )
+ command.assert_not_called()
+ create_preview.assert_not_called()
+
+
+@pytest.mark.parametrize(
+ "category",
+ ["Owner", "owner", "OwNeR"],
+ ids=["exact", "casefold", "canonical_alias"],
+)
+def test_update_chart_preview_rejects_cached_series_colliding_with_new_category(
+ category: str,
+) -> None:
+ """Standalone cached previews apply the same final-state semantic gate."""
+ context = _dataset_context()
+ columns = [
+ SimpleNamespace(
+ column_name=column["name"],
+ type=column["type"],
+ is_temporal=column["is_temporal"],
+ is_numeric=False,
+ )
+ for column in context.available_columns
+ ]
+ dataset = SimpleNamespace(
+ id=1,
+ table_name=context.table_name,
+ schema=None,
+ columns=columns,
+ metrics=[],
+ database=SimpleNamespace(database_name="main", db_engine_spec=None),
+ )
+ request = UpdateChartPreviewRequest(
+ form_data_key="previous",
+ dataset_id=1,
+ config=_config(category={"name": category}),
+ generate_preview=False,
+ preview_formats=[],
+ )
+ previous = {
+ "viz_type": "gantt_chart",
+ "start_time": "Start_Time",
+ "end_time": "End_Time",
+ "y_axis": "Task",
+ "series": "Owner",
+ "subcategories": True,
+ }
+
+ with (
+ patch(
+ "superset.mcp_service.auth.get_user_from_request",
+ return_value=Mock(id=1),
+ ),
+ patch.object(
+ update_chart_preview_module, "_find_dataset", return_value=dataset
+ ),
+ patch.object(
+ update_chart_preview_module,
+ "_get_previous_form_data",
+ return_value=previous,
+ ),
+ patch.object(update_chart_preview_module, "generate_explore_link") as link,
+ ):
+ result = update_chart_preview_module.update_chart_preview(request, ctx=Mock())
+
+ assert result["success"] is False
+ assert result["error"]["error_type"] == "gantt_semantic_validation_error"
+ assert (
+ "series and category must reference different columns"
+ in result["error"]["details"]
+ )
+ link.assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test_real_update_preserves_omitted_and_overrides_explicit_defaults() -> None:
+ saved_presentation = {
+ "color_scheme": "legacyColors",
+ "show_legend": False,
+ "legendOrientation": "right",
+ "legendType": "plain",
+ "legendMargin": 91,
+ "legendSort": "desc",
+ "zoomable": True,
+ "subcategories": True,
+ "show_extra_controls": True,
+ "x_axis_time_bounds": ["08:00:00", "19:00:00"],
+ "x_axis_time_format": "%Y-%m-%d",
+ "tooltipTimeFormat": "%H:%M",
+ "tooltipValuesFormat": ",.2f",
+ "x_axis_title": "Saved X title",
+ "x_axis_title_margin": 23,
+ "y_axis_title": "Saved Y title",
+ "y_axis_title_margin": 37,
+ }
+ chart = SimpleNamespace(
+ id=1495,
+ datasource_id=1,
+ datasource=object(),
+ slice_name="Gantt",
+ params=__import__("json").dumps(
+ {
+ **_native_example(),
+ **saved_presentation,
+ "series": "priority",
+ "y_axis_title_position": "start",
+ }
+ ),
+ uuid="chart-uuid",
+ viz_type="gantt_chart",
+ )
+ updated_chart = SimpleNamespace(
+ id=1495,
+ slice_name="Gantt",
+ uuid="chart-uuid",
+ viz_type="gantt_chart",
+ )
+
+ async def run_update(config: GanttChartConfig) -> dict[str, object]:
+ request = UpdateChartRequest(
+ identifier=1495,
+ config=config,
+ generate_preview=False,
+ preview_formats=[],
+ )
+ with (
+ patch(
+ "superset.mcp_service.auth.get_user_from_request",
+ return_value=Mock(id=1),
+ ),
+ patch.object(
+ update_chart_module, "find_chart_by_identifier", return_value=chart
+ ),
+ patch(
+ "superset.mcp_service.auth.check_chart_data_access",
+ return_value=SimpleNamespace(is_valid=True, error=None),
+ ),
+ patch.object(
+ DatasetValidator,
+ "_get_dataset_context",
+ return_value=_dataset_context(),
+ ),
+ patch.object(
+ update_chart_module,
+ "_validate_update_against_dataset",
+ return_value=None,
+ ),
+ patch("superset.commands.chart.update.UpdateChartCommand") as command,
+ patch.object(
+ update_chart_module, "analyze_chart_capabilities", return_value=None
+ ),
+ patch.object(
+ update_chart_module, "analyze_chart_semantics", return_value=None
+ ),
+ patch.object(
+ update_chart_module,
+ "get_superset_base_url",
+ return_value="http://superset",
+ ),
+ ):
+ command.return_value.run.return_value = updated_chart
+ result = await update_chart_module.update_chart(request=request, ctx=Mock())
+
+ assert result.success is True
+ payload = command.call_args.args[1]
+ return __import__("json").loads(payload["params"])
+
+ omitted = await run_update(
+ _config(
+ series={"name": "priority"},
+ tooltip_columns=[{"name": "project"}],
+ order_by=[{"column": "priority", "ascending": True}],
+ )
+ )
+ assert {key: omitted[key] for key in saved_presentation} == saved_presentation
+ assert "y_axis_title_position" not in omitted
+ assert omitted["start_time"] == "Start_Time"
+ assert omitted["end_time"] == "End_Time"
+ assert omitted["y_axis"] == "Task"
+ assert omitted["series"] == "Priority"
+ assert omitted["tooltip_columns"] == ["Project"]
+ assert omitted["order_by_cols"] == ['["Priority", true]']
+
+ explicit_defaults = {
+ "color_scheme": None,
+ "show_legend": True,
+ "legend_orientation": "top",
+ "legend_type": "scroll",
+ "legend_margin": None,
+ "legend_sort": None,
+ "zoomable": False,
+ "subcategories": False,
+ "show_extra_controls": False,
+ "x_axis_time_bounds": None,
+ "x_axis_time_format": "smart_date",
+ "tooltip_time_format": "smart_date",
+ "tooltip_values_format": "SMART_NUMBER",
+ "x_axis_title": None,
+ "x_axis_title_margin": None,
+ "y_axis_title": None,
+ "y_axis_title_margin": None,
+ }
+ overridden = await run_update(
+ _config(series={"name": "priority"}, **explicit_defaults)
+ )
+ expected_native_defaults = {
+ "color_scheme": None,
+ "show_legend": True,
+ "legendOrientation": "top",
+ "legendType": "scroll",
+ "legendMargin": None,
+ "legendSort": None,
+ "zoomable": False,
+ "subcategories": False,
+ "show_extra_controls": False,
+ "x_axis_time_bounds": None,
+ "x_axis_time_format": "smart_date",
+ "tooltipTimeFormat": "smart_date",
+ "tooltipValuesFormat": "SMART_NUMBER",
+ "x_axis_title": None,
+ "x_axis_title_margin": None,
+ "y_axis_title": None,
+ "y_axis_title_margin": None,
+ }
+ assert {
+ key: overridden[key] for key in expected_native_defaults
+ } == expected_native_defaults
+
+ omitted_dependency = await run_update(_config())
+ assert omitted_dependency["series"] == "priority"
+ assert omitted_dependency["subcategories"] is True
+ GanttChartConfig.model_validate(omitted_dependency)
+
+ removed_dependency = await run_update(_config(series=None))
+ assert removed_dependency.get("series") is None
+ assert removed_dependency["subcategories"] is False
+ GanttChartConfig.model_validate(removed_dependency)
+
+
+def test_gantt_rejects_false_success_y_axis_title_position() -> None:
+ schema = _get_chart_type_schema_impl("gantt")["schema"]
+ assert "y_axis_title_position" not in schema["properties"]
+ with pytest.raises(ValidationError, match="unsupported by Gantt"):
+ GanttChartConfig.model_validate(
+ {**_config().model_dump(), "y_axis_title_position": "start"}
+ )
+
+
+@pytest.mark.parametrize(
+ "overrides",
+ [
+ {},
+ {"filters": []},
+ {"filters": [{"column": "owner", "op": "=", "value": "Amin"}]},
+ {"temporal_column": "end_time", "time_range": "Last 7 days"},
+ ],
+)
+def test_gantt_update_preview_matches_would_be_persisted_state(
+ overrides: dict[str, object],
+) -> None:
+ existing = {
+ **_native_example(),
+ "adhoc_filters": [
+ {
+ "clause": "WHERE",
+ "expressionType": "SIMPLE",
+ "subject": "project",
+ "operator": "==",
+ "comparator": "Legacy",
+ }
+ ],
+ }
+ chart = SimpleNamespace(
+ id=1495,
+ datasource_id=None,
+ slice_name="Gantt",
+ params=__import__("json").dumps(existing),
+ )
+ config = _config(**overrides)
+ request = UpdateChartRequest(identifier=1495, config=config)
+
+ payload = _build_update_payload(request, chart, parsed_config=config)
+ preview = _build_preview_form_data(request, chart, parsed_config=config)
+ assert isinstance(payload, dict)
+ assert isinstance(preview, dict)
+ assert request.generate_preview is True
+ assert preview["slice_name"] == "Gantt"
+ assert isinstance(validate_gantt_form_data(preview), GanttChartConfig)
+ persisted = __import__("json").loads(payload["params"])
+ preview_state = {
+ key: value
+ for key, value in preview.items()
+ if key not in {"datasource", "slice_id", "slice_name"}
+ }
+ assert preview_state == persisted
+
+ previous_filters = existing["adhoc_filters"]
+ assert isinstance(previous_filters, list)
+ assert (previous_filters[0] in persisted["adhoc_filters"]) is (
+ "filters" not in overrides
+ )
+
+ marker = persisted["_mcp_dashboard_time_filter_subject"]
+ temporal_filters = [
+ filter_
+ for filter_ in persisted["adhoc_filters"]
+ if filter_["operator"] == "TEMPORAL_RANGE"
+ ]
+ assert len(temporal_filters) == 1
+ assert temporal_filters[0]["subject"] == marker
+ expected_range = overrides.get("time_range", "No filter")
+ assert temporal_filters[0]["comparator"] == expected_range
+
+
+def test_registry_schema_validation_and_presentation_merge_wiring() -> None:
+ from superset.mcp_service.chart import registry
+
+ plugin = registry.get("gantt")
+ assert plugin is not None
+ assert plugin.resolve_viz_type(None) == "gantt_chart"
+ assert registry.display_name_for_viz_type("gantt_chart") == "Gantt Chart"
+ valid, parsed, error = SchemaValidator.validate_request(
+ {
+ "dataset_id": 1,
+ "config": {
+ "chart_type": "gantt",
+ "start_time": {"name": "start_time"},
+ "end_time": {"name": "end_time"},
+ "category": {"name": "task"},
+ },
+ }
+ )
+ assert valid is True
+ assert parsed is not None
+ assert error is None
+
+ previous = {
+ "viz_type": "gantt_chart",
+ "legendOrientation": "right",
+ "legendMargin": 100,
+ "zoomable": True,
+ "x_axis_time_bounds": ["08:00:00", "19:00:00"],
+ }
+ replacement = map_gantt_config(_config())
+ merge_gantt_ui_config(previous, replacement)
+ assert replacement["legendOrientation"] == "right"
+ assert replacement["legendMargin"] == 100
+ assert replacement["zoomable"] is True
+ assert replacement["x_axis_time_bounds"] == ["08:00:00", "19:00:00"]
+
+
+@pytest.mark.parametrize("field", ["start_time", "end_time", "y_axis", "series"])
+def test_gantt_preview_accepts_native_physical_column_objects(field: str) -> None:
+ form_data: dict[str, object] = {
+ "viz_type": "gantt_chart",
+ "start_time": "start",
+ "end_time": "end",
+ "y_axis": "task",
+ "series": "owner",
+ "tooltip_columns": [{"column_name": "project"}],
+ }
+ form_data[field] = {"column_name": form_data[field]}
+ row = {"start": 1, "end": 2, "task": "Build", "owner": "Amin", "project": "MCP"}
+ preview = _generate_gantt_vega_lite_preview([row], form_data)
+ assert isinstance(preview, VegaLitePreview)
+ assert preview.specification["data"]["values"] == [row]
+
+
+def test_gantt_preview_bounds_renderable_rows_and_projects_used_fields() -> None:
+ form_data = {
+ "start_time": "start",
+ "end_time": "end",
+ "y_axis": "task",
+ }
+ invalid = {"start": None, "end": 2, "task": "Open"}
+ valid = {"start": 1, "end": 2, "task": "Build", "unused": "large payload"}
+ preview = _generate_gantt_vega_lite_preview(
+ [invalid] * 1001 + [valid] * 1001, form_data
+ )
+ assert isinstance(preview, VegaLitePreview)
+ assert (
+ preview.specification["data"]["values"]
+ == [{"start": 1, "end": 2, "task": "Build"}] * 1000
+ )
+
+
+def test_gantt_saved_update_replaces_marked_binding_and_preserves_native_filters() -> (
+ None
+):
+ """A changed temporal subject must not discard unrelated saved predicates."""
+ existing = {
+ "viz_type": "gantt_chart",
+ "_mcp_dashboard_time_filter_subject": "start_time",
+ "adhoc_filters": [
+ {
+ "expressionType": "SIMPLE",
+ "clause": "WHERE",
+ "subject": "start_time",
+ "operator": "TEMPORAL_RANGE",
+ "comparator": "Last 30 days",
+ },
+ {
+ "expressionType": "SIMPLE",
+ "clause": "WHERE",
+ "subject": "owner",
+ "operator": "==",
+ "comparator": "Amin",
+ },
+ ],
+ }
+ config = _config(temporal_column="end_time", time_range="Last 7 days")
+ chart = SimpleNamespace(
+ id=1,
+ datasource_id=None,
+ slice_name="Gantt",
+ params=__import__("json").dumps(existing),
+ )
+ request = UpdateChartRequest(identifier=1, config=config)
+ payload = _build_update_payload(request, chart, parsed_config=config)
+ assert isinstance(payload, dict)
+ saved = __import__("json").loads(payload["params"])
+ assert existing["adhoc_filters"][1] in saved["adhoc_filters"]
+ temporal = [
+ item for item in saved["adhoc_filters"] if item["operator"] == "TEMPORAL_RANGE"
+ ]
+ assert len(temporal) == 1
+ assert temporal[0]["subject"] == "end_time"
+ assert temporal[0]["comparator"] == "Last 7 days"
+ GanttChartConfig.model_validate(saved)
+
+
+@pytest.mark.asyncio
+async def test_default_gantt_update_validates_named_preview_before_caching() -> None:
+ """Default updates validate the complete preview envelope, not stripped params."""
+ chart = SimpleNamespace(
+ id=1495,
+ datasource_id=1,
+ datasource=SimpleNamespace(id=1),
+ slice_name="Saved schedule",
+ params=__import__("json").dumps(_native_example()),
+ uuid="chart-uuid",
+ viz_type="gantt_chart",
+ )
+ request = UpdateChartRequest(identifier=1495, config=_config())
+ assert request.generate_preview is True
+
+ with (
+ patch.object(
+ update_chart_module.event_logger,
+ "log_context",
+ side_effect=lambda **kwargs: nullcontext(),
+ ),
+ patch(
+ "superset.mcp_service.auth.get_user_from_request", return_value=Mock(id=1)
+ ),
+ patch.object(
+ update_chart_module, "find_chart_by_identifier", return_value=chart
+ ),
+ patch(
+ "superset.mcp_service.auth.check_chart_data_access",
+ return_value=SimpleNamespace(is_valid=True, error=None),
+ ),
+ patch.object(
+ DatasetValidator, "_get_dataset_context", return_value=_dataset_context()
+ ),
+ patch.object(
+ update_chart_module,
+ "validate_and_compile",
+ return_value=SimpleNamespace(success=True),
+ ) as compile_chart,
+ patch.object(
+ update_chart_module,
+ "_create_preview_url",
+ return_value=(
+ "http://superset/explore/?form_data_key=preview",
+ "preview",
+ [],
+ ),
+ ) as create_preview,
+ patch("superset.commands.chart.update.UpdateChartCommand") as command,
+ ):
+ result = await update_chart_module.update_chart(request=request, ctx=Mock())
+
+ assert result.success is True
+ validated_config, preview, dataset = compile_chart.call_args.args
+ assert isinstance(validated_config, GanttChartConfig)
+ assert preview["slice_name"] == "Saved schedule"
+ assert preview["slice_id"] == chart.id
+ assert preview["datasource"] == "1__table"
+ assert dataset is chart.datasource
+ assert create_preview.call_args.args[1] == preview
+ command.assert_not_called()
+
+
+def test_gantt_transport_name_does_not_allow_unknown_controls() -> None:
+ """Allow chart naming metadata without weakening visualization typo checks."""
+ form_data = {**map_gantt_config(_config()), "slice_name": "Saved schedule"}
+ with pytest.raises(
+ GanttSemanticNormalizationError, match="Unknown field 'zoomabl'"
+ ):
+ validate_gantt_form_data({**form_data, "zoomabl": True})
+
+
+def _gantt_partial_update_states(
+ existing: dict[str, object], config: GanttChartConfig, dataset_rebind: bool = False
+) -> list[dict[str, Any]]:
+ """Build saved-preview, persisted, and cached-preview shared merge states."""
+ chart = SimpleNamespace(
+ id=1495,
+ datasource_id=1,
+ slice_name="Saved schedule",
+ params=__import__("json").dumps(existing),
+ )
+ request = UpdateChartRequest(
+ identifier=1495, config=config, dataset_id=2 if dataset_rebind else None
+ )
+ with patch(
+ "superset.mcp_service.chart.chart_utils._is_temporal_for_dashboard_binding",
+ return_value=True,
+ ):
+ 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)
+ persisted = __import__("json").loads(payload["params"])
+ cached = merge_chart_form_data(
+ existing, map_gantt_config(config), config, dataset_rebind=dataset_rebind
+ )
+ return [preview, persisted, cached]
+
+
+@pytest.mark.parametrize("explicit", [False, True])
+def test_gantt_partial_update_preserves_omitted_query_controls(explicit: bool) -> None:
+ """Omitted query controls survive; explicit empty/default values replace them."""
+ existing = map_gantt_config(
+ _config(
+ tooltip_columns=[{"name": "project"}],
+ tooltip_metrics=[{"name": "priority", "aggregate": "SUM"}],
+ order_by=[{"column": "priority", "ascending": False}],
+ row_limit=42,
+ )
+ )
+ config = (
+ _config(
+ zoomable=True,
+ tooltip_columns=[],
+ tooltip_metrics=[],
+ order_by=[],
+ row_limit=10000,
+ )
+ if explicit
+ else _config(zoomable=True)
+ )
+ for state in _gantt_partial_update_states(existing, config):
+ assert state["zoomable"] is True
+ for key in ("tooltip_columns", "tooltip_metrics", "order_by_cols", "row_limit"):
+ assert state[key] == (
+ map_gantt_config(config)[key] if explicit else existing[key]
+ )
+ assert isinstance(validate_gantt_form_data(state), GanttChartConfig)
+ assert existing["row_limit"] == 42
+
+
+def test_gantt_dataset_rebind_drops_saved_query_references() -> None:
+ """Changing datasets must not carry old series, filters, or tooltip/sort roles."""
+ existing = map_gantt_config(
+ _config(
+ series={"name": "old_series"},
+ subcategories=True,
+ filters=[{"column": "old_filter", "op": "=", "value": "old"}],
+ temporal_column="old_time",
+ time_range="Last month",
+ tooltip_columns=[{"name": "old_tooltip"}],
+ tooltip_metrics=[{"name": "old_metric", "saved_metric": True}],
+ order_by=[{"column": "old_sort", "ascending": True}],
+ row_limit=42,
+ )
+ )
+ config = _config()
+ for state in _gantt_partial_update_states(existing, config, dataset_rebind=True):
+ assert not state.get("series")
+ assert not state.get("subcategories")
+ assert state["tooltip_columns"] == []
+ assert state["tooltip_metrics"] == []
+ assert state["order_by_cols"] == []
+ assert state["row_limit"] == 10000
+ assert state["adhoc_filters"] == map_gantt_config(config)["adhoc_filters"]
+ assert "old_" not in __import__("json").dumps(state)
+ assert isinstance(validate_gantt_form_data(state), GanttChartConfig)
+
+
+@pytest.mark.parametrize(
+ ("overrides", "subject", "comparator"),
+ [
+ ({}, "end_time", "Last month"),
+ ({"time_range": "Last 7 days"}, "start_time", "Last 7 days"),
+ ({"temporal_column": "start_time"}, "start_time", "No filter"),
+ ({"filters": []}, "start_time", "No filter"),
+ ],
+)
+@pytest.mark.parametrize("keep_marker", [False, True])
+@pytest.mark.parametrize("previous_subject", ["start_time", "end_time"])
+def test_gantt_partial_update_reconciles_native_temporal_filter(
+ overrides: dict[str, object],
+ subject: str,
+ comparator: str,
+ keep_marker: bool,
+ previous_subject: str,
+) -> None:
+ """Keep an omitted native time predicate without adding a second binding."""
+ existing = map_gantt_config(
+ _config(
+ temporal_column=previous_subject,
+ time_range="Last month",
+ filters=[{"column": "project", "op": "=", "value": "Legacy"}],
+ )
+ )
+ if not keep_marker:
+ existing.pop("_mcp_dashboard_time_filter_subject")
+ for state in _gantt_partial_update_states(
+ existing, _config(zoomable=True, **overrides)
+ ):
+ adapted = validate_gantt_form_data(state)
+ assert isinstance(adapted, GanttChartConfig)
+ temporal = [
+ item
+ for item in state["adhoc_filters"]
+ if item["operator"] == "TEMPORAL_RANGE"
+ ]
+ assert len(temporal) == 1
+ assert temporal[0]["subject"] == (subject if overrides else previous_subject)
+ assert temporal[0]["comparator"] == comparator
+ if overrides:
+ assert state["_mcp_dashboard_time_filter_subject"] == subject
+ else:
+ assert "_mcp_dashboard_time_filter_subject" not in state
+ for repeated in _gantt_partial_update_states(
+ state, _config(zoomable=False)
+ ):
+ assert repeated["adhoc_filters"] == state["adhoc_filters"]
+ assert any(item["subject"] == "project" for item in state["adhoc_filters"]) is (
+ "filters" not in overrides
+ )
+
+
+@pytest.mark.parametrize("set_in_explore", [False, True])
+def test_gantt_partial_update_preserves_active_range_on_marked_subject(
+ set_in_explore: bool,
+) -> None:
+ """A marked start column retains both MCP-set and Explore-edited ranges."""
+ time_range = "2024-06-01 : 2024-07-01"
+ existing = map_gantt_config(
+ _config(time_range="No filter" if set_in_explore else time_range)
+ )
+ marker = existing["_mcp_dashboard_time_filter_subject"]
+ temporal = next(
+ item
+ for item in existing["adhoc_filters"]
+ if item["operator"] == "TEMPORAL_RANGE"
+ )
+ assert temporal["subject"] == marker == "start_time"
+ if set_in_explore:
+ temporal["comparator"] = time_range
+
+ for state in _gantt_partial_update_states(existing, _config(show_legend=False)):
+ assert state["show_legend"] is False
+ assert state["adhoc_filters"] == existing["adhoc_filters"]
+ assert isinstance(validate_gantt_form_data(state), GanttChartConfig)
+ for repeated in _gantt_partial_update_states(state, _config(show_legend=True)):
+ assert repeated["adhoc_filters"] == existing["adhoc_filters"]
+ assert isinstance(validate_gantt_form_data(repeated), GanttChartConfig)
+
+
+@pytest.mark.parametrize(
+ ("bounds", "expected"),
+ [
+ (["", "19:00:00"], (None, "19:00:00")),
+ (["08:00:00", ""], ("08:00:00", None)),
+ (["", ""], (None, None)),
+ ([None, ""], (None, None)),
+ ],
+)
+def test_gantt_partial_update_accepts_empty_native_time_bounds(
+ bounds: list[str | None], expected: tuple[str | None, str | None]
+) -> None:
+ """Explore's empty bound strings round-trip as unset values during updates."""
+ existing = {**map_gantt_config(_config()), "x_axis_time_bounds": bounds}
+ for state in _gantt_partial_update_states(existing, _config(zoomable=True)):
+ adapted = validate_gantt_form_data(state)
+ assert isinstance(adapted, GanttChartConfig)
+ assert adapted.x_axis_time_bounds == expected
+ assert map_gantt_config(adapted)["x_axis_time_bounds"] == list(expected)
+
+
+@pytest.mark.parametrize("overrides", [{}, {"time_range": "Last 7 days"}])
+def test_gantt_partial_update_rejects_multiple_native_temporal_predicates(
+ overrides: dict[str, object],
+) -> None:
+ """Reconciliation must not silently discard multiple user time predicates."""
+ existing = map_gantt_config(
+ _config(temporal_column="end_time", time_range="Last month")
+ )
+ existing.pop("_mcp_dashboard_time_filter_subject")
+ existing["adhoc_filters"].append(
+ {
+ "expressionType": "SIMPLE",
+ "clause": "WHERE",
+ "operator": "TEMPORAL_RANGE",
+ "subject": "other_time",
+ "comparator": "Last year",
+ }
+ )
+ states = _gantt_partial_update_states(existing, _config(**overrides))
+ for state in states:
+ with pytest.raises(
+ GanttSemanticNormalizationError, match="at most one TEMPORAL_RANGE"
+ ):
+ validate_gantt_form_data(state)
+
+
+@pytest.mark.parametrize("bound", [" ", "8am", "08:00", "25:00:00"])
+def test_gantt_partial_update_still_rejects_invalid_nonempty_time_bounds(
+ bound: str,
+) -> None:
+ """Only empty bound strings are unset; malformed times remain errors."""
+ existing = {**map_gantt_config(_config()), "x_axis_time_bounds": [bound, ""]}
+ with pytest.raises(GanttSemanticNormalizationError, match="HH:MM:SS"):
+ _gantt_partial_update_states(existing, _config())
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 41925b85a8f..63050c1378f 100644
--- a/tests/unit_tests/mcp_service/chart/test_preview_utils.py
+++ b/tests/unit_tests/mcp_service/chart/test_preview_utils.py
@@ -22,9 +22,12 @@ Tests for preview_utils query context column building.
import ast
import inspect
from pathlib import Path
-from unittest.mock import Mock, patch
+from unittest.mock import MagicMock, Mock, patch
+
+import pytest
from superset.mcp_service.chart import preview_utils
+from superset.mcp_service.chart.schemas import TablePreview
def _imports_chart_data_command(node: ast.Import | ast.ImportFrom) -> bool:
@@ -69,7 +72,7 @@ class TestPreviewUtilsColumnBuilding:
XY charts, and fall back to form_data["columns"] for table charts.
"""
- def test_xy_chart_uses_x_axis_and_groupby(self):
+ def test_xy_chart_uses_x_axis_and_groupby(self) -> None:
"""Test XY chart form_data builds columns from x_axis + groupby."""
form_data = {
"x_axis": "territory",
@@ -77,44 +80,22 @@ class TestPreviewUtilsColumnBuilding:
"metrics": [{"label": "SUM(sales)"}],
}
- x_axis_config = form_data.get("x_axis")
- groupby_columns = form_data.get("groupby", [])
- raw_columns = form_data.get("columns", [])
-
- columns = (
- raw_columns.copy() if "columns" in form_data else groupby_columns.copy()
- )
- if x_axis_config and isinstance(x_axis_config, str):
- if x_axis_config not in columns:
- columns.insert(0, x_axis_config)
- elif x_axis_config and isinstance(x_axis_config, dict):
- col_name = x_axis_config.get("column_name")
- if col_name and col_name not in columns:
- columns.insert(0, col_name)
+ columns = preview_utils._build_query_columns(form_data)
assert columns == ["territory", "year"]
- def test_table_chart_uses_columns_field(self):
+ def test_table_chart_uses_columns_field(self) -> None:
"""Test table chart form_data uses 'columns' field directly."""
form_data = {
"columns": ["name", "region", "sales"],
"metrics": [],
}
- x_axis_config = form_data.get("x_axis")
- groupby_columns = form_data.get("groupby", [])
- raw_columns = form_data.get("columns", [])
-
- columns = (
- raw_columns.copy() if "columns" in form_data else groupby_columns.copy()
- )
- if x_axis_config and isinstance(x_axis_config, str):
- if x_axis_config not in columns:
- columns.insert(0, x_axis_config)
+ columns = preview_utils._build_query_columns(form_data)
assert columns == ["name", "region", "sales"]
- def test_xy_chart_x_axis_dict_format(self):
+ def test_xy_chart_x_axis_dict_format(self) -> None:
"""Test XY chart with x_axis as dict (column_name key)."""
form_data = {
"x_axis": {"column_name": "order_date"},
@@ -122,63 +103,32 @@ class TestPreviewUtilsColumnBuilding:
"metrics": [{"label": "SUM(revenue)"}],
}
- x_axis_config = form_data.get("x_axis")
- groupby_columns = form_data.get("groupby", [])
- raw_columns = form_data.get("columns", [])
-
- columns = (
- raw_columns.copy() if "columns" in form_data else groupby_columns.copy()
- )
- if x_axis_config and isinstance(x_axis_config, str):
- if x_axis_config not in columns:
- columns.insert(0, x_axis_config)
- elif x_axis_config and isinstance(x_axis_config, dict):
- col_name = x_axis_config.get("column_name")
- if col_name and col_name not in columns:
- columns.insert(0, col_name)
+ columns = preview_utils._build_query_columns(form_data)
assert columns == ["order_date", "product_type"]
- def test_no_x_axis_no_columns_uses_groupby(self):
+ def test_no_x_axis_no_columns_uses_groupby(self) -> None:
"""Test fallback to groupby when no x_axis and no columns."""
form_data = {
"groupby": ["category"],
"metrics": [{"label": "COUNT(*)"}],
}
- x_axis_config = form_data.get("x_axis")
- groupby_columns = form_data.get("groupby", [])
- raw_columns = form_data.get("columns", [])
-
- columns = (
- raw_columns.copy() if "columns" in form_data else groupby_columns.copy()
- )
- if x_axis_config and isinstance(x_axis_config, str):
- if x_axis_config not in columns:
- columns.insert(0, x_axis_config)
+ columns = preview_utils._build_query_columns(form_data)
assert columns == ["category"]
- def test_empty_form_data_returns_empty_columns(self):
+ def test_empty_form_data_returns_empty_columns(self) -> None:
"""Test empty form_data returns empty columns list."""
- form_data: dict = {
+ form_data = {
"metrics": [{"label": "COUNT(*)"}],
}
- x_axis_config = form_data.get("x_axis")
- groupby_columns = form_data.get("groupby", [])
- raw_columns = form_data.get("columns", [])
-
- columns = (
- raw_columns.copy() if "columns" in form_data else groupby_columns.copy()
- )
- if x_axis_config and isinstance(x_axis_config, str):
- if x_axis_config not in columns:
- columns.insert(0, x_axis_config)
+ columns = preview_utils._build_query_columns(form_data)
assert columns == []
- def test_x_axis_not_duplicated_when_in_groupby(self):
+ def test_x_axis_not_duplicated_when_in_groupby(self) -> None:
"""Test x_axis is not added if already present in groupby."""
form_data = {
"x_axis": "territory",
@@ -186,16 +136,7 @@ class TestPreviewUtilsColumnBuilding:
"metrics": [{"label": "SUM(sales)"}],
}
- x_axis_config = form_data.get("x_axis")
- groupby_columns = form_data.get("groupby", [])
- raw_columns = form_data.get("columns", [])
-
- columns = (
- raw_columns.copy() if "columns" in form_data else groupby_columns.copy()
- )
- if x_axis_config and isinstance(x_axis_config, str):
- if x_axis_config not in columns:
- columns.insert(0, x_axis_config)
+ columns = preview_utils._build_query_columns(form_data)
assert columns == ["territory", "year"]
@@ -212,6 +153,58 @@ def test_build_query_columns_empty_columns_key_keeps_groupby():
) == ["country"]
+@pytest.mark.parametrize("time_grain", [None, "P1D", "P1M"])
+def test_unsaved_big_number_preview_uses_temporal_query_contract(
+ time_grain: str | None,
+) -> None:
+ """The shared preview keeps raw temporal grouping, not grain bucketing."""
+ form_data = {
+ "viz_type": "big_number",
+ "x_axis": {"column_name": "recorded_at"},
+ "granularity_sqla": "event_time",
+ "time_grain_sqla": time_grain,
+ "metric": "count",
+ "show_trend_line": True,
+ "time_range": "Last week",
+ "adhoc_filters": [
+ {
+ "clause": "WHERE",
+ "expressionType": "SIMPLE",
+ "subject": "region",
+ "operator": "==",
+ "comparator": "EMEA",
+ }
+ ],
+ }
+ with (
+ patch(
+ "superset.mcp_service.chart.chart_helpers.resolve_datasource_engine",
+ return_value="base",
+ ),
+ patch("superset.extensions.db.session.get", return_value=object()),
+ patch(
+ "superset.commands.chart.data.get_data_command.ChartDataCommand"
+ ) as command,
+ patch("superset.common.query_context_factory.QueryContextFactory") as factory,
+ ):
+ factory.return_value.create.return_value = MagicMock()
+ command.return_value.run.return_value = {
+ "queries": [{"status": "success", "data": []}]
+ }
+
+ result = preview_utils.generate_preview_from_form_data(form_data, 1, "table")
+
+ assert isinstance(result, TablePreview)
+ query = factory.return_value.create.call_args.kwargs["queries"][0]
+ assert query["columns"] == ["recorded_at"]
+ assert "granularity" not in query
+ assert "time_grain_sqla" not in query.get("extras", {})
+ assert query["metrics"] == ["count"]
+ assert query["time_range"] == "Last week"
+ assert query["filters"] == [{"col": "region", "op": "==", "val": "EMEA"}]
+ assert query["row_limit"] == 100
+
+
@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")
diff --git a/tests/unit_tests/mcp_service/chart/test_query_result.py b/tests/unit_tests/mcp_service/chart/test_query_result.py
new file mode 100644
index 00000000000..8352d902442
--- /dev/null
+++ b/tests/unit_tests/mcp_service/chart/test_query_result.py
@@ -0,0 +1,71 @@
+# 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.
+
+from enum import Enum
+
+import pytest
+
+from superset.common.db_query_status import QueryStatus
+from superset.mcp_service.chart.query_result import query_result_failure
+
+
+class UppercaseStatus(Enum):
+ FAILED = "FAILED"
+
+
+@pytest.mark.parametrize(
+ "payload",
+ [
+ {"error": "top-level error", "queries": []},
+ {"error_message": "top-level error message", "queries": []},
+ {"status": "ERROR", "message": "top-level status failure"},
+ {"status": "timed out", "message": "top-level timeout"},
+ {"success": False, "message": "top-level unsuccessful payload"},
+ {"message": "standalone top-level failure"},
+ {"queries": [{"status": "Failed", "message": "query failed"}]},
+ {
+ "queries": [
+ {"status": "success", "data": [{"value": 1}]},
+ {"status": QueryStatus.FAILED, "error_message": "second failed"},
+ ]
+ },
+ {"queries": [{"status": UppercaseStatus.FAILED, "message": "enum failed"}]},
+ ],
+)
+def test_query_result_failure_detects_failure_envelopes(payload):
+ failure = query_result_failure(payload)
+
+ assert failure is not None
+ assert failure.error_type == "QueryError"
+
+
+@pytest.mark.parametrize(
+ "payload",
+ [
+ {"status": "success", "message": "served from cache", "queries": []},
+ {
+ "queries": [
+ {"status": QueryStatus.SUCCESS, "message": "no rows", "data": []}
+ ]
+ },
+ {"queries": [{"status": "running", "message": "in progress", "data": []}]},
+ {"queries": [{"message": "informational", "data": []}]},
+ {"queries": [{"data": []}]},
+ ],
+)
+def test_query_result_failure_allows_valid_and_informational_envelopes(payload):
+ assert query_result_failure(payload) is None
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 7ea2aafa9c5..af10c5024d8 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
@@ -2179,6 +2179,62 @@ class TestUpdateChartValidationGate:
assert error["error_code"] == "CHART_VALIDATION_FAILED"
mock_update_cmd_cls.assert_not_called()
+ @pytest.mark.asyncio
+ async def test_persist_path_status_failure_skips_db_write(self, mcp_server):
+ """A status-only compile failure must block generate_preview=False."""
+ chart = self._mock_chart_with_dataset(chart_id=42)
+ chart.datasource.id = 10
+ access = DatasetValidationResult(
+ is_valid=True, dataset_id=10, dataset_name="ds", warnings=[]
+ )
+
+ with (
+ patch("superset.db.session"),
+ patch("superset.daos.chart.ChartDAO.find_by_id", return_value=chart),
+ patch(
+ "superset.mcp_service.auth.check_chart_data_access",
+ return_value=access,
+ ),
+ patch(
+ "superset.mcp_service.chart.compile.build_dataset_context_from_orm",
+ return_value=None,
+ ),
+ patch(
+ "superset.mcp_service.chart.compile."
+ "DatasetValidator.validate_against_dataset",
+ return_value=(True, None),
+ ),
+ patch(
+ "superset.common.query_context_factory.QueryContextFactory"
+ ) as factory,
+ patch(
+ "superset.commands.chart.data.get_data_command.ChartDataCommand"
+ ) as chart_data_command,
+ patch("superset.commands.chart.update.UpdateChartCommand") as update,
+ ):
+ factory.return_value.create.return_value = Mock()
+ chart_data_command.return_value.run.return_value = {
+ "queries": [{"status": "failed", "message": "boom", "data": []}]
+ }
+ request = {
+ "identifier": 42,
+ "generate_preview": False,
+ "config": {
+ "chart_type": "table",
+ "columns": [{"name": "region"}],
+ },
+ }
+
+ async with Client(mcp) as client:
+ result = await client.call_tool("update_chart", {"request": request})
+
+ assert result.structured_content["success"] is False
+ error = result.structured_content["error"]
+ assert error["error_code"] == "CHART_COMPILE_FAILED"
+ assert error["error_type"] == "compile_error"
+ assert "boom" in error["details"]
+ update.assert_not_called()
+
# ---------------------------------------------------------------------------
# Column normalization in update_chart
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 3f73d64ba3c..358df1d74d5 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
@@ -1444,3 +1444,39 @@ class TestUpdateChartPreviewValidation:
assert isinstance(error, dict)
assert error["error_type"] == "DatasetNotAccessible"
mock_create_form_data.assert_not_called()
+
+
+@pytest.mark.parametrize("allowed", [True, False])
+def test_previous_form_data_uses_existing_explore_access_gate(allowed: bool) -> None:
+ """Cached config retrieval retains Explore's resource-access check."""
+ from superset.commands.dataset.exceptions import DatasetAccessDeniedError
+ from superset.utils.core import DatasourceType
+
+ cached_form_data = '{"viz_type": "gantt_chart", "category": "task"}'
+ state = {
+ "owner": None,
+ "datasource_id": 7,
+ "datasource_type": "table",
+ "chart_id": 12,
+ "form_data": cached_form_data,
+ }
+ with (
+ patch(
+ "superset.commands.explore.form_data.get.app",
+ Mock(config={"EXPLORE_FORM_DATA_CACHE_CONFIG": {}}),
+ ),
+ patch("superset.commands.explore.form_data.get.cache_manager") as cache_manager,
+ patch(
+ "superset.commands.explore.form_data.utils.explore_check_access",
+ side_effect=None if allowed else DatasetAccessDeniedError(),
+ ) as check_access,
+ ):
+ cache_manager.explore_form_data_cache.get.return_value = state
+
+ result = update_chart_preview_module._get_previous_form_data("cached-key")
+
+ cache_manager.explore_form_data_cache.get.assert_called_once_with("cached-key")
+ check_access.assert_called_once_with(7, 12, DatasourceType.TABLE)
+ assert result == (
+ {"viz_type": "gantt_chart", "category": "task"} if allowed else None
+ )
diff --git a/tests/unit_tests/migrations/test_single_migration_head.py b/tests/unit_tests/migrations/test_single_migration_head.py
new file mode 100644
index 00000000000..36606d6c821
--- /dev/null
+++ b/tests/unit_tests/migrations/test_single_migration_head.py
@@ -0,0 +1,29 @@
+# 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.
+"""Regression coverage for a connected Alembic migration graph."""
+
+from pathlib import Path
+
+from alembic.script import ScriptDirectory
+
+
+def test_migrations_have_one_head() -> None:
+ """Database initialization must have an unambiguous upgrade target."""
+ migrations = Path(__file__).resolve().parents[3] / "superset" / "migrations"
+ scripts = ScriptDirectory(str(migrations))
+
+ assert len(scripts.get_heads()) == 1