sadpandajoe commented on code in PR #43770:
URL: https://github.com/apache/superset/pull/43770#discussion_r4180181683
##########
superset/mcp_service/chart/chart_utils.py:
##########
@@ -1715,6 +1758,733 @@ 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 = {
Review Comment:
A presentation-only Bullet update omits saved top-level
`where`/`having`/`filters` and the supported `order_by_cols` alias from this
preservation list, so it can persist an unfiltered chart or change which
category survives a row limit. Could inherited native predicates and ordering
aliases be normalized before the omission-aware merge, while retaining explicit
clear semantics?
##########
superset/mcp_service/chart/preview_utils.py:
##########
@@ -496,6 +1119,357 @@ 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] = 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:
+ token = token.strip(_JAVASCRIPT_WHITESPACE)
+ 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 = (
+ _javascript_numeric_string(token)
+ if type(token) is str
+ else 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)
+
+ metric_reference = "".join(
+ "\\" + char if char in ".[]\\" else char for char in model.metric_field
+ )
+ 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": metric_reference,
+ "type": "quantitative",
+ "title": model.metric_field,
+ "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": [
Review Comment:
These range tooltip arrays contain constant `value` definitions, but
Vega-Lite accepts field definitions in tooltip arrays and skips these entries,
so range/marker hover details disappear and schema-validating consumers reject
the preview. Could the range, marker, and marker-line layers use valid tooltip
definitions?
##########
superset/mcp_service/chart/chart_helpers.py:
##########
@@ -809,10 +1739,551 @@ def build_mixed_timeseries_secondary(
return qd
-# Deck.gl viz types that conditionally set is_timeseries from time_grain_sqla
-_DECK_TIMESERIES_VIZ_TYPES: frozenset[str] = frozenset(
- {"deck_arc", "deck_path", "deck_polygon", "deck_scatter",
"deck_screengrid"}
-)
+def build_histogram_query_dicts(
+ form_data: dict[str, Any],
+ *,
+ engine: str,
+ row_limit: int | None,
+ order_desc: bool | None,
+) -> list[dict[str, Any]]:
+ """Render Histogram buildQuery, including its histogram post-processing."""
+ column = form_data.get("column")
+ histogram_groupby = _as_list(form_data.get("groupby"))
+ query = build_single_query_dict(
+ form_data,
+ [*histogram_groupby, column] if column is not None else
histogram_groupby,
+ [],
+ row_limit=row_limit,
+ order_desc=order_desc,
+ )
+ having_filter = bool(form_data.get("having")) or any(
+ isinstance(filter_, dict) and filter_.get("clause") == "HAVING"
+ for filter_ in form_data.get("adhoc_filters") or []
+ )
+ if having_filter:
+ query["metrics"] = [
+ {
+ "expressionType": "SQL",
+ "sqlExpression": "COUNT(*)",
+ "label": "COUNT(*)",
+ }
+ ]
+ bins = form_data.get("bins", 5)
+ try:
+ parsed_bins = float(bins)
+ parsed_bins = int(parsed_bins) if parsed_bins.is_integer() else
parsed_bins
+ except (TypeError, ValueError):
+ parsed_bins = 5
+ query["post_processing"] = [
+ {
+ "operation": "histogram",
+ "options": {
+ "column": _column_label(column),
+ "groupby": [
+ label
+ for item in histogram_groupby
+ if (label := _column_label(item))
+ ],
+ "bins": parsed_bins,
+ "cumulative": bool(form_data.get("cumulative")),
+ "normalize": bool(form_data.get("normalize")),
+ },
+ }
+ ]
+ return [query]
+
+
+def build_box_plot_query_dicts( # noqa: C901
+ form_data: dict[str, Any],
+ *,
+ engine: str,
+ row_limit: int | None,
+ order_desc: bool | None,
+) -> list[dict[str, Any]]:
+ """Render Box Plot buildQuery, including its boxplot post-processing."""
+ distribute = _as_list(form_data.get("columns"))
+ if not distribute and form_data.get("granularity_sqla"):
+ distribute = [form_data["granularity_sqla"]]
+ box_groupby = _as_list(form_data.get("groupby"))
+ query = build_single_query_dict(
+ form_data,
+ [
+ *(_temporal_column(column, form_data) for column in distribute),
+ *box_groupby,
+ ],
+ list(form_data.get("metrics") or []),
+ row_limit=row_limit,
+ order_desc=order_desc,
+ )
+ query["series_columns"] = box_groupby
+ if whisker := form_data.get("whiskerOptions"):
+ whisker_type = "tukey"
+ percentiles: list[int] | None = None
+ if whisker == "Min/max (no outliers)":
+ whisker_type = "min/max"
+ elif match := re.fullmatch(r"(\d{1,3})/(\d{1,3}) percentiles",
str(whisker)):
+ whisker_type = "percentile"
+ percentiles = [int(match.group(1)), int(match.group(2))]
+ elif whisker != "Tukey":
+ raise ValueError(f"Unsupported whisker type: {whisker}")
+ query["post_processing"] = [
+ {
+ "operation": "boxplot",
+ "options": {
+ "whisker_type": whisker_type,
+ "percentiles": percentiles,
+ "groupby": [
+ label
+ for column in box_groupby
+ if (label := _column_label(column))
+ ],
+ "metrics": [
+ label
+ for metric in query["metrics"]
+ if (label := _metric_label(metric))
+ ],
+ },
+ }
+ ]
+ return [query]
+
+
+def build_pivot_table_query_dicts(
+ form_data: dict[str, Any],
+ *,
+ engine: str,
+ row_limit: int | None,
+ order_desc: bool | None,
+) -> list[dict[str, Any]]:
+ """Render Pivot Table buildQuery, including subtotal grouping sets."""
+ rows = _as_list(form_data.get("groupbyRows"))
+ pivot_columns = _as_list(form_data.get("groupbyColumns"))
+ if form_data.get("transposePivot"):
+ rows, pivot_columns = pivot_columns, rows
+ columns = _dedupe_query_fields([*rows, *pivot_columns], _column_label)
+ query = build_single_query_dict(
+ form_data,
+ [_temporal_column(column, form_data) for column in columns],
+ list(form_data.get("metrics") or []),
+ row_limit=row_limit,
+ order_desc=order_desc,
+ )
+ sort_metric = query.get("series_limit_metric")
+ if sort_metric is None and query["metrics"]:
+ sort_metric = query["metrics"][0]
+ if sort_metric is not None:
+ query["orderby"] = [[sort_metric, not query.get("order_desc", True)]]
+ if grouping_sets := _pivot_grouping_sets(form_data, rows, pivot_columns):
+ query["grouping_sets"] = grouping_sets
+ return [query]
+
+
+def build_pie_query_dicts(
+ form_data: dict[str, Any],
+ *,
+ contribution: bool,
+ engine: str,
+ row_limit: int | None,
+ order_desc: bool | None,
+) -> list[dict[str, Any]]:
+ """Render Pie/Sunburst buildQuery; Pie adds a contribution operator."""
+ metric = form_data.get("metric")
+ query = build_single_query_dict(
+ form_data,
+ _as_list(form_data.get("groupby")),
+ [metric] if metric is not None else [],
+ row_limit=row_limit,
+ order_desc=order_desc,
+ orderby=form_data.get("orderby"),
+ )
+ if form_data.get("sort_by_metric") and metric is not None:
+ query["orderby"] = [[metric, False]]
+ if contribution and (label := _metric_label(metric)):
+ query["post_processing"] = [
+ {
+ "operation": "contribution",
+ "options": {
+ "columns": [label],
+ "rename_columns": [f"{label}__contribution"],
+ },
+ }
+ ]
+ return [query]
+
+
+def _positive_int(value: Any) -> int:
+ """Coerce a stored limit (int, numeric string, or empty) to a positive int
or 0."""
+ try:
+ coerced = int(value)
+ except (TypeError, ValueError):
+ return 0
+ return coerced if coerced > 0 else 0
+
+
+def build_table_query_dicts( # noqa: C901
+ form_data: dict[str, Any],
+ *,
+ engine: str,
+ row_limit: int | None,
+ order_desc: bool | None,
+) -> list[dict[str, Any]]:
+ """Render Table buildQuery: percent metrics, comparisons, totals,
paging."""
+ raw_mode = form_data.get("query_mode") == "raw" or (
+ form_data.get("query_mode") not in {"raw", "aggregate"}
+ and bool(form_data.get("all_columns"))
+ )
+ table_columns = list(
+ (form_data.get("all_columns") or [])
+ if raw_mode
+ else (form_data.get("groupby") or [])
+ )
+ table_metrics = [] if raw_mode else list(form_data.get("metrics") or [])
+ percent_metrics = [] if raw_mode else
_as_list(form_data.get("percent_metrics"))
+ table_metrics = _dedupe_query_fields(
+ [*table_metrics, *percent_metrics], _metric_label
+ )
+ table_orderby = _parse_orderby(form_data.get("order_by_cols"))
+ if not raw_mode:
+ sort_metrics = _as_list(form_data.get("timeseries_limit_metric"))
+ if sort_metrics:
+ table_orderby = [[sort_metrics[0], not form_data.get("order_desc",
False)]]
+ elif table_metrics:
+ table_orderby = [[table_metrics[0], False]]
+ query = build_single_query_dict(
+ form_data,
+ table_columns,
+ table_metrics,
+ row_limit=row_limit,
+ order_desc=order_desc,
+ orderby=table_orderby,
+ )
+ if not raw_mode:
+ # Table selects one temporal axis and places it before the other roles.
+ for index, column in enumerate(table_columns):
+ temporal_column = _temporal_column(column, form_data)
+ if temporal_column is not column:
+ query["columns"] = [
+ temporal_column,
+ *table_columns[:index],
+ *table_columns[index + 1 :],
+ ]
+ break
+ offsets = _table_time_offsets(form_data, query)
+ query["time_offsets"] = offsets
+ post_processing: list[dict[str, Any]] = []
+ contribution: dict[str, Any] | None = None
+ if percent_metrics:
+ labels: list[str] = []
+ for metric in percent_metrics:
+ if label := _metric_label(metric):
+ candidates = [label]
+ if offsets:
+ candidates.extend(f"{label}__{offset}" for offset in
offsets)
+ for candidate in candidates:
+ if candidate not in labels:
+ labels.append(candidate)
+ contribution = {
+ "operation": "contribution",
+ "options": {
+ "columns": labels,
+ "rename_columns": [f"%{label}" for label in labels],
+ },
+ }
+ post_processing.append(contribution)
+ if offsets and form_data.get("comparison_type") != "values":
+ source: list[str] = []
+ shifted: list[str] = []
+ for metric in table_metrics:
+ if label := _metric_label(metric):
+ for offset in offsets:
+ source.append(label)
+ shifted.append(f"{label}__{offset}")
+ post_processing.append(
+ {
+ "operation": "compare",
+ "options": {
+ "source_columns": source,
+ "compare_columns": shifted,
+ "compare_type": form_data.get("comparison_type"),
+ "drop_original_columns": True,
+ },
+ }
+ )
+ query["post_processing"] = post_processing
+
+ # ``query["row_limit"]`` is the normalized caller limit (explicit request
+ # limit or the saved row_limit, which may be stored as a string); page
+ # sizing narrows it but never replaces it.
+ configured_limit = _positive_int(query.get("row_limit"))
+ if form_data.get("server_pagination"):
+ if page_size := _positive_int(form_data.get("server_page_length")):
+ query["row_limit"] = (
+ min(page_size, configured_limit) if configured_limit else
page_size
+ )
+ query["row_offset"] = 0
+
+ extra_queries: list[dict[str, Any]] = []
+ if form_data.get("percent_metric_calculation") == "all_records" and
percent_metrics:
+ extra_queries.append(
+ {
+ **query,
+ "columns": [],
+ "metrics": percent_metrics,
+ "post_processing": [],
+ "row_limit": 0,
+ "row_offset": 0,
+ "orderby": [],
+ "is_timeseries": False,
+ }
+ )
+ if table_metrics and form_data.get("show_totals") and not raw_mode:
+ totals = {
+ **query,
+ "columns": [],
+ "metrics": _table_totals_metrics(
+ table_metrics, form_data.get("totals_aggregate")
+ ),
+ "row_limit": 0,
+ "row_offset": 0,
+ "post_processing": [contribution] if contribution else [],
+ }
+ totals.pop("orderby", None)
+ totals.pop("order_desc", None)
+ extra_queries.append(totals)
+ if form_data.get("server_pagination"):
+ rowcount = {
+ **query,
+ "time_offsets": [],
+ "row_limit": configured_limit or 0,
+ "row_offset": 0,
+ "post_processing": [],
+ "is_rowcount": True,
+ }
+ return [query, rowcount, *extra_queries]
+ return [query, *extra_queries]
+
+
+def build_gantt_query_dicts(
+ form_data: dict[str, Any],
+ *,
+ engine: str,
+ row_limit: int | None,
+ order_desc: bool | None,
+) -> list[dict[str, Any]]:
+ """Render Gantt buildQuery with its interval columns and series."""
+ (
+ gantt_columns,
+ gantt_metrics,
+ gantt_orderby,
+ gantt_groupby,
+ ) = resolve_gantt_query_fields(form_data)
+ query = build_single_query_dict(
+ form_data,
+ gantt_columns,
+ gantt_metrics,
+ row_limit=row_limit,
+ order_desc=order_desc,
+ orderby=gantt_orderby,
+ )
+ query["series_columns"] = gantt_groupby
+ return [query]
+
+
+def build_interactive_pivot_query_dicts(
+ form_data: dict[str, Any],
+ *,
+ engine: str,
+ row_limit: int | None,
+ order_desc: bool | None,
+) -> list[dict[str, Any]]:
+ """Render Interactive Pivot Table buildQuery."""
+ interactive_columns = [
+ _temporal_column(column, form_data)
+ for column in _as_list(form_data.get("groupby"))
+ ]
+ query = build_single_query_dict(
+ form_data,
+ interactive_columns,
+ list(form_data.get("metrics") or []),
+ row_limit=row_limit,
+ order_desc=order_desc,
+ orderby=form_data.get("orderby"),
+ )
+ _normalize_orderby(query)
+ return [query]
+
+
+def build_big_number_query_dicts( # noqa: C901
+ form_data: dict[str, Any],
+ *,
+ trendline: bool,
+ engine: str,
+ row_limit: int | None,
+ order_desc: bool | None,
+) -> list[dict[str, Any]]:
+ """Render Big Number (with or without trendline) buildQuery."""
+ metric = form_data.get("metric")
Review Comment:
Saved or migrated Big Number charts with only `metrics: ["count"]` now
reconstruct an empty query and fail previews/data fallback, although
`BigNumberChartPlugin.resolve_query_fields` still explicitly supports that
saved shape. Could this builder retain its plural-metric fallback when `metric`
is absent?
##########
superset/mcp_service/chart/preview_utils.py:
##########
@@ -339,6 +414,554 @@ def _generate_safe_ascii_bar_chart(data: List[Dict[str,
Any]]) -> str:
return "\n".join(lines)
+def _form_metric_label(metric: Any) -> str | None:
+ """Return the result-column label for a native QueryFormMetric."""
+ if type(metric) is str:
+ return metric
+ if type(metric) is not dict:
+ return None
+ if label := dict.get(metric, "label"):
+ return label if type(label) is str else None
+ if dict.get(metric, "expressionType") == "SQL":
+ expression = dict.get(metric, "sqlExpression")
+ return expression if type(expression) is str and expression else None
+ column = dict.get(metric, "column")
+ column_name = dict.get(column, "column_name") if type(column) is dict else
column
+ aggregate = dict.get(metric, "aggregate")
+ if type(column_name) is str and type(aggregate) is str:
+ return f"{aggregate}({column_name})"
+ return None
+
+
+def _form_column_label(column: Any) -> str | None:
+ """Return the result-column label for a native QueryFormColumn."""
+ if type(column) is str:
+ return column
+ if type(column) is not dict:
+ return None
+ for key in ("label", "column_name"):
+ if type(value := dict.get(column, key)) is str and value:
+ return value
+ return None
+
+
+def _canonical_result_field(label: str | None, row: Dict[str, Any]) -> str |
None:
+ """Resolve an exact or one unambiguous casefold result-field match."""
+ if label is None:
+ return None
+ if label in dict.keys(row):
+ return label
+ matches = [
+ field
+ for field in dict.keys(row)
+ if type(field) is str and field.casefold() == label.casefold()
+ ]
+ return matches[0] if len(matches) == 1 else None
+
+
+def _require_result_field(label: str | None, row: dict[str, Any], role: str)
-> str:
+ """Resolve a role without falling back to an unrelated result field."""
+ if not label:
+ raise BulletOutputError(f"Bullet {role} has no declared result alias")
+ if label in dict.keys(row):
+ return label
+ matches = sorted(
+ field
+ for field in dict.keys(row)
+ if type(field) is str and field.casefold() == label.casefold()
+ )
+ if len(matches) == 1:
+ return matches[0]
+ if matches:
+ raise BulletOutputError(
+ f"Bullet {role} alias {label!r} is ambiguous; candidates: "
+ f"{', '.join(matches)}"
+ )
+ raise BulletOutputError(
+ f"Bullet {role} alias {label!r} is missing from query output"
+ )
+
+
+def _safe_enum_backing(value: Any) -> Any:
+ """Extract Enum's stored value without public descriptors/conversions."""
+ value_type = type(value)
+ try:
+ mro = type.__getattribute__(value_type, "__mro__")
+ except (AttributeError, TypeError): # pragma: no cover - normal types
have MRO
+ return value
+ if type(mro) is not tuple or not any(base is Enum for base in mro):
+ return value
+ try:
+ backing = object.__getattribute__(value, "_value_")
+ except Exception as ex:
+ raise BulletOutputError("Bullet output contains an unreadable enum")
from ex
+ if not any(type(backing) is allowed for allowed in _ENUM_SCALAR_TYPES):
+ raise BulletOutputError("Bullet output contains an unsupported enum
value")
+ return backing
+
+
+def _decimal_javascript_string(value: Decimal) -> str:
+ """Render an exact binary64 spelling with JavaScript Number thresholds."""
+ sign, digits_tuple, exponent = Decimal.as_tuple(value)
+ if type(exponent) is not int: # finite Decimals always have an integer
exponent
+ raise BulletOutputError("Bullet dimension contains a non-finite
Decimal")
+ if not any(digits_tuple):
+ return "0"
+
+ digits = "".join(str(digit) for digit in digits_tuple)
+ adjusted = len(digits) + exponent - 1
+ prefix = "-" if sign else ""
+ if -6 <= adjusted < 21:
+ point = len(digits) + exponent
+ if point <= 0:
+ text = f"0.{('0' * -point)}{digits}"
+ elif point >= len(digits):
+ text = digits + ("0" * (point - len(digits)))
+ else:
+ text = f"{digits[:point]}.{digits[point:]}"
+ if "." in text:
+ text = text.rstrip("0").rstrip(".")
+ return prefix + text
+
+ fraction = digits[1:].rstrip("0")
+ coefficient = digits[0] + (f".{fraction}" if fraction else "")
+ exponent_text = f"+{adjusted}" if adjusted >= 0 else str(adjusted)
+ return f"{prefix}{coefficient}e{exponent_text}"
+
+
+def _javascript_number_string(value: int | float | Decimal) -> str:
+ """Apply JSON-number -> IEEE-754 Number -> JavaScript String semantics.
+
+ Exact result scalars can retain precision that the frontend cannot: JSON
+ parsing first rounds a numeric token to binary64, and ``String`` then emits
+ the shortest round-tripping decimal with fixed notation for exponents in
+ [-6, 20]. Converting exact builtin scalars to an exact builtin float keeps
+ the path hook-free. Python and JavaScript use the same shortest
+ round-tripping binary64 digits; ``_decimal_javascript_string`` only adjusts
+ the notation thresholds and exponent spelling.
+
+ A finite integer or Decimal outside binary64's range becomes an infinity
+ after JSON parsing, matching JavaScript. Non-finite source values are
+ rejected by the trusted scalar normalizer before this helper is called.
+ """
+ value_type = type(value)
+ if value_type not in {int, float, Decimal}:
+ raise BulletOutputError("Bullet dimension contains an unsupported
number")
+ if value_type is float and not math.isfinite(value):
+ raise BulletOutputError("Bullet dimension contains a non-finite
number")
+ if isinstance(value, Decimal) and not Decimal.is_finite(value):
+ raise BulletOutputError("Bullet dimension contains a non-finite
Decimal")
+ try:
+ number = float(value)
+ except OverflowError:
+ number = -math.inf if value < 0 else math.inf
+
+ if math.isinf(number):
+ return "-Infinity" if number < 0 else "Infinity"
+ if number == 0:
+ # String(-0) is "0" even though JSON.parse preserves negative zero.
+ return "0"
+ return _decimal_javascript_string(Decimal(float.__repr__(number)))
+
+
+def _bullet_category_value( # noqa: C901
+ value: Any, dimension: str, row_index: int
+) -> tuple[Any, str]:
+ """Return a JSON-safe value and bounded frontend ``String(value)`` text.
+
+ The trusted scalar normalizer is type-exact and does not dispatch through
+ application hooks. Vega data retains the normalized Chart Data wire value
+ (including epoch-ms temporal numbers); only the derived category key and
+ ASCII label use the JavaScript-compatible text.
+ """
+ from superset.mcp_service.chart.query_result import (
+ _bounded_utf8_length,
+ _chart_data_duration_text,
+ _chart_data_temporal_number,
+ _is_chart_data_duration_scalar,
+ _is_chart_data_temporal_scalar,
+ _normalize_trusted_scalar,
+ )
+
+ normalized: Any
+ reason: str | None
+ if _is_chart_data_temporal_scalar(value):
+ normalized, reason = _chart_data_temporal_number(value)
+ elif _is_chart_data_duration_scalar(value):
+ normalized, reason = _chart_data_duration_text(value)
+ else:
+ normalized, reason = _normalize_trusted_scalar(
+ value, max_string_bytes=_MAX_BULLET_TEXT_BYTES
+ )
+ if reason is not None:
+ if reason == "contains an unsupported or subclassed value":
+ reason = "has an unsupported value type"
+ elif "oversized string" in reason:
+ reason = "exceeds the size limit"
+ raise BulletOutputError(
+ f"Bullet dimension {dimension!r} row {row_index} {reason}"
+ )
+
+ value_type = type(normalized)
+ if normalized is None:
+ text = "null"
+ elif value_type is str:
+ text = normalized
+ elif value_type is bool:
+ text = "true" if normalized else "false"
+ elif value_type is int or value_type is float or value_type is Decimal:
+ text = _javascript_number_string(normalized)
+ else:
+ raise BulletOutputError(
+ f"Bullet dimension {dimension!r} row {row_index} has an "
+ "unsupported value type"
+ )
+
+ if _bounded_utf8_length(text, _MAX_BULLET_TEXT_BYTES) is None:
+ raise BulletOutputError(
+ f"Bullet dimension {dimension!r} row {row_index} exceeds the size
limit"
+ )
+ return normalized, text
+
+
+def _javascript_numeric_string(value: str) -> float:
+ """Parse a nonempty trimmed string using JavaScript Number's grammar."""
+ if re.fullmatch(r"0[xX][0-9a-fA-F]+|0[bB][01]+|0[oO][0-7]+", value):
+ return float(int(value, 0))
+ if re.fullmatch(
+
r"[+-]?(?:Infinity|(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+)(?:[eE][+-]?[0-9]+)?)",
+ value,
+ ):
+ return float(value)
+ raise ValueError("Invalid JavaScript number spelling")
+
+
+def _bullet_number(value: Any, row_index: int, metric_field: str) -> float:
+ """Apply the frontend's useful ``Number(value ?? 0)`` numeric subset."""
+ value = _safe_enum_backing(value)
+ if value is None:
+ number = 0.0
+ elif type(value) is bool:
+ raise BulletOutputError(
+ f"Bullet metric {metric_field!r} row {row_index} returned a
boolean"
+ )
+ elif type(value) is int or type(value) is float or type(value) is Decimal:
+ try:
+ number = float(value)
+ except (TypeError, ValueError, OverflowError) as ex:
+ raise BulletOutputError(
+ f"Bullet metric {metric_field!r} row {row_index} is not
numeric"
+ ) from ex
+ elif type(value) is str:
+ if len(value) > _MAX_BULLET_TEXT_BYTES:
+ raise BulletOutputError(
+ f"Bullet metric {metric_field!r} row {row_index} is not
numeric"
+ )
+ stripped = value.strip(_JAVASCRIPT_WHITESPACE)
+ if not stripped:
+ raise BulletOutputError(
+ f"Bullet metric {metric_field!r} row {row_index} is not
numeric"
+ )
+ try:
+ number = _javascript_numeric_string(stripped)
+ except (ValueError, OverflowError) as ex:
+ raise BulletOutputError(
+ f"Bullet metric {metric_field!r} row {row_index} returned "
+ f"non-numeric text"
+ ) from ex
+ else:
+ raise BulletOutputError(
+ f"Bullet metric {metric_field!r} row {row_index} is not numeric"
+ )
+ if not math.isfinite(number):
+ raise BulletOutputError(
+ f"Bullet metric {metric_field!r} row {row_index} is NaN or
infinite"
+ )
+ return number
+
+
+def _bullet_string_tokens(value: Any) -> list[str]:
+ """Parse labels exactly like the frontend's comma tokenizer."""
+ from superset.mcp_service.chart.query_result import _truncate_utf8
+
+ value = _safe_enum_backing(value)
+ if value is None:
+ return []
+ if type(value) is not str or len(value) > _MAX_BULLET_TEXT_BYTES:
+ raise BulletOutputError("Bullet labels must be a bounded
comma-separated list")
+ if not value.strip():
+ return []
+ tokens = value.split(",")
+ if len(tokens) > _MAX_BULLET_TOKENS:
+ raise BulletOutputError("Bullet labels exceed the item limit")
+ return [_truncate_utf8(token.strip(), _MAX_BULLET_TEXT_BYTES) for token in
tokens]
+
+
+def _unique_bullet_derived_field(
+ rows: list[dict[str, Any]], base: str, reserved: tuple[str, ...] = ()
+) -> str:
+ """Return one internal key absent from result rows and prior derived
keys."""
+ occupied = {key for row in rows for key in dict.keys(row)}
+ occupied.update(reserved)
+ candidate = base
+ suffix = 0
+ while candidate in occupied:
+ suffix += 1
+ candidate = f"{base}_{suffix}"
+ return candidate
+
+
+def _unique_bullet_category_field(rows: list[dict[str, Any]]) -> str:
+ """Return an internal category key absent from every query-result row."""
+ return _unique_bullet_derived_field(rows, "__mcp_bullet_category")
+
+
+def _validate_bullet_format(format_: Any, values: list[float]) -> str:
+ """Reject a presentation format the backend cannot reproduce."""
+ format_ = _safe_enum_backing(format_)
+ if format_ is None or format_ == "":
+ format_ = "SMART_NUMBER"
+ if type(format_) is not str or len(format_) > 50:
+ raise BulletOutputError(
+ "Bullet number format is unsupported by previews",
+ error_type="UnsupportedFormat",
+ )
+ from superset.utils.number_format import D3_FORMAT_RE
+
+ # Specifier length does not bound its requested output precision. Check the
+ # parsed precision before the formatter can allocate or round any value.
+ match = D3_FORMAT_RE.match(format_)
+ if match and match.group(8) is not None and int(match.group(8)) > 20:
+ raise BulletOutputError(
+ "Bullet number format precision must not exceed 20",
+ error_type="UnsupportedFormat",
+ )
+ try:
+ for value in values:
+ _format_bullet_number(format_, value)
Review Comment:
Formatting a valid `.20f` Bullet measure of `10000000000.0` raises
`decimal.InvalidOperation` with the default Decimal precision, so this
mandatory validation aborts previews and even raw data/export reads despite the
query succeeding. Could accepted bounded formats use sufficient Decimal
precision for the measure before this formatter is called?
##########
superset/mcp_service/chart/compile.py:
##########
@@ -282,6 +329,450 @@ 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":
+ # Match QueryObject's guarded legacy saved-metric normalization.
+ if not ({"sqlExpression", "aggregate", "column"} & value.keys()):
+ label = value.get("label")
+ if isinstance(label, str) and label:
+ return "saved_metric", label
+ 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":
+ # Native lookup selects an exact name unambiguously; only a
+ # case-folded reference has to be unique.
+ matches = (
+ [name]
+ if name in saved_metrics
+ else [
+ item for item in saved_metrics if item.casefold() ==
name.casefold()
+ ]
+ )
+ if len(set(matches)) != 1:
+ saved_role = f"{role.removesuffix(' metric')} saved metric"
+ return _native_validation_error(saved_role, name)
+ return None
+ return column_error(name, f"{role} column")
+
+ # Dataset-only rebind has no typed config to expose these native plugin
+ # roles. Validate the raw controls independently: some are consumed only
+ # while building ordering/post-processing and therefore may be absent from
+ # the final QueryObject (notably an explicit ordering can hide a ranking
+ # metric). Primary and secondary Mixed layers are deliberately separate.
+ viz_type = form_data.get("viz_type")
+ if strict_all_form_refs and (
+ viz_type == "mixed_timeseries"
+ or (
+ isinstance(viz_type, str)
+ and (
+ viz_type.startswith("echarts_timeseries") or viz_type ==
"echarts_area"
+ )
+ )
+ ):
+ if (raw_x_axis := form_data.get("x_axis")) is not None and (
+ error := column_error(raw_x_axis, "form-data x_axis column")
+ ):
+ return error
+ metric_fields = [
+ "metrics",
+ "size",
+ "timeseries_limit_metric",
+ "series_limit_metric",
+ ]
+ if viz_type == "mixed_timeseries":
+ metric_fields.extend(
+ [
+ "metrics_b",
+ "size_b",
+ "timeseries_limit_metric_b",
+ "series_limit_metric_b",
+ ]
+ )
+ for field_name in metric_fields:
+ raw_value = form_data.get(field_name)
+ values = raw_value if isinstance(raw_value, list) else [raw_value]
+ for value in values:
+ if value is not None and (
+ error := metric_error(value, f"form-data {field_name}
metric")
+ ):
+ return error
+
+ layer_suffixes = ("", "_b") if viz_type == "mixed_timeseries" else
("",)
+ for suffix in layer_suffixes:
+ sort_field = f"x_axis_sort{suffix}"
+ if sort_field not in form_data or form_data.get(sort_field) is
None:
+ continue
+ x_axis = form_data.get(f"x_axis{suffix}", form_data.get("x_axis"))
+ allowed_labels: set[str] = set()
+ if x_axis_label := _native_column_label(x_axis):
+ allowed_labels.add(x_axis_label)
+ raw_metrics = form_data.get(f"metrics{suffix}")
+ for metric in raw_metrics if isinstance(raw_metrics, list) else []:
+ if label := _metric_label_for_validation(metric):
+ allowed_labels.add(label)
+ raw_limit_metric =
form_data.get(f"timeseries_limit_metric{suffix}")
+ limit_metrics = (
+ raw_limit_metric
+ if isinstance(raw_limit_metric, list)
+ else [raw_limit_metric]
+ )
+ for metric in limit_metrics:
+ if label := _metric_label_for_validation(metric):
+ allowed_labels.add(label)
+ sort_value = form_data[sort_field]
+ if not isinstance(sort_value, str) or sort_value not in
allowed_labels:
+ return _native_validation_error(sort_field,
repr(sort_value)[:200])
+
+ if (
+ strict_all_form_refs
+ and isinstance(viz_type, str)
+ and viz_type.startswith("deck_")
+ ):
+ # Deck layers store most query roles outside common columns/metrics.
+ # Validate every renderer-consumed raw control as well as the generated
+ # QueryObject so a dataset-only rebind cannot hide or discard a stale
+ # tooltip, cross-filter, spatial, path, or metric reference.
+ for spatial_field in ("spatial", "start_spatial", "end_spatial"):
+ spatial = form_data.get(spatial_field)
+ if spatial is None:
+ continue
+ if not isinstance(spatial, dict):
+ return _native_validation_error(
+ f"form-data {spatial_field}", repr(spatial)[:200]
+ )
+ spatial_type = spatial.get("type")
+ if not isinstance(spatial_type, str):
+ return _native_validation_error(
+ f"form-data {spatial_field} type", repr(spatial_type)[:200]
+ )
+ role_fields = {
+ "latlong": ("lonCol", "latCol"),
+ "delimited": ("lonlatCol",),
+ "geohash": ("geohashCol",),
+ }.get(spatial_type)
+ if role_fields is None:
+ return _native_validation_error(
+ f"form-data {spatial_field} type", repr(spatial_type)[:200]
+ )
+ for role_field in role_fields:
+ spatial_value = spatial.get(role_field)
+ if spatial_value is None:
+ return _native_validation_error(
+ f"form-data {spatial_field}.{role_field} column",
"missing"
+ )
+ if error := column_error(
+ spatial_value, f"form-data {spatial_field}.{role_field}
column"
+ ):
+ return error
+
+ for field_name in (
+ "line_column",
+ "geojson",
+ "dimension",
+ "cross_filter_column",
+ ):
+ column_value = form_data.get(field_name)
+ if column_value is not None and (
+ error := column_error(column_value, f"form-data {field_name}
column")
+ ):
+ return error
+
+ tooltip_contents = form_data.get("tooltip_contents")
+ if tooltip_contents is not None and not isinstance(tooltip_contents,
list):
+ return _native_validation_error(
+ "form-data tooltip_contents", repr(tooltip_contents)[:200]
+ )
+ for index, item in enumerate(tooltip_contents or []):
+ tooltip_value: Any = None
+ if isinstance(item, str):
+ tooltip_value = item
+ elif isinstance(item, dict) and item.get("item_type") == "column":
+ tooltip_value = item.get("column_name")
+ if tooltip_value is not None and (
+ error := column_error(
+ tooltip_value, f"form-data tooltip_contents[{index}]
column"
+ )
+ ):
+ return error
+
+ metric_values: list[tuple[str, Any]] = []
+ if viz_type not in {"deck_geojson", "deck_polygon"}:
+ for field_name in ("metrics", "metric", "size"):
+ raw_deck_metrics = form_data.get(field_name)
+ deck_metrics = (
+ raw_deck_metrics
+ if isinstance(raw_deck_metrics, list)
+ else [raw_deck_metrics]
+ )
+ metric_values.extend(
+ (f"form-data {field_name} metric", deck_metric)
+ for deck_metric in deck_metrics
+ if deck_metric is not None
+ )
+ if viz_type == "deck_polygon" and form_data.get("metric") is not None:
+ metric_values.append(("form-data metric metric",
form_data.get("metric")))
+ fixed_metric_fields = (
+ ("point_radius_fixed",)
+ if viz_type in {"deck_scatter", "deck_polygon"}
+ else ()
+ ) + (("line_width",) if viz_type == "deck_path" else ())
+ for field_name in fixed_metric_fields:
+ fixed_value = form_data.get(field_name)
+ deck_metric: Any = (
+ fixed_value
+ if (
+ isinstance(fixed_value, str)
+ and fixed_value
+ and viz_type != "deck_polygon"
+ )
+ else None
+ )
+ if isinstance(fixed_value, dict) and fixed_value.get("type") ==
"metric":
+ deck_metric = fixed_value.get("value")
+ if deck_metric is not None:
+ metric_values.append((f"form-data {field_name} metric",
deck_metric))
+ if viz_type == "deck_path" and form_data.get("breakpoint_metric") is
not None:
+ metric_values.append(
+ (
+ "form-data breakpoint_metric metric",
+ form_data.get("breakpoint_metric"),
+ )
+ )
+ for role, deck_metric in metric_values:
+ if error := metric_error(deck_metric, role):
+ return error
+
+ for filter_ in form_data.get("adhoc_filters") or []:
+ if not isinstance(filter_, dict) or filter_.get("expressionType") !=
"SIMPLE":
+ continue
+ if not strict_all_form_refs and _is_inert_adhoc_filter(filter_):
+ continue
+ subject = filter_.get("subject")
+ clause = str(filter_.get("clause") or "WHERE").upper()
+ if clause == "HAVING" and isinstance(subject, str):
+ metric_matches = (
+ [subject]
+ if subject in saved_metrics
+ else [
+ name
+ for name in saved_metrics
+ if name.casefold() == subject.casefold()
+ ]
+ )
+ if len(metric_matches) == 1:
+ continue
+ if subject is not None and (
+ error := column_error(subject, "form-data filter column")
+ ):
+ return error
+ if filter_.get("operator") == "TEMPORAL_RANGE" and isinstance(subject,
str):
+ try:
+ temporal = resolve_dataset_column(subject, dataset_context)
+ except ValueError:
+ temporal = None
+ if temporal is not None and not temporal.get("is_temporal", False):
+ return _native_validation_error("temporal filter column",
subject)
+
+ # temporal_columns_lookup describes the entire datasource, not selected
+ # roles. The physical form/query column checks validate selected
references.
+
+ for query_index, query in enumerate(queries, 1):
+ metric_labels: set[str] = set()
+ for column in query.get("columns") or []:
+ if error := column_error(column, f"query {query_index} column"):
+ return error
+ for column in query.get("series_columns") or []:
+ if error := column_error(column, f"query {query_index} series
column"):
+ return error
+ for column in query.get("groupby") or []:
+ if error := column_error(column, f"query {query_index} groupby
column"):
+ return error
+ for level in query.get("grouping_sets") or []:
+ for column in level:
Review Comment:
A Pivot with a Custom SQL dimension such as `UPPER(region)` labeled
`RegionUpper` and totals enabled is rejected on a compatible dataset-only
rebind because its grouping-set label is checked as a physical column. Could
grouping-set references resolve against the selected logical column labels, as
the native query path does?
##########
superset/mcp_service/chart/chart_helpers.py:
##########
@@ -809,10 +1739,551 @@ def build_mixed_timeseries_secondary(
return qd
-# Deck.gl viz types that conditionally set is_timeseries from time_grain_sqla
-_DECK_TIMESERIES_VIZ_TYPES: frozenset[str] = frozenset(
- {"deck_arc", "deck_path", "deck_polygon", "deck_scatter",
"deck_screengrid"}
-)
+def build_histogram_query_dicts(
+ form_data: dict[str, Any],
+ *,
+ engine: str,
+ row_limit: int | None,
+ order_desc: bool | None,
+) -> list[dict[str, Any]]:
+ """Render Histogram buildQuery, including its histogram post-processing."""
+ column = form_data.get("column")
+ histogram_groupby = _as_list(form_data.get("groupby"))
+ query = build_single_query_dict(
+ form_data,
+ [*histogram_groupby, column] if column is not None else
histogram_groupby,
+ [],
+ row_limit=row_limit,
+ order_desc=order_desc,
+ )
+ having_filter = bool(form_data.get("having")) or any(
+ isinstance(filter_, dict) and filter_.get("clause") == "HAVING"
+ for filter_ in form_data.get("adhoc_filters") or []
+ )
+ if having_filter:
+ query["metrics"] = [
+ {
+ "expressionType": "SQL",
+ "sqlExpression": "COUNT(*)",
+ "label": "COUNT(*)",
+ }
+ ]
+ bins = form_data.get("bins", 5)
+ try:
+ parsed_bins = float(bins)
+ parsed_bins = int(parsed_bins) if parsed_bins.is_integer() else
parsed_bins
+ except (TypeError, ValueError):
+ parsed_bins = 5
+ query["post_processing"] = [
+ {
+ "operation": "histogram",
+ "options": {
+ "column": _column_label(column),
+ "groupby": [
+ label
+ for item in histogram_groupby
+ if (label := _column_label(item))
+ ],
+ "bins": parsed_bins,
+ "cumulative": bool(form_data.get("cumulative")),
+ "normalize": bool(form_data.get("normalize")),
+ },
+ }
+ ]
+ return [query]
+
+
+def build_box_plot_query_dicts( # noqa: C901
+ form_data: dict[str, Any],
+ *,
+ engine: str,
+ row_limit: int | None,
+ order_desc: bool | None,
+) -> list[dict[str, Any]]:
+ """Render Box Plot buildQuery, including its boxplot post-processing."""
+ distribute = _as_list(form_data.get("columns"))
+ if not distribute and form_data.get("granularity_sqla"):
+ distribute = [form_data["granularity_sqla"]]
+ box_groupby = _as_list(form_data.get("groupby"))
+ query = build_single_query_dict(
+ form_data,
+ [
+ *(_temporal_column(column, form_data) for column in distribute),
+ *box_groupby,
+ ],
+ list(form_data.get("metrics") or []),
+ row_limit=row_limit,
+ order_desc=order_desc,
+ )
+ query["series_columns"] = box_groupby
+ if whisker := form_data.get("whiskerOptions"):
+ whisker_type = "tukey"
+ percentiles: list[int] | None = None
+ if whisker == "Min/max (no outliers)":
+ whisker_type = "min/max"
+ elif match := re.fullmatch(r"(\d{1,3})/(\d{1,3}) percentiles",
str(whisker)):
+ whisker_type = "percentile"
+ percentiles = [int(match.group(1)), int(match.group(2))]
+ elif whisker != "Tukey":
+ raise ValueError(f"Unsupported whisker type: {whisker}")
+ query["post_processing"] = [
+ {
+ "operation": "boxplot",
+ "options": {
+ "whisker_type": whisker_type,
+ "percentiles": percentiles,
+ "groupby": [
+ label
+ for column in box_groupby
+ if (label := _column_label(column))
+ ],
+ "metrics": [
+ label
+ for metric in query["metrics"]
+ if (label := _metric_label(metric))
+ ],
+ },
+ }
+ ]
+ return [query]
+
+
+def build_pivot_table_query_dicts(
+ form_data: dict[str, Any],
+ *,
+ engine: str,
+ row_limit: int | None,
+ order_desc: bool | None,
+) -> list[dict[str, Any]]:
+ """Render Pivot Table buildQuery, including subtotal grouping sets."""
+ rows = _as_list(form_data.get("groupbyRows"))
+ pivot_columns = _as_list(form_data.get("groupbyColumns"))
+ if form_data.get("transposePivot"):
+ rows, pivot_columns = pivot_columns, rows
+ columns = _dedupe_query_fields([*rows, *pivot_columns], _column_label)
+ query = build_single_query_dict(
+ form_data,
+ [_temporal_column(column, form_data) for column in columns],
+ list(form_data.get("metrics") or []),
+ row_limit=row_limit,
+ order_desc=order_desc,
+ )
+ sort_metric = query.get("series_limit_metric")
+ if sort_metric is None and query["metrics"]:
+ sort_metric = query["metrics"][0]
+ if sort_metric is not None:
+ query["orderby"] = [[sort_metric, not query.get("order_desc", True)]]
+ if grouping_sets := _pivot_grouping_sets(form_data, rows, pivot_columns):
+ query["grouping_sets"] = grouping_sets
+ return [query]
+
+
+def build_pie_query_dicts(
+ form_data: dict[str, Any],
+ *,
+ contribution: bool,
+ engine: str,
+ row_limit: int | None,
+ order_desc: bool | None,
+) -> list[dict[str, Any]]:
+ """Render Pie/Sunburst buildQuery; Pie adds a contribution operator."""
+ metric = form_data.get("metric")
+ query = build_single_query_dict(
+ form_data,
+ _as_list(form_data.get("groupby")),
+ [metric] if metric is not None else [],
+ row_limit=row_limit,
+ order_desc=order_desc,
+ orderby=form_data.get("orderby"),
+ )
+ if form_data.get("sort_by_metric") and metric is not None:
+ query["orderby"] = [[metric, False]]
+ if contribution and (label := _metric_label(metric)):
+ query["post_processing"] = [
+ {
+ "operation": "contribution",
+ "options": {
+ "columns": [label],
+ "rename_columns": [f"{label}__contribution"],
+ },
+ }
+ ]
+ return [query]
+
+
+def _positive_int(value: Any) -> int:
+ """Coerce a stored limit (int, numeric string, or empty) to a positive int
or 0."""
+ try:
+ coerced = int(value)
+ except (TypeError, ValueError):
+ return 0
+ return coerced if coerced > 0 else 0
+
+
+def build_table_query_dicts( # noqa: C901
+ form_data: dict[str, Any],
+ *,
+ engine: str,
+ row_limit: int | None,
+ order_desc: bool | None,
+) -> list[dict[str, Any]]:
+ """Render Table buildQuery: percent metrics, comparisons, totals,
paging."""
+ raw_mode = form_data.get("query_mode") == "raw" or (
+ form_data.get("query_mode") not in {"raw", "aggregate"}
+ and bool(form_data.get("all_columns"))
+ )
+ table_columns = list(
Review Comment:
A saved aggregate Table with `groupby: "region"` now reconstructs columns as
`["r", "e", "g", "i", "o", "n"]`, making previews and fallback data/SQL reads
fail; scalar `metrics` is split the same way. Could these roles use the
existing scalar-to-list normalization rather than `list(...)`?
##########
superset/mcp_service/chart/preview_utils.py:
##########
@@ -339,6 +414,554 @@ def _generate_safe_ascii_bar_chart(data: List[Dict[str,
Any]]) -> str:
return "\n".join(lines)
+def _form_metric_label(metric: Any) -> str | None:
+ """Return the result-column label for a native QueryFormMetric."""
+ if type(metric) is str:
+ return metric
+ if type(metric) is not dict:
+ return None
+ if label := dict.get(metric, "label"):
+ return label if type(label) is str else None
+ if dict.get(metric, "expressionType") == "SQL":
+ expression = dict.get(metric, "sqlExpression")
+ return expression if type(expression) is str and expression else None
+ column = dict.get(metric, "column")
+ column_name = dict.get(column, "column_name") if type(column) is dict else
column
+ aggregate = dict.get(metric, "aggregate")
+ if type(column_name) is str and type(aggregate) is str:
+ return f"{aggregate}({column_name})"
+ return None
+
+
+def _form_column_label(column: Any) -> str | None:
+ """Return the result-column label for a native QueryFormColumn."""
+ if type(column) is str:
+ return column
+ if type(column) is not dict:
+ return None
+ for key in ("label", "column_name"):
+ if type(value := dict.get(column, key)) is str and value:
+ return value
+ return None
+
+
+def _canonical_result_field(label: str | None, row: Dict[str, Any]) -> str |
None:
+ """Resolve an exact or one unambiguous casefold result-field match."""
+ if label is None:
+ return None
+ if label in dict.keys(row):
+ return label
+ matches = [
+ field
+ for field in dict.keys(row)
+ if type(field) is str and field.casefold() == label.casefold()
+ ]
+ return matches[0] if len(matches) == 1 else None
+
+
+def _require_result_field(label: str | None, row: dict[str, Any], role: str)
-> str:
+ """Resolve a role without falling back to an unrelated result field."""
+ if not label:
+ raise BulletOutputError(f"Bullet {role} has no declared result alias")
+ if label in dict.keys(row):
+ return label
+ matches = sorted(
+ field
+ for field in dict.keys(row)
+ if type(field) is str and field.casefold() == label.casefold()
+ )
+ if len(matches) == 1:
+ return matches[0]
+ if matches:
+ raise BulletOutputError(
+ f"Bullet {role} alias {label!r} is ambiguous; candidates: "
+ f"{', '.join(matches)}"
+ )
+ raise BulletOutputError(
+ f"Bullet {role} alias {label!r} is missing from query output"
+ )
+
+
+def _safe_enum_backing(value: Any) -> Any:
+ """Extract Enum's stored value without public descriptors/conversions."""
+ value_type = type(value)
+ try:
+ mro = type.__getattribute__(value_type, "__mro__")
+ except (AttributeError, TypeError): # pragma: no cover - normal types
have MRO
+ return value
+ if type(mro) is not tuple or not any(base is Enum for base in mro):
+ return value
+ try:
+ backing = object.__getattribute__(value, "_value_")
+ except Exception as ex:
+ raise BulletOutputError("Bullet output contains an unreadable enum")
from ex
+ if not any(type(backing) is allowed for allowed in _ENUM_SCALAR_TYPES):
+ raise BulletOutputError("Bullet output contains an unsupported enum
value")
+ return backing
+
+
+def _decimal_javascript_string(value: Decimal) -> str:
+ """Render an exact binary64 spelling with JavaScript Number thresholds."""
+ sign, digits_tuple, exponent = Decimal.as_tuple(value)
+ if type(exponent) is not int: # finite Decimals always have an integer
exponent
+ raise BulletOutputError("Bullet dimension contains a non-finite
Decimal")
+ if not any(digits_tuple):
+ return "0"
+
+ digits = "".join(str(digit) for digit in digits_tuple)
+ adjusted = len(digits) + exponent - 1
+ prefix = "-" if sign else ""
+ if -6 <= adjusted < 21:
+ point = len(digits) + exponent
+ if point <= 0:
+ text = f"0.{('0' * -point)}{digits}"
+ elif point >= len(digits):
+ text = digits + ("0" * (point - len(digits)))
+ else:
+ text = f"{digits[:point]}.{digits[point:]}"
+ if "." in text:
+ text = text.rstrip("0").rstrip(".")
+ return prefix + text
+
+ fraction = digits[1:].rstrip("0")
+ coefficient = digits[0] + (f".{fraction}" if fraction else "")
+ exponent_text = f"+{adjusted}" if adjusted >= 0 else str(adjusted)
+ return f"{prefix}{coefficient}e{exponent_text}"
+
+
+def _javascript_number_string(value: int | float | Decimal) -> str:
+ """Apply JSON-number -> IEEE-754 Number -> JavaScript String semantics.
+
+ Exact result scalars can retain precision that the frontend cannot: JSON
+ parsing first rounds a numeric token to binary64, and ``String`` then emits
+ the shortest round-tripping decimal with fixed notation for exponents in
+ [-6, 20]. Converting exact builtin scalars to an exact builtin float keeps
+ the path hook-free. Python and JavaScript use the same shortest
+ round-tripping binary64 digits; ``_decimal_javascript_string`` only adjusts
+ the notation thresholds and exponent spelling.
+
+ A finite integer or Decimal outside binary64's range becomes an infinity
+ after JSON parsing, matching JavaScript. Non-finite source values are
+ rejected by the trusted scalar normalizer before this helper is called.
+ """
+ value_type = type(value)
+ if value_type not in {int, float, Decimal}:
+ raise BulletOutputError("Bullet dimension contains an unsupported
number")
+ if value_type is float and not math.isfinite(value):
+ raise BulletOutputError("Bullet dimension contains a non-finite
number")
+ if isinstance(value, Decimal) and not Decimal.is_finite(value):
+ raise BulletOutputError("Bullet dimension contains a non-finite
Decimal")
+ try:
+ number = float(value)
+ except OverflowError:
+ number = -math.inf if value < 0 else math.inf
+
+ if math.isinf(number):
+ return "-Infinity" if number < 0 else "Infinity"
+ if number == 0:
+ # String(-0) is "0" even though JSON.parse preserves negative zero.
+ return "0"
+ return _decimal_javascript_string(Decimal(float.__repr__(number)))
+
+
+def _bullet_category_value( # noqa: C901
+ value: Any, dimension: str, row_index: int
+) -> tuple[Any, str]:
+ """Return a JSON-safe value and bounded frontend ``String(value)`` text.
+
+ The trusted scalar normalizer is type-exact and does not dispatch through
+ application hooks. Vega data retains the normalized Chart Data wire value
+ (including epoch-ms temporal numbers); only the derived category key and
+ ASCII label use the JavaScript-compatible text.
+ """
+ from superset.mcp_service.chart.query_result import (
+ _bounded_utf8_length,
+ _chart_data_duration_text,
+ _chart_data_temporal_number,
+ _is_chart_data_duration_scalar,
+ _is_chart_data_temporal_scalar,
+ _normalize_trusted_scalar,
+ )
+
+ normalized: Any
+ reason: str | None
+ if _is_chart_data_temporal_scalar(value):
+ normalized, reason = _chart_data_temporal_number(value)
+ elif _is_chart_data_duration_scalar(value):
+ normalized, reason = _chart_data_duration_text(value)
+ else:
+ normalized, reason = _normalize_trusted_scalar(
Review Comment:
A Bullet grouped by an array-valued column returns values such as `[1, 2]`
that pass the bounded result walker but are rejected by this scalar-only
dimension conversion, breaking saved data reads and exports as well as
previews. Could bounded container dimensions retain their raw values and use
the frontend-compatible category text (`"1,2"` for this array)?
##########
superset/mcp_service/chart/chart_helpers.py:
##########
@@ -589,23 +591,518 @@ def resolve_big_number_columns(form_data: dict[str,
Any]) -> list[Any]:
return [granularity] if isinstance(granularity, str) and granularity else
[]
-def resolve_gantt_query_fields( # noqa: C901
- form_data: dict[str, Any],
-) -> tuple[list[Any], list[Any], list[list[Any]], list[Any]]:
- """Mirror the ECharts Gantt ``buildQuery`` field extraction contract.
+def extract_x_axis_col(form_data: dict[str, Any]) -> str | None:
+ """Return the x_axis column name from form_data, or None if not set."""
+ x_axis = form_data.get("x_axis")
+ if isinstance(x_axis, str) and x_axis:
+ return x_axis
+ if isinstance(x_axis, dict):
+ col_name = x_axis.get("column_name")
+ return col_name if isinstance(col_name, str) and col_name else None
+ return None
+
- Returns ``(columns, metrics, orderby, series_columns)``. Saved form data is
- user-editable, so malformed or oversized native ordering is rejected rather
- than silently dropped or passed into ``QueryContextFactory``.
+def _x_axis_query_field(form_data: dict[str, Any]) -> Any | None:
+ """Resolve a frontend x-axis value without losing SQL expressions."""
+ x_axis = form_data.get("x_axis")
+ if isinstance(x_axis, str) and x_axis:
+ return x_axis
+ if isinstance(x_axis, dict):
+ if (
+ isinstance(x_axis.get("sqlExpression"), str)
+ and x_axis.get("sqlExpression")
+ and isinstance(x_axis.get("label"), str)
+ and x_axis.get("label")
+ and x_axis.get("expressionType") in (None, "SQL")
+ ):
+ return x_axis
+ column_name = x_axis.get("column_name") or x_axis.get("columnName")
+ if isinstance(column_name, str) and column_name:
+ return column_name
+ return None
+
+
+def _normalized_x_axis_query_field(form_data: dict[str, Any]) -> Any | None:
+ """Mirror ``buildQueryContext.normalizeTimeColumn`` for a set x-axis."""
+ x_axis = _x_axis_query_field(form_data)
+ if x_axis is None:
+ return None
+ time_grain = form_data.get("time_grain_sqla")
+ if isinstance(x_axis, str):
+ normalized = {
+ "columnType": "BASE_AXIS",
+ "sqlExpression": x_axis,
+ "label": x_axis,
+ "expressionType": "SQL",
+ "isColumnReference": True,
+ }
+ if time_grain is not None:
+ normalized["timeGrain"] = time_grain
+ return normalized
+ normalized = {"columnType": "BASE_AXIS", **x_axis}
+ # The original adhoc column's grain overrides the common control, matching
+ # the frontend spread order.
+ if "timeGrain" not in normalized and time_grain is not None:
+ normalized["timeGrain"] = time_grain
+ return normalized
+
+
+def _resolve_big_number_query_columns(form_data: dict[str, Any]) -> list[Any]:
+ """Resolve only Big Number's explicit x-axis query column.
+
+ The frontend keeps ``granularity_sqla`` out of ``columns`` and asks the
+ backend for a timeseries instead, which yields ``__timestamp``. An explicit
+ ``x_axis`` is different: the plugin retains that column in the final query.
"""
- from superset.utils import json as utils_json
+ if (x_axis := _normalized_x_axis_query_field(form_data)) is not None:
+ return [x_axis]
+ return []
+
+
+def _as_list(value: Any) -> list[Any]:
+ """Match the frontend's ``ensureIsArray`` for query controls."""
+ if value is None:
+ return []
+ return value if isinstance(value, list) else [value]
+
+
+def _column_label(column: Any) -> str | None:
+ """Return the frontend ``getColumnLabel`` value for a query column."""
+ if isinstance(column, str):
+ return column
+ if not isinstance(column, dict):
+ return None
+ return (
+ column.get("label") or column.get("sqlExpression") or
column.get("column_name")
+ )
+
+
+def _metric_label(metric: Any) -> str | None:
+ """Return the frontend ``getMetricLabel`` value for a query metric."""
+ if isinstance(metric, str):
+ return metric
+ if not isinstance(metric, dict):
+ return None
+ if label := metric.get("label"):
+ return label
+ if metric.get("expressionType") == "SIMPLE":
+ column = metric.get("column") or {}
+ name = (
+ column.get("columnName") or column.get("column_name")
+ if isinstance(column, dict)
+ else None
+ )
+ if name and metric.get("aggregate"):
+ return f"{metric['aggregate']}({name})"
+ return metric.get("sqlExpression")
+
+
+def _is_query_form_metric(value: Any) -> bool:
+ """Mirror the frontend's ``isQueryFormMetric`` type guard."""
+ return isinstance(value, str) or (
+ isinstance(value, dict) and value.get("expressionType") in {"SIMPLE",
"SQL"}
+ )
+
+
+def _timeseries_base_metrics(form_data: dict[str, Any]) -> list[Any]:
+ """Return metrics extracted by the common frontend query-field aliases."""
Review Comment:
A saved timeseries chart using the backward-compatible singular `metric:
"revenue"` now reconstructs with no metrics, so its MCP preview and fallback
data/SQL query lose the measure. Could this preserve the singular metric alias
supported by both the previous resolver and frontend `extractQueryFields`?
##########
superset/mcp_service/chart/schemas.py:
##########
@@ -2713,6 +2820,465 @@ def validate_unique_column_labels(self) ->
"XYChartConfig":
return self
+class BulletChartConfig(BaseChartConfig):
+ """Config for bullet charts (viz_type ``bullet``)."""
+
+ # Semantic field names are exposed to MCP clients; validation aliases and
the
+ # native adapter accept saved Explore ``form_data`` without weakening the
+ # unknown-field checks that catch misspelled controls.
+ model_config = ConfigDict(extra="ignore", populate_by_name=True)
+
+ chart_type: Literal["bullet"] = "bullet"
+ metric: ColumnRef = Field(
+ ...,
+ description="Numeric measure shown by each bullet bar",
+ )
+ dimensions: List[ColumnRef] | None = Field(
+ None,
+ validation_alias=AliasChoices("dimensions", "groupby"),
+ description=(
+ "Category hierarchy; one bullet row per unique combination. Omit
to "
+ "keep the saved hierarchy on update; [] clears it."
+ ),
+ max_length=20,
+ )
+ filters: List[FilterConfig] | None = Field(None, max_length=100)
+ time_range: str | None = Field(
+ None,
+ min_length=1,
+ max_length=1000,
+ description=(
+ "Superset time range, e.g. 'Last 30 days' or '2025-01-01 :
2025-12-31'"
+ ),
+ )
+ row_limit: int = Field(
+ 10000,
+ ge=1,
+ le=50000,
+ description="Maximum bullet rows",
+ )
+ order_by: List[SortByConfig] = Field(
+ default_factory=list,
+ validation_alias=AliasChoices("order_by", "orderby", "order_by_cols"),
+ max_length=20,
+ description="Row order by a dimension name or the metric output
label/name",
+ )
+
+ # Presentation fields map one-for-one onto Bullet/transformProps.ts
controls.
+ ranges: List[float] = Field(
+ default_factory=list,
+ max_length=100,
+ description="Qualitative range thresholds shaded behind the measure",
+ )
+ range_labels: List[str] = Field(
+ default_factory=list,
+ validation_alias=AliasChoices("range_labels", "rangeLabels"),
+ max_length=100,
+ )
+ markers: List[float] = Field(
+ default_factory=list,
+ max_length=100,
+ description="Target values drawn as point markers",
+ )
+ marker_labels: List[str] = Field(
+ default_factory=list,
+ validation_alias=AliasChoices("marker_labels", "markerLabels"),
+ max_length=100,
+ )
+ marker_lines: List[float] = Field(
+ default_factory=list,
+ validation_alias=AliasChoices("marker_lines", "markerLines"),
+ max_length=100,
+ description="Reference values drawn as vertical lines",
+ )
+ marker_line_labels: List[str] = Field(
+ default_factory=list,
+ validation_alias=AliasChoices("marker_line_labels",
"markerLineLabels"),
+ max_length=100,
+ )
+ y_axis_format: str = Field(
+ "SMART_NUMBER",
+ validation_alias=AliasChoices("y_axis_format", "yAxisFormat"),
+ max_length=100,
+ )
+ show_labels: bool = Field(
+ False,
+ validation_alias=AliasChoices("show_labels", "showLabels"),
+ )
+ show_legend: bool = Field(
+ False,
+ validation_alias=AliasChoices("show_legend", "showLegend"),
+ )
+
+ @staticmethod
+ def _adapt_native_metric(value: Any) -> Any:
+ """Translate QueryFormMetric shapes into the shared ColumnRef
contract."""
+ if isinstance(value, str):
+ return {"name": value, "saved_metric": True}
+ if not isinstance(value, dict):
+ return value
+ if "expressionType" not in value:
+ # QueryObject's documented legacy saved-metric representation is a
+ # label-only object. Keep this adapter deliberately narrow: objects
+ # carrying ad-hoc fields must declare expressionType explicitly,
and
+ # semantic ColumnRef objects continue through normal validation.
+ if set(value) == {"label"}:
+ label = value["label"]
+ if not isinstance(label, str) or not label or len(label) > 255:
+ raise ValueError(
+ "legacy saved metric label must be a non-empty string
of "
+ "at most 255 characters"
+ )
+ return {"name": label, "saved_metric": True}
+ return value
+ expression_type = value.get("expressionType")
+ if expression_type == "SQL":
+ return {
+ "sql_expression": value.get("sqlExpression"),
+ "label": value.get("label"),
Review Comment:
A saved Bullet SQL metric without an explicit label previews correctly using
its SQL expression as the result key, but this adapter supplies `label: None`,
so a compatible dataset-only update fails `ColumnRef` validation before saving
or previewing. Could native SQL metrics retain the expression-as-label fallback
here too?
--
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]