aminghadersohi commented on code in PR #43770:
URL: https://github.com/apache/superset/pull/43770#discussion_r4175282560
##########
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]
Review Comment:
Fixed in b92dc4189fbb416c0bf2d714524a216c787c2782. Inherited Bullet sorts
are pruned after the merge against final exact output roles, including native
adhoc metric sorts. Replacing SavedRevenue removes its obsolete sort, and
removing Region keeps only the exact region sort. The update regressions no
longer mock normalization or _validate_update_against_dataset: real
merged-state and dataset validation run; only live SQL execution is substituted.
##########
superset/mcp_service/chart/compile.py:
##########
@@ -282,6 +329,427 @@ def _validate_adhoc_filter_columns(
)
+def _native_validation_error(role: str, reference: str) ->
ChartGenerationError:
+ """Build a fail-closed error for an incompatible native chart reference."""
+ return ChartGenerationError(
+ error_type="invalid_native_chart_reference",
+ message=f"Native chart {role} {reference!r} is incompatible with the
dataset",
+ details=(
+ "The rebound form data must retain its exact query roles on the
target "
+ "dataset; no column or saved-metric reference may be guessed or
dropped."
+ ),
+ suggestions=[
+ "Choose a target dataset with a compatible schema",
+ "Provide a complete typed chart config using target-dataset
fields",
+ ],
+ error_code="CHART_VALIDATION_FAILED",
+ )
+
+
+def _native_column_name(value: Any) -> str | None:
+ """Extract a physical QueryFormColumn reference, or None for SQL
columns."""
+ if isinstance(value, str):
+ return value
+ if not isinstance(value, dict):
+ return None
+ if value.get("expressionType") == "SQL":
+ reference = value.get("sqlExpression")
+ if value.get("isColumnReference") is True and isinstance(reference,
str):
+ return reference or None
+ return None
+ name = value.get("column_name") or value.get("columnName")
+ return name if isinstance(name, str) and name else None
+
+
+def _native_column_label(value: Any) -> str | None:
+ """Return the frontend label for a native column without custom hooks."""
+ if isinstance(value, str):
+ return value
+ if not isinstance(value, dict):
+ return None
+ for key in ("label", "sqlExpression", "column_name", "columnName"):
+ candidate = value.get(key)
+ if isinstance(candidate, str) and candidate:
+ return candidate
+ return None
+
+
+def _native_metric_ref(value: Any) -> tuple[str, str] | None:
+ """Return ``(saved_metric|column, name)`` for a native query metric."""
+ if isinstance(value, str):
+ return "saved_metric", value
+ if not isinstance(value, dict):
+ return None
+ if value.get("expressionType") == "SQL":
+ return None
+ if value.get("expressionType") != "SIMPLE":
+ return None
+ column = value.get("column")
+ name = (
+ column.get("column_name") or column.get("columnName")
+ if isinstance(column, dict)
+ else None
+ )
+ return ("column", name) if isinstance(name, str) and name else None
+
+
+def _native_reference_error( # noqa: C901
+ form_data: Dict[str, Any],
+ dataset_context: DatasetContext,
+ dataset_id: int,
+ *,
+ strict_all_form_refs: bool,
+) -> ChartGenerationError | None:
+ """Validate the canonical native QueryObjects against a rebound dataset."""
+ from superset.mcp_service.chart.chart_helpers import (
+ build_query_dicts_from_form_data,
+ )
+
+ try:
+ queries = build_query_dicts_from_form_data(
+ deepcopy(form_data), dataset_id, "table"
+ )
+ except (KeyError, TypeError, ValueError) as ex:
+ return _native_validation_error("query contract",
safe_exception_message(ex))
+
+ saved_metrics = [item["name"] for item in
dataset_context.available_metrics]
+
+ def column_error(value: Any, role: str) -> ChartGenerationError | None:
+ name = _native_column_name(value)
+ if name is None:
+ if isinstance(value, dict) and value.get("expressionType") ==
"SQL":
+ return None
+ return _native_validation_error(role, repr(value)[:200])
+ try:
+ if resolve_dataset_column(name, dataset_context) is not None:
+ return None
+ except ValueError:
+ pass
+ return _native_validation_error(role, name)
+
+ def metric_error(value: Any, role: str) -> ChartGenerationError | None:
+ """Validate one raw or generated metric reference against the
target."""
+ ref = _native_metric_ref(value)
+ if ref is None:
+ if isinstance(value, dict) and value.get("expressionType") ==
"SQL":
+ return None
+ return _native_validation_error(role, repr(value)[:200])
+ kind, name = ref
+ if kind == "saved_metric":
+ matches = [
+ item
+ for item in saved_metrics
+ if item == name or item.casefold() == name.casefold()
+ ]
+ if len(set(matches)) != 1:
Review Comment:
Fixed in b92dc4189fbb416c0bf2d714524a216c787c2782. The native HAVING branch
prefers exact saved-metric names before case-folded fallback. Regressions
validate both exact Revenue and revenue with both metrics present, using the
real native reference validator.
##########
superset/mcp_service/chart/schemas.py:
##########
@@ -2538,6 +2539,89 @@ def _metric_display_label(col: ColumnRef) -> str:
return col.label or col.name or ""
+def _require_unique_bullet_order_match(
+ matches: list[tuple[str, int | None]], target: str, role: str
+) -> tuple[str, int | None] | None:
+ """Return a unique sort-role match or reject ambiguity."""
+ if len(matches) == 1:
+ return matches[0]
+ if len(matches) > 1:
+ raise ValueError(f"ambiguous {role}: {target}")
+ return None
+
+
+def _bullet_metric_output_label(metric: ColumnRef) -> str:
+ """Return the field name emitted by Bullet's native metric shape."""
+ if metric.saved_metric:
+ # Saved metrics are serialized as a bare metric-name string; a
+ # ColumnRef display label does not change the query output alias.
+ return metric.name or ""
+ return _metric_display_label(metric)
Review Comment:
Fixed in b92dc4189fbb416c0bf2d714524a216c787c2782. Bullet output-role
validation and create_metric_object share aggregate normalization: omitted
aggregates emit SUM, and STDDEV/VAR emit STDDEV_SAMP/VAR_SAMP. The regression
groups by Revenue while sorting the actual aggregate output.
##########
superset/mcp_service/chart/plugins/bullet.py:
##########
@@ -0,0 +1,459 @@
+# 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 (
+ _safe_enum_backing,
+ 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
+ )
+ if not data:
+ # The strict model's 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 [], None
+ # The strict model retains exact result keys while replacing unselected
+ # values with None and normalizing the dimensions. Its float measure is
+ # render-only: exported rows keep the validated source metric so exact
+ # BIGINT/Decimal values are not rounded to binary64.
+ rows: list[Any] = []
+ for source, row in zip(data, model.rows, strict=False):
+ exposed = dict(row)
+ if model.metric_field in source:
+ exposed[model.metric_field] = _safe_enum_backing(
+ source[model.metric_field]
+ )
+ rows.append(exposed)
+ return rows, 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
+ from superset.mcp_service.chart.chart_utils import (
+ merge_bullet_form_data,
+ merge_update_form_data,
+ )
+
+ merged = dict(new_form_data)
+ merge_update_form_data(existing_form_data, merged, config)
+ merge_bullet_form_data(existing_form_data, merged)
Review Comment:
Fixed in b92dc4189fbb416c0bf2d714524a216c787c2782. The Bullet same-dataset
merge preserves omitted url_params. Save and preview regressions retain the
saved EU template input across a presentation-only change; dataset rebinds
still use their separate merge contract.
##########
superset/mcp_service/chart/preview_utils.py:
##########
@@ -496,6 +1091,350 @@ 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)
+ row_field = _unique_bullet_derived_field(
+ model.rows, "__mcp_bullet_row", (category_field,)
+ )
+ 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, row_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[row_field] = row_index
+ 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)
+
+ # Explore uses indexed rows even when their display labels are identical.
+ category_labels = [row[category_field] for row in values]
+ vega_format = {
+ "SMART_NUMBER": "~s",
+ "SMART_NUMBER_SIGNED": "+~s",
+ }.get(model.y_axis_format, model.y_axis_format)
+ y_encoding = {
+ "field": row_field,
+ "type": "nominal",
+ "title": ", ".join(model.dimensions) if model.dimensions else None,
+ "sort": None,
+ "axis": {"labelExpr": f"{json.dumps(category_labels)}[datum.value]"},
+ }
+ tooltip = [
+ {
+ "field": category_field,
+ "type": "nominal",
+ "title": ", ".join(model.dimensions) if model.dimensions else None,
+ },
+ {
+ "field": model.metric_field,
+ "type": "quantitative",
+ "format": vega_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)
+
+ def label_at(labels: list[str], index: int, value: float, prefix: str) ->
str:
+ if index < len(labels) and labels[index]:
+ return labels[index]
+ return (
+ ""
+ if prefix == "Range"
+ else _format_bullet_number(model.y_axis_format, value)
+ )
+
+ def legend_color(name: str) -> dict[str, Any]:
+ return {
+ "datum": name,
+ "type": "nominal",
+ "legend": {"title": None} if model.show_legend else None,
+ }
+
+ layers: list[dict[str, Any]] = []
+ range_entries = sorted(
+ [
+ (
+ threshold,
+ label_at(model.range_labels, index, threshold, "Range"),
+ )
+ for index, threshold in enumerate(model.ranges)
+ ],
+ key=lambda entry: entry[0],
+ reverse=True,
+ )
+ for index, (threshold, label) in enumerate(range_entries):
+ layers.append(
+ {
+ "mark": {
+ "type": "rect",
+ "opacity": max(0.08, 0.28 - index * 0.04),
+ },
+ "encoding": {
+ "x": {"datum": axis_min, "type": "quantitative"},
+ "x2": {"datum": threshold},
+ "y": y_encoding,
+ "color": legend_color(
+ (f"{label}: " if label else "")
+ + f"≤ {_format_bullet_number(model.y_axis_format,
threshold)}"
+ ),
+ "tooltip": [
+ {"value": label, "title": "Range"},
+ {
+ "value": _format_bullet_number(
+ model.y_axis_format, threshold
+ ),
+ "title": "Threshold",
+ },
+ ],
+ },
+ }
+ )
+ if model.show_labels and label:
+ layers.append(
+ {
+ "mark": {"type": "text", "align": "right", "dx": -3},
+ "encoding": {
+ "x": {"datum": threshold, "type": "quantitative"},
+ "y": y_encoding,
+ "text": {"value": label},
+ },
+ }
+ )
+ layers.append(
+ {
+ "mark": {"type": "bar", "tooltip": True, "size": 16},
+ "encoding": {
+ "x": {
+ "field": model.metric_field,
Review Comment:
Fixed in b92dc4189fbb416c0bf2d714524a216c787c2782. Bullet Vega metric field
references escape dots, brackets, and backslashes while row keys, titles, and
metadata remain literal. Regressions cover both the measure and its tooltip.
##########
superset/mcp_service/chart/chart_helpers.py:
##########
@@ -615,75 +1112,455 @@ def require_column(value: Any, field_name: str) -> Any:
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:
+ raw_tooltips = form_data.get("tooltip_columns") or []
+ raw_metrics = form_data.get("tooltip_metrics") or []
+ if not isinstance(raw_tooltips, list) or len(raw_tooltips) > 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:
+ if not isinstance(raw_metrics, list) or len(raw_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)
+ for index, column in enumerate(raw_tooltips)
]
+ orderby = _parse_orderby(form_data.get("order_by_cols"))
+ columns = _dedupe_query_fields(
+ [
+ start_time,
+ end_time,
+ category,
+ *series_columns,
+ *tooltip_columns,
+ *(item[0] for item in orderby),
+ ],
+ _column_label,
+ )
+ return columns, list(raw_metrics), orderby, series_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
+
+def _table_time_offsets(form_data: dict[str, Any], query: dict[str, Any]) ->
list[Any]:
+ """Resolve the Table plugin's custom/inherit comparison offsets."""
+ if not _time_comparison(form_data, query.get("metrics") or []):
+ return []
+ offsets: list[Any] = []
+ for offset in _as_list(form_data.get("time_compare")):
+ if offset == "custom":
+ offset = form_data.get("start_date_offset")
+ elif offset == "inherit":
+ offset = "inherit"
+ if offset is not None and offset not in offsets:
+ offsets.append(offset)
+ extra = form_data.get("extra_form_data")
+ if isinstance(extra, dict):
+ offset = extra.get("time_compare")
+ if offset is not None and offset not in offsets:
+ offsets = [offset]
+ return offsets
+
+
+def _table_totals_metrics(metrics: list[Any], aggregate: Any) -> list[Any]:
+ """Mirror ``getTotalsMetrics`` for Table summary queries."""
+ if aggregate not in {"SUM", "AVG"}:
+ return metrics
+ result: list[Any] = []
+ for metric in metrics:
+ if isinstance(metric, dict) and metric.get("expressionType") ==
"SIMPLE":
+ result.append({**metric, "aggregate": aggregate})
+ else:
+ result.append(metric)
+ return result
+
+
+def _temporal_column(column: Any, form_data: dict[str, Any]) -> Any:
+ """Apply the frontend BASE_AXIS wrapper for a physical temporal column."""
+ if not isinstance(column, str) or not form_data.get("time_grain_sqla"):
+ return column
+ lookup = form_data.get("temporal_columns_lookup")
+ if not isinstance(lookup, dict) or not lookup.get(column):
+ return column
+ return {
+ "timeGrain": form_data["time_grain_sqla"],
+ "columnType": "BASE_AXIS",
+ "sqlExpression": column,
+ "label": column,
+ "expressionType": "SQL",
+ }
+
+
+def _normalize_orderby(query: dict[str, Any]) -> None:
+ """Mirror ``normalizeOrderBy`` without dropping independent mixed state."""
+ orderby = query.get("orderby")
+ if (
+ isinstance(orderby, list)
+ and orderby
+ and isinstance(orderby[0], (list, tuple))
+ and len(orderby[0]) == 2
+ and orderby[0][0]
+ and isinstance(orderby[0][1], bool)
+ ):
+ return
+ query.pop("orderby", None)
+ target = query.get("series_limit_metric") or query.get("legacy_order_by")
+ if target is None:
+ metrics = query.get("metrics") or []
+ target = metrics[0] if metrics else None
+ if target is not None:
+ query["orderby"] = [[target, not query.get("order_desc", True)]]
+
+
+def _time_comparison(form_data: dict[str, Any], metrics: list[Any]) -> bool:
+ return bool(
+ metrics
+ and _as_list(form_data.get("time_compare"))
+ and form_data.get("comparison_type")
+ in {"values", "difference", "percentage", "ratio"}
+ )
+
+
+def _timeseries_post_processing( # noqa: C901
+ form_data: dict[str, Any],
+ query: dict[str, Any],
+ *,
+ operator_metrics: list[Any] | None = None,
+ complete_timeseries_contract: bool = False,
+) -> list[dict[str, Any]]:
+ """Build the frontend Mixed/Timeseries post-processing contract.
+
+ Timeseries passes its pre-extra-metric QueryObject to every operator, while
+ adding ``extractExtraMetrics`` only to its final query and normal pivot.
+ Mixed passes each layer QueryObject and implements the smaller operator set
+ in its own frontend builder.
+ """
+ metrics = (
+ list(operator_metrics)
+ if operator_metrics is not None
+ else list(query.get("metrics") or [])
+ )
+ metric_labels = [label for metric in metrics if (label :=
_metric_label(metric))]
+ x_axis = form_data.get("x_axis")
+ x_label = (
+ _column_label(x_axis)
+ if x_axis
+ else ("__timestamp" if form_data.get("granularity_sqla") else None)
+ )
+ series = _query_series_columns(query)
+ series_labels = [label for column in series if (label :=
_column_label(column))]
+ offsets = _as_list(form_data.get("time_compare"))
+ comparison = _time_comparison(form_data, metrics)
+ offset_map = {
+ f"{metric}__{offset}": metric for metric in metric_labels for offset
in offsets
+ }
+ pivot_metrics = (
+ [*offset_map.values(), *offset_map.keys()]
+ if comparison
+ else [
+ *metric_labels,
+ *(
+ [
+ label
+ for metric in _timeseries_extra_metrics(form_data)
+ if (label := _metric_label(metric))
+ ]
+ if complete_timeseries_contract
+ else []
+ ),
+ ]
+ )
+ chain: list[dict[str, Any] | None] = []
+ if x_label and pivot_metrics:
+ chain.append(
+ {
+ "operation": "pivot",
+ "options": {
+ "index": [x_label],
+ "columns": series_labels,
+ "aggregates": {
+ metric: {"operator": "mean"} for metric in
pivot_metrics
+ },
+ "drop_missing_columns": not form_data.get(
+ "show_empty_columns", False
+ ),
+ },
+ }
+ )
+ method = form_data.get("resample_method")
+ rule = form_data.get("resample_rule")
+ if method and rule:
+ zero_fill = method == "zerofill"
+ chain.append(
+ {
+ "operation": "resample",
+ "options": {
+ "method": "asfreq" if zero_fill else method,
+ "rule": rule,
+ "fill_value": 0 if zero_fill else None,
+ },
+ }
+ )
+ rolling_type = form_data.get("rolling_type")
+ rolling_columns = (
+ [*offset_map.values(), *offset_map.keys()] if comparison else
metric_labels
+ )
+ if rolling_type == "cumsum":
+ chain.append(
+ {
+ "operation": "cum",
+ "options": {
+ "operator": "sum",
+ "columns": {column: column for column in rolling_columns},
+ },
+ }
+ )
+ elif rolling_type in {"sum", "mean", "std"}:
+ chain.append(
+ {
+ "operation": "rolling",
+ "options": {
+ "rolling_type": rolling_type,
+ "window": int(form_data.get("rolling_periods") or 1),
+ "min_periods": int(form_data.get("min_periods") or 0),
+ "columns": {column: column for column in rolling_columns},
+ },
+ }
+ )
+ comparison_type = form_data.get("comparison_type")
+ if comparison and comparison_type != "values":
+ chain.append(
+ {
+ "operation": "compare",
+ "options": {
+ "source_columns": list(offset_map.values()),
+ "compare_columns": list(offset_map.keys()),
+ "compare_type": comparison_type,
+ "drop_original_columns": True,
+ },
+ }
+ )
+ if complete_timeseries_contract and form_data.get("contributionMode"):
+ chain.append(
+ {
+ "operation": "contribution",
+ "options": {
+ "orientation": form_data["contributionMode"],
+ "time_shifts": offsets if comparison else [],
+ },
+ }
+ )
+ if comparison:
+ rename: dict[str, str | None] = {}
+ for shifted, metric in offset_map.items():
+ offset = next(
+ (item for item in offsets if shifted.endswith(f"__{item}")),
None
+ )
+ source = (
+ shifted
+ if comparison_type == "values"
+ else f"{comparison_type}__{metric}__{shifted}"
+ )
+ rename[source] = f"{metric}, {offset}" if len(metrics) > 1 else
offset
+ if rename:
+ chain.append(
+ {
+ "operation": "rename",
+ "options": {"columns": rename, "level": 0, "inplace":
True},
+ }
+ )
+ elif (
+ x_label
+ and len(metrics) == 1
+ and (series_labels or len(offsets) > 1)
+ and form_data.get("truncate_metric") is not None
+ and form_data.get("truncate_metric")
+ ):
+ chain.append(
+ {
+ "operation": "rename",
+ "options": {
+ "columns": {metric_labels[0]: None},
+ "level": 0,
+ "inplace": True,
+ },
+ }
+ )
+ if complete_timeseries_contract:
+ x_axis_sort = form_data.get("x_axis_sort")
+ x_axis_sort_asc = form_data.get("x_axis_sort_asc")
+ sortable_labels = [
+ label
+ for label in [
+ x_label,
+ *(
+ _metric_label(metric)
+ for metric in _as_list(form_data.get("metrics"))
+ ),
+ *(
+ _metric_label(metric)
+ for metric in _timeseries_extra_metrics(form_data)
+ ),
+ ]
+ if label
+ ]
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)
+ x_axis_sort is not None
+ and x_axis_sort_asc is not None
+ and x_axis_sort in sortable_labels
+ and not _as_list(form_data.get("groupby"))
):
- raise ValueError(
- f"Gantt order_by_cols[{index}] must be [column,
ascending_boolean]"
- )
- orderby.append([entry[0], entry[1]])
+ options: dict[str, Any] = {"ascending": x_axis_sort_asc}
+ if x_axis_sort == x_label:
+ options["is_sort_index"] = True
+ else:
+ options["by"] = x_axis_sort
+ chain.append({"operation": "sort", "options": options})
+ chain.append({"operation": "flatten"})
+
+ if complete_timeseries_contract and form_data.get("forecastEnabled") and
x_label:
+ x_axis_grain = (
+ x_axis.get("timeGrain")
+ if isinstance(x_axis, dict)
+ and x_axis.get("expressionType") in {"SIMPLE", "SQL"}
+ else None
+ )
+ time_grain = (
+ x_axis_grain
+ or (query.get("extras") or {}).get("time_grain_sqla")
+ or form_data.get("time_grain_sqla")
+ or "P1D"
+ )
+ chain.append(
+ {
+ "operation": "prophet",
+ "options": {
+ "time_grain": time_grain,
+ "periods": int(form_data.get("forecastPeriods", 10)),
+ "confidence_interval": float(
+ form_data.get("forecastInterval", 0.8)
+ ),
+ "yearly_seasonality":
form_data.get("forecastSeasonalityYearly"),
+ "weekly_seasonality":
form_data.get("forecastSeasonalityWeekly"),
+ "daily_seasonality":
form_data.get("forecastSeasonalityDaily"),
+ "index": x_label,
+ },
+ }
+ )
+ return [operator for operator in chain if operator is not None]
- 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),
+
+def _mixed_layer_form_data(
+ form_data: dict[str, Any], *, secondary: bool
+) -> dict[str, Any]:
+ """Mirror MixedTimeseries remove/retainFormDataSuffix for one layer."""
+ if not secondary:
+ return {
+ key: value for key, value in form_data.items() if not
key.endswith("_b")
+ }
+ layer = {key: value for key, value in form_data.items() if not
key.endswith("_b")}
+ for isolated_key in (
+ "metrics",
+ "groupby",
+ "orderby",
+ "limit",
+ "series_limit",
+ "timeseries_limit_metric",
+ "series_limit_metric",
+ "order_desc",
+ "row_limit",
+ "truncate_metric",
+ "time_compare",
+ "comparison_type",
+ "resample_method",
+ "resample_rule",
+ "rolling_type",
+ "rolling_periods",
+ "min_periods",
+ "show_empty_columns",
):
- 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
+ if f"{isolated_key}_b" not in form_data:
Review Comment:
Fixed in b92dc4189fbb416c0bf2d714524a216c787c2782. Query B retains shared
row_limit when row_limit_b is absent, and an explicit suffixed limit overrides
it. The query-builder regression verifies both legs against the frontend
retainFormDataSuffix contract.
--
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]