sadpandajoe commented on code in PR #43770:
URL: https://github.com/apache/superset/pull/43770#discussion_r4155391823


##########
superset/mcp_service/chart/preview_utils.py:
##########
@@ -496,6 +1090,342 @@ def _is_nan(value: Any) -> bool:
         return False
 
 
+def _bullet_numeric_tokens(value: Any) -> list[float]:
+    """Parse native comma-separated Bullet threshold controls."""
+    if isinstance(value, str):
+        tokens: list[Any] = [token.strip() for token in value.split(",")]
+    elif isinstance(value, list):
+        tokens = value
+    else:
+        return []
+    result: list[float] = []
+    for token in tokens:
+        try:
+            number = float(token)
+        except (TypeError, ValueError):
+            continue
+        if not _is_nan(number) and math.isfinite(number):
+            result.append(number)
+    return result
+
+
+def _bullet_numeric_control_tokens(value: Any, role: str) -> list[float]:  # 
noqa: C901
+    """Drop non-numeric native tokens like Explore, retaining safety bounds."""
+    value = _safe_enum_backing(value)
+    if value is None or (type(value) is str and value == ""):
+        return []
+    if type(value) is str:
+        if len(value) > _MAX_BULLET_TEXT_BYTES:
+            raise BulletOutputError(f"Bullet {role} exceeds the size limit")
+        tokens: list[Any] = [token.strip() for token in value.split(",")]
+    elif type(value) is list:
+        tokens = [
+            list.__getitem__(value, index) for index in 
range(list.__len__(value))
+        ]
+    else:
+        raise BulletOutputError(f"Bullet {role} must be a comma-separated 
list")
+    if len(tokens) > _MAX_BULLET_TOKENS:
+        raise BulletOutputError(f"Bullet {role} exceeds the item limit")
+
+    numbers: list[float] = []
+    for index, token in enumerate(tokens):
+        token = _safe_enum_backing(token)
+        if type(token) is str and token == "":
+            continue
+        if type(token) is bool or not (
+            type(token) is str
+            or type(token) is int
+            or type(token) is float
+            or type(token) is Decimal
+        ):
+            raise BulletOutputError(f"Bullet {role}[{index}] is not numeric")
+        if type(token) is str and len(token) > _MAX_BULLET_TEXT_BYTES:
+            raise BulletOutputError(f"Bullet {role}[{index}] is not numeric")
+        try:
+            number = float(token)
+        except ValueError:
+            # Native controls tolerate stray text and incomplete input.
+            continue
+        except (TypeError, OverflowError) as ex:
+            raise BulletOutputError(f"Bullet {role}[{index}] is not numeric") 
from ex
+        if math.isnan(number):
+            continue
+        if not math.isfinite(number):
+            raise BulletOutputError(f"Bullet {role}[{index}] is NaN or 
infinite")
+        numbers.append(number)
+    return numbers
+
+
+def _generate_bullet_vega_lite_preview(  # noqa: C901
+    data: List[Dict[str, Any]], form_data: Dict[str, Any]
+) -> VegaLitePreview:
+    """Build a horizontal layered preview from the shared strict model."""
+    model = resolve_bullet_render_model(data, form_data)
+
+    category_field = _unique_bullet_category_field(model.rows)
+    containing_range_labels = (
+        [
+            _containing_bullet_range_label(measure, model.ranges, 
model.range_labels)
+            for measure in model.measures
+        ]
+        if model.range_labels
+        else [None] * len(model.rows)
+    )
+    range_tooltip_field = (
+        _unique_bullet_derived_field(
+            model.rows, "__mcp_bullet_range", (category_field,)
+        )
+        if any(label is not None for label in containing_range_labels)
+        else None
+    )
+    values = []
+    for row_index, row in enumerate(model.rows):
+        copied = dict.copy(row)
+        copied[category_field] = (

Review Comment:
   The derived category text is also the nominal y-axis key, so distinct 
grouped rows such as `Region = NULL` and `Region = "null"` both become `"null"` 
and their bars overlap in the Vega preview, while Explore keeps separate 
indexed rows. Could the row identity be kept separate from the display label?



##########
superset/mcp_service/chart/preview_utils.py:
##########
@@ -496,6 +1090,342 @@ def _is_nan(value: Any) -> bool:
         return False
 
 
+def _bullet_numeric_tokens(value: Any) -> list[float]:
+    """Parse native comma-separated Bullet threshold controls."""
+    if isinstance(value, str):
+        tokens: list[Any] = [token.strip() for token in value.split(",")]
+    elif isinstance(value, list):
+        tokens = value
+    else:
+        return []
+    result: list[float] = []
+    for token in tokens:
+        try:
+            number = float(token)
+        except (TypeError, ValueError):
+            continue
+        if not _is_nan(number) and math.isfinite(number):
+            result.append(number)
+    return result
+
+
+def _bullet_numeric_control_tokens(value: Any, role: str) -> list[float]:  # 
noqa: C901
+    """Drop non-numeric native tokens like Explore, retaining safety bounds."""
+    value = _safe_enum_backing(value)
+    if value is None or (type(value) is str and value == ""):
+        return []
+    if type(value) is str:
+        if len(value) > _MAX_BULLET_TEXT_BYTES:
+            raise BulletOutputError(f"Bullet {role} exceeds the size limit")
+        tokens: list[Any] = [token.strip() for token in value.split(",")]
+    elif type(value) is list:
+        tokens = [
+            list.__getitem__(value, index) for index in 
range(list.__len__(value))
+        ]
+    else:
+        raise BulletOutputError(f"Bullet {role} must be a comma-separated 
list")
+    if len(tokens) > _MAX_BULLET_TOKENS:
+        raise BulletOutputError(f"Bullet {role} exceeds the item limit")
+
+    numbers: list[float] = []
+    for index, token in enumerate(tokens):
+        token = _safe_enum_backing(token)
+        if type(token) is str and token == "":
+            continue
+        if type(token) is bool or not (
+            type(token) is str
+            or type(token) is int
+            or type(token) is float
+            or type(token) is Decimal
+        ):
+            raise BulletOutputError(f"Bullet {role}[{index}] is not numeric")
+        if type(token) is str and len(token) > _MAX_BULLET_TEXT_BYTES:
+            raise BulletOutputError(f"Bullet {role}[{index}] is not numeric")
+        try:
+            number = float(token)
+        except ValueError:
+            # Native controls tolerate stray text and incomplete input.
+            continue
+        except (TypeError, OverflowError) as ex:
+            raise BulletOutputError(f"Bullet {role}[{index}] is not numeric") 
from ex
+        if math.isnan(number):
+            continue
+        if not math.isfinite(number):
+            raise BulletOutputError(f"Bullet {role}[{index}] is NaN or 
infinite")
+        numbers.append(number)
+    return numbers
+
+
+def _generate_bullet_vega_lite_preview(  # noqa: C901
+    data: List[Dict[str, Any]], form_data: Dict[str, Any]
+) -> VegaLitePreview:
+    """Build a horizontal layered preview from the shared strict model."""
+    model = resolve_bullet_render_model(data, form_data)
+
+    category_field = _unique_bullet_category_field(model.rows)
+    containing_range_labels = (
+        [
+            _containing_bullet_range_label(measure, model.ranges, 
model.range_labels)
+            for measure in model.measures
+        ]
+        if model.range_labels
+        else [None] * len(model.rows)
+    )
+    range_tooltip_field = (
+        _unique_bullet_derived_field(
+            model.rows, "__mcp_bullet_range", (category_field,)
+        )
+        if any(label is not None for label in containing_range_labels)
+        else None
+    )
+    values = []
+    for row_index, row in enumerate(model.rows):
+        copied = dict.copy(row)
+        copied[category_field] = (
+            ", ".join(
+                _bullet_category_value(dict.get(row, field), field, 
row_index)[1]
+                for field in model.dimensions
+            )
+            if model.dimensions
+            else ""
+        )
+        if (
+            range_tooltip_field is not None
+            and containing_range_labels[row_index] is not None
+        ):
+            copied[range_tooltip_field] = containing_range_labels[row_index]
+        values.append(copied)
+
+    y_encoding = {
+        "field": category_field,
+        "type": "nominal",
+        "title": ", ".join(model.dimensions) if model.dimensions else None,
+        "sort": None,
+    }
+    tooltip = [
+        {
+            "field": category_field,
+            "type": "nominal",
+            "title": ", ".join(model.dimensions) if model.dimensions else None,
+        },
+        {
+            "field": model.metric_field,
+            "type": "quantitative",
+            "format": (
+                "~s" if model.y_axis_format == "SMART_NUMBER" else 
model.y_axis_format
+            ),
+        },
+    ]
+    if range_tooltip_field is not None:
+        tooltip.append(
+            {"field": range_tooltip_field, "type": "nominal", "title": "Range"}
+        )
+    axis_min = min(
+        0.0,
+        *model.measures,
+        *model.ranges,
+        *model.markers,
+        *model.marker_lines,
+    )
+    axis_max = max(
+        *model.measures,
+        *model.ranges,
+        *model.markers,
+        *model.marker_lines,
+    )
+    if axis_min == axis_max:
+        axis_max = axis_min + (abs(axis_min) or 1)
+    vega_format = "~s" if model.y_axis_format == "SMART_NUMBER" else 
model.y_axis_format

Review Comment:
   `SMART_NUMBER_SIGNED` passes the preview format validator, but this forwards 
it unchanged as a Vega/D3 axis and tooltip format, where it is an invalid 
format specifier. A saved Bullet using that supported Explore format therefore 
produces a preview that cannot render; could both format projections translate 
the signed pseudo-format too?



##########
superset/mcp_service/chart/tool/update_chart_preview.py:
##########
@@ -243,62 +294,75 @@ def update_chart_preview(  # noqa: C901
             if previous_form_data:
                 merge_table_column_config(previous_form_data, new_form_data)
                 merge_interactive_pivot_ui_config(previous_form_data, 
new_form_data)
-                new_form_data = merge_chart_form_data(
-                    previous_form_data,
-                    new_form_data,
-                    config,
-                    dataset_rebind=dataset_rebind,
-                )
+                merge_plugin = 
plugin_for_viz_type(new_form_data.get("viz_type"))
+                if merge_plugin is not None and merge_plugin.owns_update_merge:
+                    # The plugin owns its temporal/rebind merge contract.
+                    # Generic merges would restore deliberately removed state.
+                    new_form_data = merge_chart_form_data(
+                        previous_form_data,
+                        new_form_data,
+                        config,
+                        dataset_rebind=dataset_rebind,
+                    )
+                else:
+                    new_form_data = merge_chart_form_data(
+                        previous_form_data,
+                        new_form_data,
+                        config,
+                        dataset_rebind=dataset_rebind,
+                    )
+                    merge_update_form_data(previous_form_data, new_form_data, 
config)
+                    merge_same_viz_form_data(previous_form_data, 
new_form_data, config)

Review Comment:
   On a cached-preview dataset change, `merge_chart_form_data` first strips the 
old query state, but this generic preservation pass copies missing keys from 
the previous dataset back in, including its `datasource` and omitted grouping 
columns. The returned `form_data` can name the old dataset while the Explore 
link uses the new one, or validation fails on an old grouping column the caller 
never supplied. Could dataset rebinds avoid restoring those old 
query/datasource keys?



##########
superset/mcp_service/chart/chart_utils.py:
##########
@@ -1715,6 +1781,668 @@ def map_histogram_config(config: 
"HistogramChartConfig") -> Dict[str, Any]:
     return form_data
 
 
+def _bullet_token_list(values: Sequence[str | int | float]) -> str:
+    """Serialize typed Bullet controls to the frontend's comma-separated 
form."""
+    tokens: list[str] = []
+    for value in values:
+        if isinstance(value, float):
+            token = repr(value)
+            # ``100`` parses back to the same binary float as ``100.0`` and
+            # preserves the frontend's established compact integer spelling.
+            if token.endswith(".0") and not (
+                value == 0.0 and math.copysign(1.0, value) < 0
+            ):
+                token = token[:-2]
+            tokens.append(token)
+        else:
+            tokens.append(str(value))
+    return ",".join(tokens)
+
+
+def map_bullet_config(config: BulletChartConfig) -> Dict[str, Any]:  # noqa: 
C901
+    """Map typed Bullet config to ``Bullet/buildQuery`` and transformProps.
+
+    The frontend buildQuery replaces the generic query fields with exactly one
+    metric and the groupby hierarchy. Presentation controls stay in native
+    snake_case form_data; the chart plugin camelizes them for transformProps.
+    """
+    if config.dimensions is None and config.order_by:
+        # An update resolves its saved hierarchy before mapping. Without one,
+        # creation must validate sort targets against an empty hierarchy.
+        BulletChartConfig.model_validate(
+            {**config.model_dump(exclude_unset=True), "dimensions": []}
+        )
+    metric = create_metric_object(config.metric)
+    form_data: Dict[str, Any] = {
+        "viz_type": "bullet",
+        "metric": metric,
+    }
+
+    # Optional semantic/query fields are emitted only when explicitly supplied.
+    # This lets update_chart and update_chart_preview preserve native saved 
state,
+    # while an explicit empty value still clears it through the generic merge 
path.
+    if "dimensions" in config.model_fields_set:
+        form_data["groupby"] = [dimension.name for dimension in 
config.dimensions or []]
+    if "row_limit" in config.model_fields_set:
+        form_data["row_limit"] = config.row_limit
+    if "time_range" in config.model_fields_set:
+        form_data["time_range"] = config.time_range
+
+    if config.order_by:
+        dimensions = config.dimensions or []
+        orderby: list[list[Any]] = []
+        for order in config.order_by:
+            role, index = resolve_bullet_order_target(
+                order.column, dimensions, config.metric
+            )
+            if role == "metric":
+                order_target: Any = metric
+            else:
+                if index is None:  # Defensive: resolver pairs dimensions with 
indexes.
+                    raise ValueError("Bullet dimension order target has no 
index")
+                order_target = dimensions[index].name
+            orderby.append([order_target, order.ascending])
+        form_data["orderby"] = orderby
+    elif "order_by" in config.model_fields_set:
+        form_data["orderby"] = []
+
+    presentation_fields: dict[str, tuple[str, Any]] = {
+        "ranges": ("ranges", _bullet_token_list(config.ranges)),
+        "range_labels": (
+            "range_labels",
+            _bullet_token_list(config.range_labels),
+        ),
+        "markers": ("markers", _bullet_token_list(config.markers)),
+        "marker_labels": (
+            "marker_labels",
+            _bullet_token_list(config.marker_labels),
+        ),
+        "marker_lines": (
+            "marker_lines",
+            _bullet_token_list(config.marker_lines),
+        ),
+        "marker_line_labels": (
+            "marker_line_labels",
+            _bullet_token_list(config.marker_line_labels),
+        ),
+        "y_axis_format": ("y_axis_format", config.y_axis_format),
+        "show_labels": ("show_labels", config.show_labels),
+        "show_legend": ("show_legend", config.show_legend),
+    }
+    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)
+    if config.filters == [] and "filters" in config.model_fields_set:
+        form_data["adhoc_filters"] = []
+    if config.time_range and config.temporal_column:
+        _ensure_temporal_adhoc_filter(form_data, config.temporal_column)
+        for filter_ in form_data.get("adhoc_filters", []):
+            if (
+                isinstance(filter_, dict)
+                and filter_.get("operator") == 
FilterOperator.TEMPORAL_RANGE.value
+                and filter_.get("subject") == config.temporal_column
+                and filter_.get("comparator") == NO_TIME_RANGE
+            ):
+                filter_["comparator"] = config.time_range
+    return form_data
+
+
+def merge_bullet_form_data(
+    existing_form_data: Mapping[str, Any], new_form_data: Dict[str, Any]
+) -> None:
+    """Preserve omitted native Bullet controls across update tool paths.
+
+    Query roles and every UI control have an explicit typed representation.
+    Mappers emit optional fields only when the caller supplied them, so copying
+    the bounded native keys below preserves omitted state while explicit empty,
+    false, null, and zero-like values remain authoritative.
+    """
+    if (
+        existing_form_data.get("viz_type") != "bullet"
+        or new_form_data.get("viz_type") != "bullet"
+    ):
+        return
+    preserved_keys = {
+        "groupby",
+        "adhoc_filters",
+        "time_range",
+        "row_limit",
+        "orderby",
+        "ranges",
+        "range_labels",
+        "markers",
+        "marker_labels",
+        "marker_lines",
+        "marker_line_labels",
+        "y_axis_format",
+        "show_labels",
+        "show_legend",
+        MCP_DASHBOARD_TIME_FILTER_SUBJECT,
+    }
+
+    # Threshold and label arrays are one frontend control pair. If callers
+    # replace the values without replacing their labels, clear the stale labels
+    # instead of accidentally reassigning them by position.
+    dependent_controls = {
+        "ranges": "range_labels",
+        "markers": "marker_labels",
+        "marker_lines": "marker_line_labels",
+    }
+    for values_key, labels_key in dependent_controls.items():
+        if values_key in new_form_data and labels_key not in new_form_data:
+            new_form_data[labels_key] = ""
+
+    for key in preserved_keys:
+        if (
+            key == MCP_DASHBOARD_TIME_FILTER_SUBJECT
+            and "adhoc_filters" in new_form_data
+        ):
+            # The marker describes a mapper-generated temporal filter. Do not
+            # retain stale provenance when an explicit filter update removed 
it.
+            continue
+        if key in existing_form_data and key not in new_form_data:
+            new_form_data[key] = existing_form_data[key]
+
+
+def _filter_identity(filter_: Any) -> tuple[Any, ...] | None:
+    """Return the native identity used when one filter replaces another."""
+    if not isinstance(filter_, Mapping):
+        return None
+    return (
+        filter_.get("clause"),
+        filter_.get("expressionType"),
+        filter_.get("subject"),
+        filter_.get("operator"),
+    )
+
+
+def _temporal_binding_filter(filters: list[Any], subject: Any) -> dict[str, 
Any] | None:
+    """Find the unique filter owned by a recorded MCP temporal marker."""
+    if subject is None:
+        return None
+    if not isinstance(subject, str) or not subject:
+        raise ValueError(
+            "MCP temporal binding provenance subject must be a non-empty 
string"
+        )
+    matches = [
+        filter_
+        for filter_ in filters
+        if isinstance(filter_, dict)
+        and filter_.get("subject") == subject
+        and filter_.get("operator") == FilterOperator.TEMPORAL_RANGE.value
+    ]
+    if len(matches) != 1:
+        raise ValueError(
+            "MCP temporal binding provenance must match exactly one "
+            f"TEMPORAL_RANGE filter for subject {subject!r}; found 
{len(matches)}"
+        )
+    return matches[0]
+
+
+def _append_or_replace_filter(filters: list[Any], filter_: Any) -> None:
+    """Append a filter, replacing the same native role when identifiable."""
+    identity = _filter_identity(filter_)
+    if identity is None:
+        if filter_ not in filters:
+            filters.append(filter_)
+        return
+    filters[:] = [item for item in filters if _filter_identity(item) != 
identity]
+    filters.append(filter_)
+
+
+_NATIVE_TEMPORAL_ROLE_FIELDS: dict[str, frozenset[str]] = {
+    # Typed ``x`` is persisted as native x_axis/granularity_sqla for XY and
+    # Mixed Timeseries. Waterfall exposes the typed field as ``x_axis``.
+    "x_axis": frozenset({"x", "x_axis"}),
+    "granularity_sqla": frozenset({"x", "x_axis", "temporal_column"}),
+    # Chart plugins may designate a chart-specific query role as the implicit
+    # dashboard-time subject.
+    "start_time": frozenset({"start_time"}),
+}
+
+
+def _native_temporal_subject_changed(
+    existing_form_data: Mapping[str, Any],
+    new_form_data: Mapping[str, Any],
+    explicit_fields: set[str],
+) -> bool:
+    """Return whether an authoritative native temporal role was replaced.
+
+    Mapping a partial update can propose a dataset fallback binding even when
+    the caller only changed filters. That proposal is not authoritative. A
+    changed x/granularity/chart-specific role is authoritative only when its
+    corresponding typed field was actually supplied.
+    """
+    for native_key, typed_fields in _NATIVE_TEMPORAL_ROLE_FIELDS.items():
+        if explicit_fields.isdisjoint(typed_fields):
+            continue
+        existing_value = existing_form_data.get(native_key)
+        incoming_value = new_form_data.get(native_key)
+        if existing_value != incoming_value:
+            return True
+    return False
+
+
+def _native_temporal_binding(
+    form_data: Mapping[str, Any], filters: list[Any]
+) -> tuple[str | None, dict[str, Any] | None]:
+    """Resolve one binding for a trusted native temporal role, if present."""
+    for native_key in _NATIVE_TEMPORAL_ROLE_FIELDS:
+        subject = form_data.get(native_key)
+        if not isinstance(subject, str) or not subject:
+            continue
+        matches = [
+            filter_
+            for filter_ in filters
+            if isinstance(filter_, dict)
+            and filter_.get("subject") == subject
+            and filter_.get("operator") == FilterOperator.TEMPORAL_RANGE.value
+        ]
+        if len(matches) > 1:
+            raise ValueError(
+                "An authoritative native temporal subject must match at most 
one "
+                f"TEMPORAL_RANGE filter for subject {subject!r}; found "
+                f"{len(matches)}"
+            )
+        if matches:
+            return subject, matches[0]
+    return None, None
+
+
+def merge_update_form_data(  # noqa: C901
+    existing_form_data: Mapping[str, Any],
+    new_form_data: Dict[str, Any],
+    config: ChartConfig,
+) -> None:
+    """Apply the shared omission/provenance contract for chart updates.
+
+    Mapper-generated neutral temporal bindings are infrastructure, not evidence
+    that the caller supplied ``filters`` or changed a saved time-range binding.
+    This helper is used by immediate saves, preview-first saved updates, and
+    cached-preview updates so omission, clear, replacement, and temporal
+    overrides have identical behavior.
+    """
+    existing_filters = list(existing_form_data.get("adhoc_filters") or [])
+    incoming_filters = list(new_form_data.get("adhoc_filters") or [])
+    existing_subject = 
existing_form_data.get(MCP_DASHBOARD_TIME_FILTER_SUBJECT)
+    incoming_subject = new_form_data.get(MCP_DASHBOARD_TIME_FILTER_SUBJECT)
+    existing_binding = _temporal_binding_filter(existing_filters, 
existing_subject)

Review Comment:
   A saved chart rebind to a dataset without its old time column removes the 
inherited temporal filter in `_build_replacement_form_data`, but leaves 
`_mcp_dashboard_time_filter_subject` behind. This lookup then raises `found 0`, 
so an otherwise valid Table/Bullet config and dataset replacement fails before 
saving. Could the provenance marker be discarded alongside the invalid 
inherited filter during a rebind?



##########
superset/mcp_service/chart/plugins/bullet.py:
##########
@@ -0,0 +1,445 @@
+# 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 Bullet chart type plugin."""
+
+from __future__ import annotations
+
+from collections.abc import Callable, Mapping
+from typing import Any, ClassVar
+
+from superset.mcp_service.chart.chart_utils import (
+    _summarize_filters,
+    map_bullet_config,
+)
+from superset.mcp_service.chart.plugin import BaseChartPlugin
+from superset.mcp_service.chart.schemas import (
+    BulletChartConfig,
+    ChartError,
+    ColumnRef,
+    resolve_bullet_order_target,
+    VegaLitePreview,
+)
+from superset.mcp_service.chart.validation.dataset_validator import (
+    AmbiguousDatasetReferenceError,
+    DatasetValidator,
+    is_numeric_column,
+    resolve_dataset_column,
+)
+from superset.mcp_service.common.error_schemas import (
+    ChartGenerationError,
+    DatasetContext,
+)
+
+
+def _canonical_reference(
+    name: str,
+    candidates: list[str],
+    role: str,
+) -> str:
+    """Resolve exact/casefold matches without silently choosing ambiguity."""
+    if name in candidates:
+        return name
+    matches = [
+        candidate for candidate in candidates if candidate.casefold() == 
name.casefold()
+    ]
+    if len(matches) > 1:
+        # AmbiguousDatasetReferenceError subclasses ValueError, so existing
+        # ValueError handlers keep working, while the validation pipeline
+        # re-raises it instead of downgrading it to a warning and proceeding
+        # with the unresolved reference.
+        raise AmbiguousDatasetReferenceError(name, matches, f"Bullet {role}")
+    return matches[0] if matches else name
+
+
+def _render_model_error(rows: Any, form_data: Mapping[str, Any]) -> ChartError 
| None:
+    """Return why ``rows`` cannot build the strict Bullet render model."""
+    from superset.mcp_service.chart.preview_utils import (
+        BulletOutputError,
+        resolve_bullet_render_model,
+    )
+    from superset.mcp_service.chart.query_result import safe_exception_message
+
+    try:
+        resolve_bullet_render_model(rows, dict(form_data))
+    except BulletOutputError as ex:
+        return ChartError(error=safe_exception_message(ex), 
error_type=ex.error_type)
+    return None
+
+
+class BulletChartPlugin(BaseChartPlugin):
+    """Plugin matching ``plugin-chart-echarts/src/Bullet``."""
+
+    chart_type = "bullet"
+    display_name = "Bullet Chart"
+    native_viz_types: ClassVar[Mapping[str, str]] = {
+        "bullet": "Bullet Chart",
+    }
+    # Bullet/transformProps.ts converts temporal values with Number().
+    temporal_json_numbers = True
+    # The frontend renders an empty result (a zero measure when ungrouped).
+    allows_empty_result = True
+    allows_empty_data_result = True
+    # Updates merge filter provenance plus Bullet's bounded native controls;
+    # no other saved control is carried into the typed Bullet state.
+    owns_update_merge = True
+    binds_time_range_to_temporal_filter = True
+    table_preview_unsupported_reason: ClassVar[str | None] = (
+        "Table previews cannot represent Bullet ranges, markers, "
+        "labels, and legend semantics"
+    )
+    invalid_result_error_code = "MALFORMED_BULLET_OUTPUT"
+    invalid_result_message = (
+        "Bullet query output does not contain a usable sizing measure."
+    )
+
+    def pre_validate(self, config: dict[str, Any]) -> ChartGenerationError | 
None:
+        if "metric" in config:
+            return None
+        return ChartGenerationError(
+            error_type="missing_bullet_fields",
+            message="Bullet chart missing required field: metric",
+            details=(
+                "A Bullet chart measures one numeric aggregate or saved/SQL 
metric; "
+                "optional dimensions split it into one row per group."
+            ),
+            suggestions=[
+                "Add metric: {'name': 'revenue', 'aggregate': 'SUM'}",
+                "For a saved metric use {'name': 'revenue', 'saved_metric': 
true}",
+                "Add dimensions: [{'name': 'region'}] for grouped bullet rows",
+            ],
+            error_code="MISSING_BULLET_FIELDS",
+        )
+
+    def extract_column_refs(self, config: Any) -> list[ColumnRef]:
+        if not isinstance(config, BulletChartConfig):
+            return []
+        refs = [config.metric, *(config.dimensions or [])]
+        refs.extend(ColumnRef(name=filter_.column) for filter_ in 
config.filters or [])
+        # order_by is constrained to role outputs by the schema, so those names
+        # are already represented by metric/dimension refs and must not be
+        # reinterpreted as physical columns.
+        return refs
+
+    def to_form_data(
+        self, config: Any, dataset_id: int | str | None = None
+    ) -> dict[str, Any]:
+        if not isinstance(config, BulletChartConfig):
+            raise TypeError("BulletChartPlugin requires BulletChartConfig")
+        return map_bullet_config(config)
+
+    def post_map_validate(  # noqa: C901
+        self,
+        config: Any,
+        form_data: dict[str, Any],
+        dataset_id: int | str | None = None,
+    ) -> ChartGenerationError | None:
+        """Require an unambiguous numeric metric output for Number(...)."""
+        if not isinstance(config, BulletChartConfig) or dataset_id is None:
+            return None
+        dataset_context = DatasetValidator._get_dataset_context(dataset_id)
+        if dataset_context is None:
+            return None
+
+        columns = [column["name"] for column in 
dataset_context.available_columns]
+        metrics = [metric["name"] for metric in 
dataset_context.available_metrics]
+        requested: list[tuple[str, list[str], str]] = []
+        if config.metric.name and not config.metric.sql_expression:
+            requested.append(
+                (
+                    config.metric.name,
+                    metrics if config.metric.saved_metric else columns,
+                    "saved metric" if config.metric.saved_metric else "metric 
column",
+                )
+            )
+        requested.extend(
+            (dimension.name or "", columns, "dimension")
+            for dimension in config.dimensions or []
+            if dimension.name
+        )
+        requested.extend(
+            (filter_.column, columns, "filter column")
+            for filter_ in config.filters or []
+        )
+        if config.temporal_column:
+            requested.append((config.temporal_column, columns, "temporal 
column"))
+
+        for name, candidates, role in requested:
+            if (
+                name not in candidates
+                and sum(
+                    candidate.casefold() == name.casefold() for candidate in 
candidates
+                )
+                > 1
+            ):
+                return ChartGenerationError(
+                    error_type="ambiguous_bullet_reference",
+                    message=(
+                        f"Bullet {role} {name!r} is ambiguous in dataset 
metadata"
+                    ),
+                    details=(
+                        "Multiple dataset fields differ only by case. The 
query and "
+                        "frontend require an exact canonical field name."
+                    ),
+                    suggestions=[
+                        "Use get_dataset_info and copy the exact-case field 
name"
+                    ],
+                    error_code="AMBIGUOUS_BULLET_REFERENCE",
+                )
+
+        metric = config.metric
+        if metric.saved_metric or metric.sql_expression:
+            # Saved/SQL metric result types are determined by their 
expressions;
+            # Tier-2 compile validation remains authoritative.
+            return None
+        if (metric.aggregate or "SUM") in {"COUNT", "COUNT_DISTINCT"}:
+            return None
+        try:
+            column = resolve_dataset_column(metric.name or "", dataset_context)
+        except ValueError as ex:
+            return ChartGenerationError(
+                error_type="ambiguous_bullet_reference",
+                message=(
+                    f"Bullet metric column {metric.name!r} is ambiguous in "
+                    "dataset metadata"
+                ),
+                details=str(ex),
+                suggestions=["Use get_dataset_info and copy the exact-case 
field name"],
+                error_code="AMBIGUOUS_BULLET_REFERENCE",
+            )
+        if column is None or is_numeric_column(column):
+            return None
+        return ChartGenerationError(
+            error_type="non_numeric_bullet_metric",
+            message=(
+                f"Bullet metric {metric.name!r} must produce a number; dataset 
"
+                f"type is {column.get('type', 'UNKNOWN')}."
+            ),
+            details=(
+                "Bullet/transformProps.ts converts the metric result with 
Number(). "
+                "A non-numeric MIN/MAX or default SUM would render an invalid 
bar."
+            ),
+            suggestions=[
+                "Use COUNT or COUNT_DISTINCT for a text column",
+                "Choose a numeric dataset column",
+                "Use a saved or SQL metric that returns a numeric value",
+            ],
+            error_code="NON_NUMERIC_BULLET_METRIC",
+        )
+
+    def normalize_column_refs(
+        self, config: Any, dataset_context: DatasetContext
+    ) -> Any:
+        if not isinstance(config, BulletChartConfig):
+            return config
+        explicit_fields = set(config.model_fields_set)
+        config_dict = config.model_dump(exclude_unset=True)
+        columns = [column["name"] for column in 
dataset_context.available_columns]
+        metrics = [metric["name"] for metric in 
dataset_context.available_metrics]
+
+        metric = config_dict["metric"]
+        if not metric.get("sql_expression"):
+            metric["name"] = _canonical_reference(
+                metric["name"],
+                metrics if metric.get("saved_metric") else columns,
+                "saved metric" if metric.get("saved_metric") else "metric 
column",
+            )
+        for dimension in config_dict.get("dimensions") or []:
+            dimension["name"] = _canonical_reference(
+                dimension["name"], columns, "dimension"
+            )
+        if temporal := config_dict.get("temporal_column"):
+            config_dict["temporal_column"] = _canonical_reference(
+                temporal, columns, "temporal column"
+            )
+        for filter_ in config_dict.get("filters") or []:
+            filter_["column"] = _canonical_reference(
+                filter_["column"], columns, "filter column"
+            )
+
+        # Sort targets may use ergonomic role names or labels. Canonicalize
+        # physical-name targets and leave explicit display labels untouched.
+        for order in config_dict.get("order_by") or []:
+            role, index = resolve_bullet_order_target(
+                order["column"], config.dimensions or [], config.metric
+            )
+            if role == "dimension" and index is not None:
+                order["column"] = config_dict["dimensions"][index]["name"]
+            elif not metric.get("sql_expression") and not metric.get("label"):
+                order["column"] = metric["name"]
+
+        normalized = BulletChartConfig.model_validate(config_dict)
+        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:
+        metric = config.metric.label or config.metric.name or "Metric"
+        what = f"{metric} bullet"
+        if config.dimensions:
+            what += " by " + ", ".join(
+                dimension.label or dimension.name or "dimension"
+                for dimension in config.dimensions
+            )
+        return self._with_context(what, _summarize_filters(config.filters))
+
+    def resolve_viz_type(self, config: Any) -> str:
+        return "bullet"
+
+    def schema_error_hint(self) -> ChartGenerationError | None:
+        return ChartGenerationError(
+            error_type="bullet_validation_error",
+            message="Bullet chart configuration validation failed",
+            details=(
+                "Bullet requires one numeric metric and optional unique 
physical "
+                "dimensions. Threshold/marker labels are optional; missing 
marker "
+                "labels fall back to formatted values."
+            ),
+            suggestions=[
+                "Use metric with aggregate, saved_metric, or sql_expression + 
label",
+                "Use dimensions (alias: groupby) for row hierarchy",
+                "Use ranges, markers, and marker_lines for comparison targets",
+            ],
+            error_code="BULLET_VALIDATION_ERROR",
+        )
+
+    def normalize_query_result(self, result: Any, form_data: Mapping[str, 
Any]) -> Any:
+        """Reject results that cannot size a Bullet chart without guessing."""
+        from superset.mcp_service.chart.query_result import query_result_data
+
+        data, failure = query_result_data(result, temporal_json_numbers=True)
+        if failure is not None:
+            return failure
+        rows = data[0] if data else []
+        if (error := _render_model_error(rows, form_data)) is not None:
+            return error
+        return result
+
+    def sanitize_data_rows(
+        self, data: list[Any], form_data: Mapping[str, Any]
+    ) -> tuple[list[Any], ChartError | None]:
+        """Expose rows through the same strict model the renderers use."""
+        from superset.mcp_service.chart.preview_utils import (
+            BulletOutputError,
+            resolve_bullet_render_model,
+        )
+        from superset.mcp_service.chart.query_result import 
safe_exception_message
+
+        try:
+            model = resolve_bullet_render_model(data, dict(form_data))
+        except BulletOutputError as ex:
+            return [], ChartError(
+                error=safe_exception_message(ex), error_type=ex.error_type
+            )
+        # The strict model retains exact result keys while replacing unselected
+        # values with None and normalizing the selected roles. Its zero-valued
+        # ungrouped row is a render-only frontend fallback, not source query
+        # data, so an empty query exposes no rows to get-data and exports.
+        return (model.rows if data else []), None
+
+    def ascii_preview(
+        self, data: list[Any], form_data: dict[str, Any], width: int
+    ) -> str | ChartError | None:
+        from superset.mcp_service.chart.preview_utils import (
+            _generate_ascii_bullet_chart,
+            BulletOutputError,
+        )
+        from superset.mcp_service.chart.query_result import 
safe_exception_message
+
+        try:
+            return _generate_ascii_bullet_chart(data, form_data)
+        except BulletOutputError as ex:
+            return ChartError(
+                error=safe_exception_message(ex), error_type=ex.error_type
+            )
+
+    def vega_lite_preview(
+        self, data: list[Any], form_data: dict[str, Any]
+    ) -> VegaLitePreview | ChartError | None:
+        from superset.mcp_service.chart.preview_utils import (
+            _generate_bullet_vega_lite_preview,
+            BulletOutputError,
+        )
+        from superset.mcp_service.chart.query_result import 
safe_exception_message
+
+        try:
+            return _generate_bullet_vega_lite_preview(data, form_data)
+        except BulletOutputError as ex:
+            return ChartError(
+                error=safe_exception_message(ex), error_type=ex.error_type
+            )
+
+    def resolve_update_config(
+        self,
+        config: Any,
+        existing_form_data: dict[str, Any],
+        *,
+        dataset_rebind: bool,
+    ) -> Any:
+        """Resolve sort targets against the saved hierarchy before mapping."""
+        if not isinstance(config, BulletChartConfig) or config.dimensions is 
not None:
+            return config
+        if not config.order_by:
+            return config
+        dimensions = (
+            existing_form_data.get("groupby") or []
+            if not dataset_rebind and existing_form_data.get("viz_type") == 
"bullet"
+            else []
+        )
+        return BulletChartConfig.model_validate(
+            {**config.model_dump(exclude_unset=True), "dimensions": dimensions}
+        )
+
+    def merge_update_form_data(
+        self,
+        existing_form_data: dict[str, Any],
+        new_form_data: dict[str, Any],
+        config: Any,
+        *,
+        dataset_rebind: bool,
+    ) -> dict[str, Any] | None:
+        """Merge filter provenance and preserve omitted native Bullet 
controls."""
+        if not isinstance(config, BulletChartConfig) or dataset_rebind:
+            return None

Review Comment:
   A cached-preview update that switches datasets drops the saved Bullet 
hierarchy here when `dimensions` is omitted, even if the new dataset has the 
same grouping columns; the saved-chart update path instead checks those 
references and preserves them. The same metric/dataset update can therefore 
turn a grouped Bullet into one aggregate only in the preview flow. Could 
compatible omitted dimensions follow the same preservation contract on both 
paths?



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to