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


##########
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:
   Fixed in 475698ba5a831ad8af0d6d8f427449afe8abe81e by separating the indexed 
row key from the category display text. Regression tests cover null/string, 
boolean/string, and numeric/string label collisions.



##########
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:
   Fixed in 475698ba5a831ad8af0d6d8f427449afe8abe81e by translating 
SMART_NUMBER_SIGNED to +~s for both the Vega axis and tooltip. The regression 
test checks both format fields.



##########
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:
   Fixed in 475698ba5a831ad8af0d6d8f427449afe8abe81e by checking inherited 
query references against the replacement dataset before the cached-preview 
merge. Compatible omitted Bullet dimensions and dimension-based sorting are 
preserved, matching saved updates.



-- 
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