sadpandajoe commented on code in PR #43771:
URL: https://github.com/apache/superset/pull/43771#discussion_r4191416874
##########
superset/mcp_service/utils/serialization.py:
##########
@@ -170,24 +179,30 @@ def _sanitize_other(value: Any, depth: int) -> Any:
# FastMCP's ``fallback=str``, which the structured-content path
# (``to_jsonable_python``) does not apply.
return _sanitize_str(str(value))
- return sanitize_json_value(converted, depth + 1)
+ return sanitize_json_value(
+ converted, depth + 1, preserve_decimals=preserve_decimals
+ )
-def sanitize_json_value(value: Any, depth: int = 0) -> Any:
+def sanitize_json_value(
+ value: Any, depth: int = 0, *, preserve_decimals: bool = False
+) -> Any:
"""Return ``value`` with anything ``pydantic_core.to_json`` cannot encode
replaced by a JSON-safe equivalent."""
+ if preserve_decimals and type(value) is Decimal:
Review Comment:
Keeping exact `Decimal`s changes the wire format for every chart type that
uses `ExactJsonSafeRows`: a Table or Pie cell of `Decimal("12.50")` previously
came back from `get_chart_data` as the number `12.5` and now comes back as the
string `"12.50"` (Pydantic's JSON mode), in `data`, `query_results[].data` and
the column samples, while `schema_version` stays `"2.0"`. Clients that sum or
chart those values will receive strings, and `query_dataset`/`get_table` rows
still return numbers.
Is the string form intentional for existing chart types, and if so should it
be called out in the docs/schema version, or should finite Decimals keep
serializing as numbers?
##########
superset/common/form_data_query_context.py:
##########
@@ -288,71 +461,1272 @@ def _pie_contribution_post_processing(metrics:
list[Any]) -> list[dict[str, Any]
]
-def build_query_context_from_form_data(
- form_data: dict[str, Any],
- datasource: dict[str, Any],
- viz_type: str | None = None,
+def _as_list(value: Any) -> list[Any]:
+ """Return the frontend ``ensureIsArray`` representation of a value."""
+ if value is None:
+ return []
+ return list(value) if isinstance(value, (list, tuple)) else [value]
+
+
+def _label(value: Any, *, metric: bool = False) -> str:
+ """Resolve a frontend-compatible query-field label."""
+ try:
+ return get_metric_name(value) if metric else get_column_name(value)
+ except (AttributeError, KeyError, TypeError, ValueError):
+ if isinstance(value, Mapping):
+ return str(
+ value.get("label")
+ or value.get("column_name")
+ or value.get("sqlExpression")
+ or value
+ )
+ return str(value)
+
+
+def _deduplicate_fields(values: list[Any], *, metric: bool = False) ->
list[Any]:
+ """Deduplicate query fields by their frontend-visible label."""
+ result: list[Any] = []
+ labels: set[str] = set()
+ for value in values:
+ if value is None or value == "":
+ continue
+ label = _label(value, metric=metric)
+ if label in labels:
+ continue
+ labels.add(label)
+ result.append(value)
+ return result
+
+
+def retain_mixed_timeseries_secondary_form_data(
+ form_data: Mapping[str, Any],
) -> dict[str, Any]:
- """
- Build a query-context payload (the JSON shape
``ChartDataQueryContextSchema``
- loads) from a chart's form data and datasource reference.
+ """Mirror ``retainFormDataSuffix(formData, '_b')`` exactly.
- :param form_data: The chart's saved ``params`` parsed to a dict.
- :param datasource: ``{"id": <int>, "type": "table"}`` datasource reference.
- :param viz_type: The chart's viz type, used for viz-specific handling.
- :returns: A single-query query-context dict.
+ Suffixed values are installed first, including falsey values, and shared
+ unsuffixed controls fill only keys that query B did not explicitly set.
"""
- columns, metrics = _columns_and_metrics(form_data, viz_type)
-
- # SIMPLE adhoc filters (+ legacy top-level ``filters``) become query
filters;
- # free-form SQL predicates go into ``extras``. Only ``WHERE``-clause SIMPLE
- # filters are applied (matching the chart), so the export never filters on
a
- # ``HAVING`` clause the chart itself ignores.
- filters = adhoc_filters_to_query_filters(
- form_data.get("adhoc_filters", []), where_only=True
- )
- for flt in form_data.get("filters") or []:
- if isinstance(flt, dict) and flt.get("col") is not None:
- filters.append(flt)
+ secondary: dict[str, Any] = {}
+ for key, value in form_data.items():
+ if key.endswith("_b"):
+ secondary[key[:-2]] = value
+ for key, value in form_data.items():
+ if not key.endswith("_b") and key not in secondary:
+ secondary[key] = value
+ secondary_filter_keys = {
+ "adhoc_filters": "adhoc_filters_b",
+ "extra_filters": "extra_filters_b",
+ "filters": "filters_b",
+ "having": "having_b",
+ "where": "where_b",
+ }
+ if any(suffixed in form_data for suffixed in
secondary_filter_keys.values()):
+ # The frontend exposes adhoc_filters_b, while saved/server payloads can
+ # carry equivalent legacy aliases. Treat the family atomically: an
+ # explicit clear in any B alias must not be repopulated by query A's
+ # differently named filter representation.
+ for primary, suffixed in secondary_filter_keys.items():
+ if suffixed not in form_data:
+ secondary.pop(primary, None)
+ return secondary
- extras = freeform_where_having(form_data)
- if form_data.get("time_grain_sqla"):
- extras["time_grain_sqla"] = form_data["time_grain_sqla"]
- # Prefer the modern ``time_range``; fall back to the legacy
``since``/``until``
- # pair (older charts store the range that way) before defaulting to no
filter.
- time_range = form_data.get("time_range")
- if not time_range and (form_data.get("since") or form_data.get("until")):
- time_range = f"{form_data.get('since') or ''} :
{form_data.get('until') or ''}"
- time_range = time_range or "No filter"
+def _base_query_object( # noqa: C901
+ form_data: dict[str, Any],
+ *,
+ row_limit: int | None,
+ order_desc: bool | None,
+ filters_prepared: bool,
+) -> dict[str, Any]:
+ """Build the shared frontend-equivalent portion of a QueryObject."""
+ columns, metrics, orderby = query_fields_from_form_data(form_data)
query: dict[str, Any] = {
"columns": columns,
"metrics": metrics,
- "orderby": orderby_from_form_data(form_data, metrics, viz_type),
- "filters": filters,
- "time_range": time_range,
}
+ if orderby:
+ query["orderby"] = orderby
+
+ if filters_prepared:
+ query["filters"] = list(form_data.get("filters") or [])
+ for clause in ("where", "having"):
+ if form_data.get(clause):
+ query[clause] = form_data[clause]
+ if form_data.get("extras"):
+ query["extras"] = dict(form_data["extras"])
+ else:
+ filters = adhoc_filters_to_query_filters(
+ form_data.get("adhoc_filters", []), where_only=True
+ )
+ filters.extend(
+ filter_
+ for filter_ in form_data.get("filters") or []
+ if isinstance(filter_, dict) and filter_.get("col") is not None
+ )
+ query["filters"] = filters
+ if extras := freeform_where_having(form_data):
+ query["extras"] = extras
+
+ extras = dict(query.get("extras") or {})
+ if form_data.get("time_grain_sqla") is not None:
+ extras["time_grain_sqla"] = form_data["time_grain_sqla"]
if extras:
query["extras"] = extras
- if viz_type == "pie" and (
- post_processing := _pie_contribution_post_processing(metrics)
+
+ effective_limit = row_limit if row_limit is not None else
form_data.get("row_limit")
+ if effective_limit is not None:
+ query["row_limit"] = effective_limit
+ if form_data.get("row_offset") is not None:
+ query["row_offset"] = form_data["row_offset"]
+ if order_desc is not None:
+ query["order_desc"] = order_desc
+ elif "order_desc" in form_data and form_data["order_desc"] is not None:
+ query["order_desc"] = form_data["order_desc"]
+
+ time_range = form_data.get("time_range")
+ if not time_range and (form_data.get("since") or form_data.get("until")):
+ time_range = f"{form_data.get('since') or ''} :
{form_data.get('until') or ''}"
+ if time_range:
+ query["time_range"] = time_range
+ for key in ("since", "until", "annotation_layers", "url_params",
"custom_params"):
+ if form_data.get(key) is not None:
+ query[key] = form_data[key]
+
+ granularity = form_data.get("granularity") or
form_data.get("granularity_sqla")
+ if granularity:
+ query["granularity"] = granularity
+ series_limit = form_data.get("series_limit", form_data.get("limit"))
+ if series_limit is not None:
+ query["series_limit"] = series_limit
+ series_limit_metric = form_data.get("series_limit_metric")
+ if series_limit_metric is None:
+ series_limit_metric = form_data.get("timeseries_limit_metric")
+ if series_limit_metric is not None:
+ query["series_limit_metric"] = series_limit_metric
+ if form_data.get("group_others_when_limit_reached") is not None:
+ query["group_others_when_limit_reached"] = form_data[
+ "group_others_when_limit_reached"
+ ]
+ return query
+
+
+def _temporalized_columns(form_data: dict[str, Any], columns: list[Any]) ->
list[Any]:
+ """Apply the pivot BASE_AXIS temporal-column contract."""
+ time_grain = form_data.get("time_grain_sqla")
+ temporal_lookup = form_data.get("temporal_columns_lookup") or {}
+ result: list[Any] = []
+ for column in columns:
+ if (
+ isinstance(column, str)
+ and time_grain
+ and (
+ temporal_lookup.get(column)
+ or form_data.get("granularity_sqla") == column
+ )
+ ):
+ result.append(
+ {
+ "timeGrain": time_grain,
+ "columnType": "BASE_AXIS",
+ "sqlExpression": column,
+ "label": column,
+ "expressionType": "SQL",
+ }
+ )
+ else:
+ result.append(column)
+ return result
+
+
+def _box_temporalized_columns(
+ form_data: dict[str, Any], columns: list[Any]
+) -> list[Any]:
+ """Convert only physical columns confirmed temporal by Box Plot
metadata."""
+ time_grain = form_data.get("time_grain_sqla")
+ temporal_lookup = form_data.get("temporal_columns_lookup")
+ if not time_grain or not isinstance(temporal_lookup, Mapping):
+ return columns
+ return [
+ {
+ "timeGrain": time_grain,
+ "columnType": "BASE_AXIS",
+ "sqlExpression": column,
+ "label": column,
+ "expressionType": "SQL",
+ }
+ if isinstance(column, str) and temporal_lookup.get(column) is True
+ else column
+ for column in columns
+ ]
+
+
+def _table_temporalized_columns(
+ form_data: dict[str, Any], columns: list[Any]
+) -> list[Any]:
+ """Promote the first temporal table group-by to the frontend BASE_AXIS.
+
+ Table's builder treats only physical columns named in
+ ``temporal_columns_lookup`` as temporal and moves the first match to the
+ front. Later temporal dimensions remain ordinary group-bys.
+ """
+ time_grain = form_data.get("time_grain_sqla")
+ temporal_lookup = form_data.get("temporal_columns_lookup") or {}
+ if not time_grain or not isinstance(temporal_lookup, Mapping):
+ return columns
+
+ temporal_column: dict[str, Any] | None = None
+ remaining: list[Any] = []
+ for column in columns:
+ if (
+ temporal_column is None
+ and isinstance(column, str)
+ and temporal_lookup.get(column)
+ ):
+ temporal_column = {
+ "timeGrain": time_grain,
+ "columnType": "BASE_AXIS",
+ "sqlExpression": column,
+ "label": column,
+ "expressionType": "SQL",
+ }
+ else:
+ remaining.append(column)
+ return [temporal_column, *remaining] if temporal_column else columns
+
+
+def _histogram_query(form_data: dict[str, Any], query: dict[str, Any]) -> None:
+ groupby = _as_list(form_data.get("groupby"))
+ column = form_data.get("column")
+ query["columns"] = [*groupby, *([column] if column is not None else [])]
+ query["post_processing"] = [
+ {
+ "operation": "histogram",
+ "options": {
+ "column": _label(column),
+ "groupby": [_label(value) for value in groupby],
+ "bins": int(form_data.get("bins", 5)),
+ "cumulative": form_data.get("cumulative", False),
+ "normalize": form_data.get("normalize", False),
+ },
+ }
+ ]
+ if any(
+ isinstance(filter_, dict) and filter_.get("clause") == "HAVING"
+ for filter_ in form_data.get("adhoc_filters") or []
+ ):
+ query["metrics"] = [
+ {
+ "expressionType": "SQL",
+ "sqlExpression": "COUNT(*)",
+ "label": "COUNT(*)",
+ }
+ ]
+ else:
+ query["metrics"] = []
+
+
+def _box_plot_query(form_data: dict[str, Any], query: dict[str, Any]) -> None:
+ distributed = _as_list(form_data.get("columns"))
+ if not distributed and form_data.get("granularity_sqla"):
+ distributed = [form_data["granularity_sqla"]]
+ groupby = _as_list(form_data.get("groupby"))
+ query["columns"] = [*_box_temporalized_columns(form_data, distributed),
*groupby]
+ query["series_columns"] = groupby
+ whisker = form_data.get("whiskerOptions")
+ if not whisker:
+ query["post_processing"] = []
+ return
+ whisker_type = "tukey"
+ percentiles: list[int] | None = None
+ if whisker == "Min/max (no outliers)":
+ whisker_type = "min/max"
+ elif isinstance(whisker, str) and whisker.endswith(" percentiles"):
+ low, high = whisker.removesuffix(" percentiles").split("/", 1)
+ whisker_type = "percentile"
+ percentiles = [int(low), int(high)]
+ query["post_processing"] = [
+ {
+ "operation": "boxplot",
+ "options": {
+ "whisker_type": whisker_type,
+ "percentiles": percentiles,
+ "groupby": [_label(value) for value in groupby],
+ "metrics": [_label(value, metric=True) for value in
query["metrics"]],
+ },
+ }
+ ]
+
+
+_PIVOT_ADDITIVE_AGGREGATES = frozenset({"SUM", "COUNT", "MIN", "MAX"})
+
+
+def _all_metrics_additive(metrics: list[Any]) -> bool:
+ """Mirror Pivot's conservative additive-metric fast-path."""
+ return bool(metrics) and all(
+ isinstance(metric, Mapping)
+ and metric.get("expressionType") == "SIMPLE"
+ and metric.get("aggregate") in _PIVOT_ADDITIVE_AGGREGATES
+ for metric in metrics
+ )
+
+
+def _pivot_grouping_sets(
+ form_data: dict[str, Any], rows: list[Any], columns: list[Any]
+) -> list[list[str]]:
+ """Enumerate the rollup levels requested by Pivot's frontend builder."""
+ row_prefixes = [[], *(rows[: index + 1] for index in range(len(rows)))]
+ column_prefixes = [
+ [],
+ *(columns[: index + 1] for index in range(len(columns))),
+ ]
+ show_values_as = form_data.get("showValuesAs")
+ needs_rows_collapsed = show_values_as in {"percent_col", "percent_total"}
+ needs_columns_collapsed = show_values_as in {"percent_row",
"percent_total"}
+
+ def row_prefix_needed(prefix: list[Any]) -> bool:
+ if len(prefix) == len(rows):
+ return True
+ if not prefix:
+ return bool(form_data.get("colTotals")) or needs_rows_collapsed
+ return bool(form_data.get("rowSubTotals"))
+
+ def column_prefix_needed(prefix: list[Any]) -> bool:
+ if len(prefix) == len(columns):
+ return True
+ if not prefix:
+ return bool(form_data.get("rowTotals")) or needs_columns_collapsed
+ return bool(form_data.get("colSubTotals"))
+
+ levels = [
+ (row_prefix, column_prefix)
+ for row_prefix in row_prefixes
+ if row_prefix_needed(row_prefix)
+ for column_prefix in column_prefixes
+ if column_prefix_needed(column_prefix)
+ ]
+ if form_data.get("combineMetric"):
+ metrics_layout = form_data.get("metricsLayout")
+
+ def forced_denominator(level: tuple[list[Any], list[Any]]) -> bool:
+ row_prefix, column_prefix = level
+ return (needs_rows_collapsed and not row_prefix) or (
+ needs_columns_collapsed and not column_prefix
+ )
+
+ if metrics_layout == "ROWS":
+ levels = [
+ level
+ for level in levels
+ if len(level[0]) == len(rows) or forced_denominator(level)
+ ]
+ else:
+ levels = [
+ level
+ for level in levels
+ if len(level[1]) == len(columns) or forced_denominator(level)
+ ]
+
+ return [
+ [_label(value) for value in _deduplicate_fields([*row_prefix,
*column_prefix])]
+ for row_prefix, column_prefix in levels
+ ]
+
+
+def _pivot_query(form_data: dict[str, Any], query: dict[str, Any]) -> None:
+ rows = _as_list(form_data.get("groupbyRows"))
+ columns = _as_list(form_data.get("groupbyColumns"))
+ if form_data.get("transposePivot"):
+ rows, columns = columns, rows
+ query["columns"] = _temporalized_columns(
+ form_data, _deduplicate_fields([*rows, *columns])
+ )
+ metric = query.get("series_limit_metric") or next(
+ iter(query.get("metrics") or []), None
+ )
+ query["orderby"] = (
+ [[metric, not bool(query.get("order_desc", True))]]
+ if metric is not None
+ else []
+ )
+ if not _all_metrics_additive(query.get("metrics") or []):
+ query["grouping_sets"] = _pivot_grouping_sets(form_data, rows, columns)
+
+
+def _waterfall_query(form_data: dict[str, Any], query: dict[str, Any]) -> None:
+ x_axis = form_data.get("x_axis") or form_data.get("granularity_sqla")
+ columns = [*_as_list(x_axis), *_as_list(form_data.get("groupby"))]
+ query["columns"] = _deduplicate_fields(columns)
+ query["orderby"] = [[column, True] for column in query["columns"]]
+
+
+def _gantt_query(form_data: dict[str, Any], query: dict[str, Any]) -> None:
+ groupby = _as_list(form_data.get("series"))
+ orderby = query_fields_from_form_data(form_data)[2]
+ columns = [
+ form_data.get("start_time"),
+ form_data.get("end_time"),
+ form_data.get("y_axis"),
+ *groupby,
+ *_as_list(form_data.get("tooltip_columns")),
+ *(entry[0] for entry in orderby if entry),
+ ]
+ query["columns"] = _deduplicate_fields(columns)
+ query["metrics"] = _as_list(form_data.get("tooltip_metrics"))
+ query["orderby"] = orderby
+ query["series_columns"] = groupby
+
+
+def _normalize_query_orderby(query: dict[str, Any]) -> None:
+ """Mirror ``normalizeOrderBy`` while retaining limit-direction controls."""
+ 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
+ metric = (
+ query.get("series_limit_metric")
+ or query.get("legacy_order_by")
+ or next(iter(query.get("metrics") or []), None)
+ )
+ if metric is None:
+ query.pop("orderby", None)
+ return
+ query["orderby"] = [[metric, not bool(query.get("order_desc", True))]]
+
+
+_TIME_COMPARISON_TYPES = frozenset({"values", "difference", "percentage",
"ratio"})
+
+
+def _metric_offset_map(
+ form_data: dict[str, Any],
+ metric_labels: list[str],
+ offsets: list[Any] | None = None,
+) -> dict[str, str]:
+ """Return the frontend time-comparison metric label map."""
+ if form_data.get("comparison_type") not in _TIME_COMPARISON_TYPES:
+ return {}
+ return {
+ f"{metric}__{offset}": metric
+ for metric in metric_labels
+ for offset in (
+ offsets if offsets is not None else
_as_list(form_data.get("time_compare"))
+ )
+ }
+
+
+def _table_time_offsets(form_data: dict[str, Any]) -> list[Any]:
+ """Resolve Table custom/inherited shifts like its frontend query
adapter."""
+ raw_offsets = _as_list(form_data.get("time_compare"))
+ offsets = [offset for offset in raw_offsets if offset not in {"custom",
"inherit"}]
+ if "custom" in raw_offsets and form_data.get("start_date_offset") is not
None:
+ offsets.append(form_data["start_date_offset"])
+ extra_form_data = form_data.get("extra_form_data")
+ if isinstance(extra_form_data, Mapping) and
extra_form_data.get("time_compare"):
+ inherited = extra_form_data["time_compare"]
+ if inherited not in offsets:
+ offsets = [inherited]
+ return offsets
+
+
+def _x_axis_column(form_data: Mapping[str, Any]) -> Any | None:
+ """Return a supported x-axis column, excluding legacy granularity.
+
+ ``column_name`` mappings are retained for old server/native payloads. Big
+ Number uses the stricter frontend predicate below.
+ """
+ x_axis = form_data.get("x_axis")
+ if isinstance(x_axis, str):
+ return x_axis if x_axis else None
+ if isinstance(x_axis, Mapping):
+ if isinstance(column_name := x_axis.get("column_name"), str) and
column_name:
+ return column_name
+ # Frontend SQL adhoc columns remain objects in the QueryObject.
+ return x_axis if x_axis else None
+ return None
+
+
+def _frontend_x_axis_column(form_data: Mapping[str, Any]) -> Any | None:
+ """Mirror ``isQueryFormColumn`` for physical and SQL adhoc columns."""
+ x_axis = form_data.get("x_axis")
+ if isinstance(x_axis, str):
+ return x_axis if x_axis else None
+ if (
+ isinstance(x_axis, Mapping)
+ and "sqlExpression" in x_axis
+ and "label" in x_axis
+ and x_axis.get("expressionType") in {None, "SQL"}
+ ):
+ return x_axis
+ return None
+
+
+def normalize_time_column(
+ form_data: Mapping[str, Any], query: dict[str, Any]
+) -> dict[str, Any]:
+ """Apply the final shared frontend ``normalizeTimeColumn`` mutator."""
+ x_axis = _frontend_x_axis_column(form_data)
+ columns = query.get("columns")
+ if x_axis is None or not isinstance(columns, list):
+ return query
+
+ axis_index: int | None = None
+ for index, column in enumerate(columns):
+ if isinstance(x_axis, str) and isinstance(column, str) and column ==
x_axis:
+ axis_index = index
+ break
+ if (
+ isinstance(x_axis, Mapping)
+ and isinstance(column, Mapping)
+ and column.get("sqlExpression") == x_axis.get("sqlExpression")
+ ):
+ axis_index = index
+ break
+ if axis_index is None:
+ return query
+
+ normalized = dict(query)
+ normalized_columns = list(columns)
+ grain = (query.get("extras") or {}).get("time_grain_sqla")
+ if isinstance(columns[axis_index], Mapping):
+ normalized_axis = {
+ "columnType": "BASE_AXIS",
+ **({"timeGrain": grain} if grain is not None else {}),
+ **columns[axis_index],
+ }
+ else:
+ normalized_axis = {
+ "columnType": "BASE_AXIS",
+ "sqlExpression": x_axis,
+ "label": x_axis,
+ "expressionType": "SQL",
+ "isColumnReference": True,
+ **({"timeGrain": grain} if grain is not None else {}),
+ }
+ normalized_columns[axis_index] = normalized_axis
+ normalized["columns"] = normalized_columns
+ normalized.pop("is_timeseries", None)
+ return normalized
+
+
+def _finalize_query_objects(
+ form_data: Mapping[str, Any], queries: list[dict[str, Any]]
+) -> list[dict[str, Any]]:
+ """Run shared query-context mutators after every visualization adapter."""
+ return [normalize_time_column(form_data, query) for query in queries]
+
+
+def _x_axis_label(
+ form_data: Mapping[str, Any], *, frontend_strict: bool = False
+) -> str | None:
+ """Mirror getXAxisColumn/getXAxisLabel for explicit and legacy axes."""
+ explicit = (
+ _frontend_x_axis_column(form_data)
+ if frontend_strict
+ else _x_axis_column(form_data)
+ )
+ if explicit:
+ return _label(explicit)
+ if form_data.get("granularity_sqla"):
+ return DTTM_ALIAS
+ return None
+
+
+def _rename_operator(
+ form_data: dict[str, Any],
+ query: dict[str, Any],
+ *,
+ x_axis_label: str | None,
+) -> dict[str, Any] | None:
+ """Mirror the ECharts ``renameOperator`` for Timeseries and Mixed
charts."""
+ metrics = list(query.get("metrics") or [])
+ metric_labels = [_label(metric, metric=True) for metric in metrics]
+ series_columns = query.get("series_columns")
+ columns = _as_list(
+ series_columns if series_columns is not None else query.get("columns")
+ )
+ time_offsets = _as_list(form_data.get("time_compare"))
+ offset_map = _metric_offset_map(form_data, metric_labels)
+ is_time_comparison = bool(offset_map)
+ truncate_metric = form_data.get("truncate_metric")
+
+ should_rename = (
+ bool(metrics)
+ and bool(x_axis_label)
+ and (
+ is_time_comparison
+ or (
+ (bool(columns) or len(time_offsets) > 1)
+ and "truncate_metric" in form_data
+ and bool(truncate_metric)
+ )
+ )
+ )
+ if not should_rename:
+ return None
+
+ renamed: dict[str, str | None] = {}
+ comparison_type = form_data.get("comparison_type")
+ if is_time_comparison:
+ for metric_with_offset, metric_only in offset_map.items():
+ offset_label = next(
+ (
+ str(offset)
+ for offset in time_offsets
+ if metric_with_offset.endswith(f"__{offset}")
+ ),
+ None,
+ )
+ source = (
+ metric_with_offset
+ if comparison_type == "values"
+ else f"{comparison_type}__{metric_only}__{metric_with_offset}"
+ )
+ renamed[source] = (
+ f"{metric_only}, {offset_label}" if len(metrics) > 1 else
offset_label
+ )
+
+ if (
+ comparison_type not in {"difference", "percentage", "ratio"}
+ and len(metrics) == 1
+ and not renamed
+ ):
+ renamed[metric_labels[0]] = None
+ if not renamed:
+ return None
+ return {
+ "operation": "rename",
+ "options": {"columns": renamed, "level": 0, "inplace": True},
+ }
+
+
+def _timeseries_post_processing( # noqa: C901
+ form_data: dict[str, Any],
+ query: dict[str, Any],
+ *,
+ x_axis_label: str | None,
+ groupby: list[Any],
+ mixed: bool,
+) -> tuple[list[dict[str, Any]], list[Any]]:
+ """Build the Timeseries/Mixed operator pipeline in frontend order."""
+ metric_labels = [_label(value, metric=True) for value in
query.get("metrics") or []]
+ offset_map = _metric_offset_map(form_data, metric_labels)
+ time_offsets = _as_list(form_data.get("time_compare")) if offset_map else
[]
+ post_processing: list[dict[str, Any]] = []
+
+ if x_axis_label and metric_labels:
+ aggregate_labels = (
+ [*offset_map.values(), *offset_map] if offset_map else
metric_labels
+ )
+ post_processing.append(
+ {
+ "operation": "pivot",
+ "options": {
+ "index": [x_axis_label],
+ "columns": [_label(value) for value in groupby],
+ "aggregates": {
+ label: {"operator": "mean"} for label in
aggregate_labels
+ },
+ "drop_missing_columns": not form_data.get(
+ "show_empty_columns", False
+ ),
+ },
+ }
+ )
+
+ if form_data.get("resample_method") and form_data.get("resample_rule"):
+ zero_fill = form_data["resample_method"] == "zerofill"
+ post_processing.append(
+ {
+ "operation": "resample",
+ "options": {
+ "method": "asfreq" if zero_fill else
form_data["resample_method"],
+ "rule": form_data["resample_rule"],
+ "fill_value": 0 if zero_fill else None,
+ },
+ }
+ )
+
+ rolling_labels = (
+ [*offset_map.values(), *offset_map] if offset_map else metric_labels
+ )
+ columns_map = {label: label for label in rolling_labels}
+ rolling_type = form_data.get("rolling_type")
+ if rolling_type == "cumsum":
+ post_processing.append(
+ {
+ "operation": "cum",
+ "options": {"operator": "sum", "columns": columns_map},
+ }
+ )
+ elif rolling_type in {"sum", "mean", "std"}:
+ post_processing.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": columns_map,
+ },
+ }
+ )
+
+ comparison_type = form_data.get("comparison_type")
+ if offset_map and comparison_type != "values":
+ post_processing.append(
+ {
+ "operation": "compare",
+ "options": {
+ "source_columns": list(offset_map.values()),
+ "compare_columns": list(offset_map),
+ "compare_type": comparison_type,
+ "drop_original_columns": True,
+ },
+ }
+ )
+
+ if not mixed and form_data.get("contributionMode"):
+ post_processing.append(
+ {
+ "operation": "contribution",
+ "options": {
+ "orientation": form_data["contributionMode"],
+ "time_shifts": time_offsets,
+ },
+ }
+ )
+
+ if rename := _rename_operator(form_data, query, x_axis_label=x_axis_label):
+ post_processing.append(rename)
+
+ if not mixed:
+ sortable = {
+ x_axis_label or "",
+ *metric_labels,
+ }
+ if (
+ "x_axis_sort" in form_data
+ and "x_axis_sort_asc" in form_data
+ and form_data.get("x_axis_sort") in sortable
+ and not groupby
+ ):
+ options: dict[str, Any] = {"ascending":
form_data.get("x_axis_sort_asc")}
+ if form_data.get("x_axis_sort") == x_axis_label:
+ options["is_sort_index"] = True
+ else:
+ options["by"] = form_data.get("x_axis_sort")
+ post_processing.append({"operation": "sort", "options": options})
+
+ post_processing.append({"operation": "flatten"})
+ if not mixed and form_data.get("forecastEnabled") and x_axis_label:
+ post_processing.append(
+ {
+ "operation": "prophet",
+ "options": {
+ "time_grain": form_data.get("time_grain_sqla"),
+ "periods": int(form_data.get("forecastPeriods") or 0),
+ "confidence_interval": float(
+ form_data.get("forecastInterval") or 0
+ ),
+ "yearly_seasonality":
form_data.get("forecastSeasonalityYearly"),
+ "weekly_seasonality":
form_data.get("forecastSeasonalityWeekly"),
+ "daily_seasonality":
form_data.get("forecastSeasonalityDaily"),
+ "index": x_axis_label,
+ },
+ }
+ )
+ return post_processing, time_offsets
+
+
+def _timeseries_query(form_data: dict[str, Any], query: dict[str, Any]) ->
None:
+ groupby = _as_list(form_data.get("groupby"))
+ x_axis = _x_axis_column(form_data)
+ x_axis_label = _x_axis_label(form_data)
+ query["columns"] = _deduplicate_fields([*_as_list(x_axis), *groupby])
+ query["series_columns"] = groupby
+ if not x_axis:
+ query["is_timeseries"] = True
+
+ # Timeseries includes its sort-only metric in the SELECT when no series is
+ # present. This lets the post-processing sort operator use a metric not
+ # otherwise displayed.
+ sort_metric = form_data.get("timeseries_limit_metric")
+ if isinstance(sort_metric, list):
+ sort_metric = next(iter(sort_metric), None)
+ if (
+ not groupby
+ and sort_metric is not None
+ and _label(sort_metric, metric=True) == form_data.get("x_axis_sort")
+ and _label(sort_metric, metric=True)
+ not in {_label(metric, metric=True) for metric in query.get("metrics")
or []}
+ ):
+ query.setdefault("metrics", []).append(sort_metric)
+ _normalize_query_orderby(query)
+ post_processing, time_offsets = _timeseries_post_processing(
+ form_data,
+ query,
+ x_axis_label=x_axis_label,
+ groupby=groupby,
+ mixed=form_data.get("viz_type") == "mixed_timeseries",
+ )
+ query["post_processing"] = post_processing
+ query["time_offsets"] = time_offsets
+ if form_data.get("viz_type") != "mixed_timeseries":
+ query["time_compare_full_range"] = bool(
+ time_offsets and form_data.get("time_compare_full_range")
+ )
+
+
+def _big_number_queries(
+ form_data: dict[str, Any], query: dict[str, Any]
+) -> list[dict[str, Any]]:
+ """Mirror Big Number with Trendline's one/two-query contract."""
+ # Saved/native Big Number payloads can carry the temporal binding as a
+ # ``{"column_name": ...}`` mapping, which the strict frontend predicate
does
+ # not recognize; keep grouping by it rather than falling back to a total.
+ frontend_x_axis = _frontend_x_axis_column(form_data)
+ explicit_x_axis = frontend_x_axis or _x_axis_column(form_data)
+ time_column = _as_list(explicit_x_axis)
+ x_axis_label = _x_axis_label(form_data, frontend_strict=True)
+ query["columns"] = time_column
+ if time_column and frontend_x_axis is None:
+ # A native ``{"column_name": ...}`` axis groups by its temporal column
+ # but is not rewritten by normalize_time_column, so drop the legacy
+ # granularity binding here rather than bucketing the same dimension
+ # twice.
+ query.pop("granularity", None)
+ extras = query.get("extras")
+ if isinstance(extras, dict):
+ extras.pop("time_grain_sqla", None)
+ if not extras:
+ query.pop("extras", None)
+ elif not time_column:
+ query["is_timeseries"] = True
+ metric_labels = [_label(value, metric=True) for value in
query.get("metrics") or []]
+ post_processing: list[dict[str, Any]] = []
+ if x_axis_label and metric_labels:
+ post_processing.append(
+ {
+ "operation": "pivot",
+ "options": {
+ "index": [x_axis_label],
+ "columns": [],
+ "aggregates": {
+ label: {"operator": "mean"} for label in metric_labels
+ },
+ "drop_missing_columns": not form_data.get(
+ "show_empty_columns", False
+ ),
+ },
+ }
+ )
+ if form_data.get("resample_method") and form_data.get("resample_rule"):
+ zero_fill = form_data["resample_method"] == "zerofill"
+ post_processing.append(
+ {
+ "operation": "resample",
+ "options": {
+ "method": "asfreq" if zero_fill else
form_data["resample_method"],
+ "rule": form_data["resample_rule"],
+ "fill_value": 0 if zero_fill else None,
+ },
+ }
+ )
+ rolling_type = form_data.get("rolling_type")
+ columns_map = {label: label for label in metric_labels}
+ if rolling_type == "cumsum":
+ post_processing.append(
+ {"operation": "cum", "options": {"operator": "sum", "columns":
columns_map}}
+ )
+ elif rolling_type in {"sum", "mean", "std"}:
+ post_processing.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": columns_map,
+ },
+ }
+ )
+ post_processing.append({"operation": "flatten"})
+ query["post_processing"] = post_processing
+ queries = [query]
+ if form_data.get("aggregation") == "raw":
+ overall = dict(query)
+ overall.update(
+ {
+ "columns": [],
+ "is_timeseries": False,
+ "post_processing": [],
+ }
+ )
+ queries.append(overall)
+ return queries
+
+
+def _table_queries( # noqa: C901
+ form_data: dict[str, Any], query: dict[str, Any]
+) -> list[dict[str, Any]]:
+ if is_raw_query_mode(form_data):
+ # The extractor already applies the raw-mode contract, including native
+ # ``order_by_cols`` parsing. Do not synthesize metric ordering.
+ query["columns"] = list(
+ form_data.get("all_columns") or form_data.get("columns") or []
+ )
+ query["metrics"] = []
+ if raw_orderby := orderby_from_form_data(form_data, [], "table"):
+ query["orderby"] = raw_orderby
+ else:
+ query.pop("orderby", None)
+ return [query]
+
+ metrics = list(query.get("metrics") or [])
+ query["columns"] = _table_temporalized_columns(
+ form_data, list(query.get("columns") or [])
+ )
+ percent_metrics = _as_list(form_data.get("percent_metrics"))
+ for metric in percent_metrics:
+ if _label(metric, metric=True) not in {
+ _label(existing, metric=True) for existing in metrics
+ }:
+ metrics.append(metric)
+ query["metrics"] = metrics
+ query["orderby"] = orderby_from_form_data(form_data, metrics, "table")
+ post_processing: list[dict[str, Any]] = []
+ resolved_offsets = _table_time_offsets(form_data)
+ comparison_enabled = (
+ form_data.get("comparison_type") in _TIME_COMPARISON_TYPES
+ and bool(metrics)
+ and bool(_as_list(form_data.get("time_compare")))
+ )
+ contribution: dict[str, Any] | None = None
+ if percent_metrics:
+ base_labels = [_label(metric, metric=True) for metric in
percent_metrics]
+ labels = [
+ label
+ for metric_label in base_labels
+ for label in (
+ [
+ metric_label,
+ *(f"{metric_label}__{offset}" for offset in
resolved_offsets),
+ ]
+ if comparison_enabled
+ else [metric_label]
+ )
+ ]
+ labels = list(dict.fromkeys(labels))
+ contribution = {
+ "operation": "contribution",
+ "options": {
+ "columns": labels,
+ "rename_columns": [f"%{label}" for label in labels],
+ },
+ }
+ post_processing.append(contribution)
+
+ metric_labels = [_label(metric, metric=True) for metric in metrics]
+ offset_map = _metric_offset_map(form_data, metric_labels, resolved_offsets)
+ time_offsets = resolved_offsets if offset_map else []
+ if offset_map and form_data.get("comparison_type") != "values":
+ post_processing.append(
+ {
+ "operation": "compare",
+ "options": {
+ "source_columns": list(offset_map.values()),
+ "compare_columns": list(offset_map),
+ "compare_type": form_data.get("comparison_type"),
+ "drop_original_columns": True,
+ },
+ }
+ )
+ if post_processing:
query["post_processing"] = post_processing
- # ``granularity`` does two jobs downstream: it names the temporal column
the
- # time range filters on, and it is the column ``time_grain_sqla`` buckets
- # (``models/helpers.py`` swaps a selected column for its timestamp
expression
- # when that column equals ``granularity``). Only the first job depends on
- # there being an active range, so set it whenever form data carries one —
- # matching ``extractExtras.ts``, which sets it unconditionally. Gating it
on
- # ``time_range`` dropped the bucketing, so an ordinary "all-time totals by
- # month" chart exported one row per raw timestamp instead of one per month.
- if granularity := form_data.get("granularity") or
form_data.get("granularity_sqla"):
+ else:
+ query.pop("post_processing", None)
+ query["time_offsets"] = time_offsets
+
+ is_download = form_data.get("result_format") in {"csv", "xlsx"} or (
+ form_data.get("result_format") == "json"
+ and form_data.get("result_type") == "results"
+ )
+ if is_download:
+ if form_data.get("row_limit") is not None:
+ query["row_limit"] = int(form_data["row_limit"])
+ query["row_offset"] = 0
+ elif form_data.get("server_pagination"):
Review Comment:
This branch is taken for every non-download rebuild, but `get_chart_data`
never sets `result_format` on the form data it passes in. A cached Table with
`server_pagination=true`, `server_page_length=10`, `row_limit=100` and 100
result rows, called as `get_chart_data(form_data_key=..., format="csv",
limit=100)`, queries only 10 rows (and adds an `is_rowcount` query), so the
CSV/Excel export is just the first page. The same branch also replaces the
caller's `row_limit` with the saved page size, so `limit=1` against
`server_page_length=1000` still fetches 1,000 groups.
Should the MCP rebuild treat the requested export format and explicit
`limit` as non-paginated, the way the Excel-export caller now does?
##########
superset/mcp_service/chart/chart_utils.py:
##########
@@ -1694,6 +1772,777 @@ def map_bubble_config(config: BubbleChartConfig) ->
Dict[str, Any]:
return form_data
+def map_sunburst_config(config: SunburstChartConfig) -> Dict[str, Any]:
+ """Map typed Sunburst config to the ECharts ``sunburst_v2`` form_data.
+
+ The frontend control panel stores hierarchy levels under ``columns`` and
+ metrics under singular ``metric`` / ``secondary_metric`` keys. Its
+ buildQuery adds primary-metric descending ordering when ``sort_by_metric``
+ is enabled; server-side query builders mirror that transform separately.
+ """
+ form_data: Dict[str, Any] = {
+ "viz_type": "sunburst_v2",
+ "columns": [dimension.name for dimension in config.hierarchy],
+ "metric": create_metric_object(config.metric),
+ "sort_by_metric": config.sort_by_metric,
+ "row_limit": config.row_limit,
+ "show_labels": config.show_labels,
+ "show_labels_threshold": config.show_labels_threshold,
+ "show_total": config.show_total,
+ "show_null_values": config.show_null_values,
+ "label_type": config.label_type,
+ "number_format": config.number_format,
+ "date_format": config.date_format,
+ }
+ if config.secondary_metric is not None:
+ form_data["secondary_metric"] =
create_metric_object(config.secondary_metric)
+ if config.color_scheme is not None:
+ form_data["color_scheme"] = config.color_scheme
+ if config.linear_color_scheme is not None:
+ form_data["linear_color_scheme"] = config.linear_color_scheme
+ if config.time_range is not None:
+ form_data["time_range"] = config.time_range
+ if config.temporal_column is not None:
+ form_data["granularity_sqla"] = config.temporal_column
+ if config.time_grain is not None:
+ form_data["time_grain_sqla"] = config.time_grain
+
+ _copy_sunburst_native_envelope(form_data, config)
+
+ add_currency_format(form_data, config.currency_format)
+ _add_adhoc_filters(form_data, config.filters)
+ return form_data
+
+
+# Sunburst fields with explicit omission/clear semantics. Mapper defaults must
+# not overwrite same-viz state when the typed field was omitted, while explicit
+# clears must also beat the shared preservation registry on cross-viz updates.
+# Required query roles (hierarchy and metric) are deliberately absent: a full
+# replacement always updates them.
+_SUNBURST_UPDATE_FIELD_KEYS: dict[str, str] = {
+ "time_range": "time_range",
+ "time_grain": "time_grain_sqla",
+ "temporal_column": "granularity_sqla",
+ "sort_by_metric": "sort_by_metric",
+ "row_limit": "row_limit",
+ "color_scheme": "color_scheme",
+ "linear_color_scheme": "linear_color_scheme",
+ "show_labels": "show_labels",
+ "show_labels_threshold": "show_labels_threshold",
+ "show_total": "show_total",
+ "show_null_values": "show_null_values",
+ "label_type": "label_type",
+ "number_format": "number_format",
+ "date_format": "date_format",
+ "currency_format": "currency_format",
+ "extra_form_data": "extra_form_data",
+ "url_params": "url_params",
+ "standardized_form_data": "standardizedFormData",
+}
+
+
+# Presentation controls emitted sparsely by chart mappers need three-way update
+# semantics: omitted preserves saved native state, an explicit value replaces
+# it, and explicit ``None``/``False`` clears a truthy saved value when the
mapper
+# has no canonical false/null representation. Query roles are intentionally
+# absent: a replacement config always owns those through the plugin contract.
+# Paths below also cover nested axis/legend models so an omitted nested
property
+# is not mistaken for an explicit clear of the whole control.
+_MODELED_UPDATE_CONTROL_PATHS: dict[str, dict[str, tuple[tuple[str, ...],
...]]] = {
+ "GaugeChartConfig": {
+ key: ((key,),)
+ for key in (
+ "sort_by_metric",
+ "row_limit",
+ "min_val",
+ "max_val",
+ "color_scheme",
+ "font_size",
+ "number_format",
+ "currency_format",
+ "value_formatter",
+ "start_angle",
+ "end_angle",
+ "show_pointer",
+ "animation",
+ "show_axis_tick",
+ "show_split_line",
+ "split_number",
+ "show_progress",
+ "overlap",
+ "round_cap",
+ "intervals",
+ "interval_color_indices",
+ "time_range",
+ "granularity_sqla",
+ )
+ },
+ "PieChartConfig": {
+ "color_scheme": (("color_scheme",),),
+ "show_labels": (("show_labels",),),
+ "show_legend": (("show_legend",),),
+ "legendOrientation": (("legend_orientation",),),
+ "label_type": (("label_type",),),
+ "number_format": (("number_format",),),
+ "date_format": (("date_format",),),
+ "sort_by_metric": (("sort_by_metric",),),
+ "row_limit": (("row_limit",),),
+ "donut": (("donut",),),
+ "show_total": (("show_total",),),
+ "labels_outside": (("labels_outside",),),
+ "outerRadius": (("outer_radius",),),
+ "innerRadius": (("inner_radius",),),
+ "currency_format": (("currency_format",),),
+ },
+ "TableChartConfig": {
+ "row_limit": (("row_limit",),),
+ "color_scheme": (("color_scheme",),),
+ "column_config": (("column_config",),),
+ },
+ "XYChartConfig": {
+ "row_limit": (("row_limit",),),
+ "series_limit": (("series_limit",),),
+ "stack": (("stacked",),),
+ "orientation": (("orientation",),),
+ "x_axis_title": (("x_axis", "title"),),
+ "x_axis_format": (("x_axis", "format"),),
+ "y_axis_title": (("y_axis", "title"),),
+ "y_axis_format": (("y_axis", "format"),),
+ "y_axis_scale": (("y_axis", "scale"),),
+ "show_legend": (("legend", "show"),),
+ "legendOrientation": (("legend", "position"), ("legend_orientation",)),
+ "x_axis_time_format": (("x_axis_time_format",),),
+ "show_value": (("show_value",),),
+ "currency_format": (("currency_format",),),
+ "color_scheme": (("color_scheme",),),
+ },
+ "HistogramChartConfig": {
+ "bins": (("bins",),),
+ "normalize": (("normalize",),),
+ "cumulative": (("cumulative",),),
+ "row_limit": (("row_limit",),),
+ },
+ "BoxPlotChartConfig": {
+ "whiskerOptions": (
+ ("whisker_type",),
+ ("percentile_low",),
+ ("percentile_high",),
+ ),
+ "row_limit": (("row_limit",),),
+ "number_format": (("number_format",),),
+ "date_format": (("date_format",),),
+ },
+ "GanttChartConfig": {
+ "tooltip_columns": (("tooltip_columns",),),
+ "tooltip_metrics": (("tooltip_metrics",),),
+ "order_by_cols": (("order_by",),),
+ "row_limit": (("row_limit",),),
+ },
+ "WaterfallChartConfig": {
+ "show_total": (("show_total",),),
+ "show_legend": (("show_legend",),),
+ "increase_label": (("increase_label",),),
+ "decrease_label": (("decrease_label",),),
+ "total_label": (("total_label",),),
+ "x_axis_time_format": (("x_axis_time_format",),),
+ "y_axis_format": (("y_axis_format",),),
+ "currency_format": (("currency_format",),),
+ "row_limit": (("row_limit",),),
+ },
+ "BigNumberChartConfig": {
+ "subheader": (("subheader",),),
+ "y_axis_format": (("y_axis_format",),),
+ "time_format": (("time_format",),),
+ "currency_format": (("currency_format",),),
+ "color_scheme": (("color_scheme",),),
+ "start_y_axis_at_zero": (("start_y_axis_at_zero",),),
+ "compare_lag": (("compare_lag",),),
+ "aggregation": (("aggregation",),),
+ },
+ "HandlebarsChartConfig": {
+ "row_limit": (("row_limit",),),
+ "order_desc": (("order_desc",),),
+ "styleTemplate": (("style_template",),),
+ },
+ "PivotTableChartConfig": {
+ "aggregateFunction": (("aggregate_function",),),
+ "rowTotals": (("show_row_totals",),),
+ "colTotals": (("show_column_totals",),),
+ "transposePivot": (("transpose",),),
+ "combineMetric": (("combine_metric",),),
+ "valueFormat": (("value_format",),),
+ "date_format": (("date_format",),),
+ "currency_format": (("currency_format",),),
+ "row_limit": (("row_limit",),),
+ },
+ "InteractivePivotChartConfig": {
+ "order_desc": (("sort_descending",),),
+ "row_limit": (("row_limit",),),
+ "rowGroupCounts": (("show_row_group_counts",),),
+ "rowTotals": (("show_row_totals",),),
+ "colTotals": (("show_column_totals",),),
+ "colSubTotals": (("show_column_subtotals",),),
+ "valueFormat": (("value_format",),),
+ "date_format": (("date_format",),),
+ "currency_format": (("currency_format",),),
+ "colOrder": (("column_sort",),),
+ "allow_render_html": (("allow_render_html",),),
+ "expand_pivot_groups": (("expand_pivot_groups",),),
+ "time_compare": (("comparison_period",),),
+ "comparison_type": (("comparison_type",),),
+ },
+ "MixedTimeseriesChartConfig": {
+ "seriesType": (("primary_kind",),),
+ "area": (("primary_kind",),),
+ "seriesTypeB": (("secondary_kind",),),
+ "areaB": (("secondary_kind",),),
+ "show_legend": (("show_legend",),),
+ "legendOrientation": (("legend_orientation",),),
+ "show_value": (("show_value",),),
+ "color_scheme": (("color_scheme",),),
+ "currency_format": (("currency_format",),),
+ "currency_format_secondary": (("currency_format_secondary",),),
+ "xAxisTitle": (("x_axis", "title"),),
+ "x_axis_time_format": (("x_axis", "format"),),
+ "yAxisTitle": (("y_axis", "title"),),
+ "y_axis_format": (("y_axis", "format"),),
+ "logAxis": (("y_axis", "scale"),),
+ "yAxisTitleSecondary": (("y_axis_secondary", "title"),),
+ "y_axis_format_secondary": (("y_axis_secondary", "format"),),
+ "logAxisSecondary": (("y_axis_secondary", "scale"),),
+ "row_limit": (("row_limit",),),
+ },
+}
+
+
+def _model_path_was_set(config: Any, path: tuple[str, ...]) -> bool:
+ """Return whether every component of a Pydantic model path was supplied."""
+ current = config
+ for field_name in path:
+ if field_name not in getattr(current, "model_fields_set", set()):
+ return False
+ current = getattr(current, field_name, None)
+ if current is None:
+ # An explicit null parent clears all of its mapped descendants.
+ return True
+ return True
+
+
+def _apply_modeled_update_semantics(
+ existing_form_data: Mapping[str, Any],
+ new_form_data: Dict[str, Any],
+ config: Any,
+) -> set[str]:
+ """Preserve truly omitted modeled controls and return explicit clears."""
+ explicit_clears: set[str] = set()
+ controls = _MODELED_UPDATE_CONTROL_PATHS.get(type(config).__name__, {})
+ for form_key, paths in controls.items():
+ if any(_model_path_was_set(config, path) for path in paths):
+ if form_key not in new_form_data:
+ explicit_clears.add(form_key)
+ continue
+ if form_key in existing_form_data:
+ new_form_data[form_key] = existing_form_data[form_key]
+ else:
+ new_form_data.pop(form_key, None)
+ return explicit_clears
+
+
+_TEMPORAL_FORM_DATA_KEYS = frozenset(
+ {
+ "granularity",
+ "granularity_sqla",
+ "since",
+ "time_grain",
+ "time_grain_sqla",
+ "time_range",
+ "until",
+ }
+)
+
+
+def _is_temporal_filter(filter_: Any) -> bool:
+ """Return whether a native, adhoc, or legacy filter carries a time
range."""
+ return isinstance(filter_, dict) and (
+ filter_.get("operator") == FilterOperator.TEMPORAL_RANGE.value
+ or filter_.get("op") == FilterOperator.TEMPORAL_RANGE.value
+ or filter_.get("col") in {"__time_col", "__time_grain", "__time_range"}
+ )
+
+
+def _without_temporal_filters(value: Any) -> Any:
+ """Copy a filter list without temporal predicates, preserving other
shapes."""
+ if not isinstance(value, list):
+ return value
+ return [filter_ for filter_ in value if not _is_temporal_filter(filter_)]
+
+
+def _scrub_temporal_form_data(form_data: Mapping[str, Any]) -> Dict[str, Any]:
+ """Remove every source capable of reconstructing explicitly cleared time
state."""
+ scrubbed = dict(form_data)
+ for key in _TEMPORAL_FORM_DATA_KEYS:
+ scrubbed.pop(key, None)
+ scrubbed.pop(MCP_DASHBOARD_TIME_FILTER_SUBJECT, None)
+
+ for key in ("adhoc_filters", "extra_filters", "filters"):
+ if key in scrubbed:
+ scrubbed[key] = _without_temporal_filters(scrubbed[key])
+
+ extra_form_data = scrubbed.get("extra_form_data")
+ if isinstance(extra_form_data, dict):
+ cleaned_extra = dict(extra_form_data)
+ for key in _TEMPORAL_FORM_DATA_KEYS:
+ cleaned_extra.pop(key, None)
+ for key in ("adhoc_filters", "extra_filters", "filters"):
+ if key in cleaned_extra:
+ cleaned_extra[key] =
_without_temporal_filters(cleaned_extra[key])
+ scrubbed["extra_form_data"] = cleaned_extra
+ elif extra_form_data is None:
+ scrubbed.pop("extra_form_data", None)
+ return scrubbed
+
+
+# One bounded registry owns state that may survive a form-data replacement.
+# Query roles and plugin-specific controls are deliberately absent. This keeps
+# cross-viz transitions preview/save-safe without chart-by-chart allowlists
that
+# can drift as new plugins are registered.
+FORM_DATA_UPDATE_PRESERVE_KEYS: dict[str, frozenset[str]] = {
+ "envelope": frozenset(
+ {
+ "dashboardId",
+ "dashboards",
+ "datasource",
+ "extra_form_data",
+ "slice_id",
+ "slice_name",
+ "standardizedFormData",
+ "url_params",
+ }
+ ),
+ "presentation": frozenset(
+ {
+ "color_scheme",
+ "currency_format",
+ "date_format",
+ "legendOrientation",
+ "linear_color_scheme",
+ "number_format",
+ "show_legend",
+ }
+ ),
+ "filters": frozenset({"adhoc_filters", "extra_filters", "filters"}),
+ "time": frozenset(
+ {
+ "granularity_sqla",
+ "since",
+ "time_grain_sqla",
+ "time_range",
+ "until",
+ }
+ ),
+}
+_FORM_DATA_UPDATE_PRESERVE_KEYS = frozenset().union(
+ *FORM_DATA_UPDATE_PRESERVE_KEYS.values()
+)
+
+
+_SAVED_PREDICATE_FORM_DATA_KEYS = frozenset(
+ {"adhoc_filters", "extra_filters", "filters", "having", "where"}
+)
+
+
+def _merge_preserved_adhoc_filters(
+ existing_form_data: Mapping[str, Any],
+ new_form_data: Mapping[str, Any],
+ *,
+ drop_existing_temporal: bool,
+) -> list[Any] | None:
+ """Merge omitted structured filters while removing stale time bindings."""
+ previous = existing_form_data.get("adhoc_filters")
+ generated = new_form_data.get("adhoc_filters")
+ if not isinstance(previous, list):
+ return list(generated) if isinstance(generated, list) else None
+
+ previous_binding =
existing_form_data.get(MCP_DASHBOARD_TIME_FILTER_SUBJECT)
+ new_binding = new_form_data.get(MCP_DASHBOARD_TIME_FILTER_SUBJECT)
+ merged: list[Any] = []
+ for filter_ in previous:
+ is_temporal = (
+ isinstance(filter_, dict)
+ and filter_.get("operator") == FilterOperator.TEMPORAL_RANGE.value
+ )
+ stale_generated_binding = (
+ is_temporal
+ and previous_binding
+ and previous_binding != new_binding
+ and filter_.get("subject") == previous_binding
+ and filter_.get("comparator") == NO_TIME_RANGE
+ )
+ if (drop_existing_temporal and is_temporal) or stale_generated_binding:
+ continue
+ merged.append(filter_)
+
+ for filter_ in generated if isinstance(generated, list) else []:
+ if isinstance(filter_, dict):
+ same_filter = any(
+ isinstance(previous_filter, dict)
+ and previous_filter.get("clause") == filter_.get("clause")
+ and previous_filter.get("expressionType")
+ == filter_.get("expressionType")
+ and previous_filter.get("subject") == filter_.get("subject")
+ and previous_filter.get("operator") == filter_.get("operator")
+ for previous_filter in merged
+ )
+ if same_filter:
+ continue
+ elif filter_ in merged:
+ continue
+ merged.append(filter_)
+ return merged
+
+
+def _merge_allowlisted_form_data(
+ existing_form_data: Mapping[str, Any],
+ new_form_data: Mapping[str, Any],
+) -> Dict[str, Any]:
+ """Start from mapped target state and add only registry-approved
omissions."""
+ merged = dict(new_form_data)
+ for key in _FORM_DATA_UPDATE_PRESERVE_KEYS:
+ if key not in merged and key in existing_form_data:
+ merged[key] = existing_form_data[key]
+ return merged
+
+
+def merge_form_data_for_update(
+ existing_form_data: Dict[str, Any],
+ new_form_data: Dict[str, Any],
+ config: Any,
+ *,
+ dataset_rebind: bool = False,
+) -> Dict[str, Any]:
+ """Merge mapped updates without leaking query roles across visualizations.
+
+ Same-viz updates retain native controls outside the simplified MCP schema
by
+ starting from saved form data. Cross-viz updates remain bounded by the
+ shared preservation registry. Explicit clears are applied last.
+
+ A dataset rebind prunes every dataset-bound role from the saved state and
+ then merges as a same-dataset update, unless the owning plugin declares a
+ strict rebind contract (``strict_dataset_rebind``), in which case its
+ ``merge_update_form_data`` hook receives ``dataset_rebind=True``. Plugins
+ that declare ``owns_update_merge`` merge same-viz updates themselves;
+ every other update takes the shared overlay and then the plugin's
+ ``finalize_update_form_data`` hook.
+ """
+ from superset.mcp_service.chart.registry import plugin_for_viz_type
+
+ plugin = plugin_for_viz_type(new_form_data.get("viz_type"))
+ if dataset_rebind and not (plugin is not None and
plugin.strict_dataset_rebind):
+ existing_form_data = scrub_dataset_bound_form_data(
+ existing_form_data,
+ target_viz_type=new_form_data.get("viz_type"),
+ )
+ dataset_rebind = False
+
+ same_viz = existing_form_data.get("viz_type") ==
new_form_data.get("viz_type")
+ if same_viz and plugin is not None and (dataset_rebind or
plugin.owns_update_merge):
+ plugin_merged = plugin.merge_update_form_data(
+ existing_form_data,
+ new_form_data,
+ config,
+ dataset_rebind=dataset_rebind,
+ )
+ if plugin_merged is not None:
+ return plugin_merged
+ if dataset_rebind:
+ # A strict rebind never inherits saved state the plugin did not merge.
+ return dict(new_form_data)
+
+ merged = overlay_update_form_data(existing_form_data, new_form_data,
config)
+ if plugin is not None:
+ merged = plugin.finalize_update_form_data(
+ existing_form_data, new_form_data, merged, config
+ )
+ return merged
+
+
+def overlay_update_form_data(
+ existing_form_data: Dict[str, Any],
+ new_form_data: Dict[str, Any],
+ config: Any,
+) -> Dict[str, Any]:
+ """Overlay a same-dataset update on the saved state with shared
semantics."""
+ same_viz = existing_form_data.get("viz_type") ==
new_form_data.get("viz_type")
+ explicit_control_clears = (
+ _apply_modeled_update_semantics(existing_form_data, new_form_data,
config)
+ if same_viz
+ else set()
+ )
+ if same_viz:
+ from superset.mcp_service.chart.registry import (
+ query_role_keys_for_viz_type,
+ )
+
+ # Strip every target-owned query role first, then overlay the mapper's
+ # complete replacement. This removes mutually exclusive aliases (for
+ # example Pie ``metrics`` vs ``metric`` and raw vs aggregate table
+ # roles) without dropping unmodeled native presentation controls.
+ query_role_keys = query_role_keys_for_viz_type(
+ str(new_form_data.get("viz_type"))
+ )
+ merged = {
+ key: value
+ for key, value in existing_form_data.items()
+ if key not in query_role_keys
+ }
+ merged.update(new_form_data)
+ else:
+ merged = _merge_allowlisted_form_data(existing_form_data,
new_form_data)
+
+ for key in explicit_control_clears:
+ merged.pop(key, None)
+
+ fields_set: set[str] = getattr(config, "model_fields_set", set())
+ requested_filters = getattr(config, "filters", None)
+ if requested_filters is not None:
+ # Legacy ``filters``/``where``/``having`` are reconstructed into adhoc
+ # filters at query time, so an explicit clear or replacement removes
+ # every saved source the new form data does not set itself.
+ for key in _SAVED_PREDICATE_FORM_DATA_KEYS - new_form_data.keys():
+ merged.pop(key, None)
+ else:
+ filters = _merge_preserved_adhoc_filters(
+ existing_form_data,
+ new_form_data,
+ drop_existing_temporal=bool(
+ {"temporal_column", "time_grain", "time_range"} & fields_set
+ ),
+ )
+ if filters is not None:
+ merged["adhoc_filters"] = filters
+ return merged
+
+
+def retain_mixed_timeseries_secondary_update_state(
+ existing_form_data: Mapping[str, Any],
+ new_form_data: Mapping[str, Any],
+) -> Dict[str, Any]:
+ """Return saved query-B controls a same-viz Mixed Timeseries update keeps.
+
+ Query B inherits unsuffixed controls only when the suffixed key is absent.
+ Preserve explicit native clears for controls the typed mapper did not
+ replace, so []/None never turns into accidental inheritance from query A.
+ Valid comparison state is also retained; malformed/stale dataset roles
+ remain fail-closed and are dropped.
+ """
+ from superset.common.form_data_query_context import (
+ MIXED_TIMESERIES_SECONDARY_QUERY_KEYS,
+ )
+
+ retained: Dict[str, Any] = {}
+ for key in MIXED_TIMESERIES_SECONDARY_QUERY_KEYS:
Review Comment:
Every `*_b` key is in `query_role_keys` and is stripped on a same-viz
update, but this loop only restores explicit clears, `comparison_type_b`, and
three list controls. Saved `time_range_b`, `row_limit_b`, `time_grain_sqla_b`,
`rolling_*_b` and similar are lost whenever the update doesn't restate them.
For example, a Mixed Timeseries with `time_range="No filter"` and
`time_range_b="2025-01-01 : 2025-02-01"` updated with only `show_legend=false`
ends up with no `time_range_b`, so query B inherits the unrestricted primary
range and returns different data.
Should valid secondary-query controls the request didn't mention be
preserved the way the primary query's are?
##########
tests/unit_tests/mcp_service/chart/test_native_form_data_and_dataset_rebind_contracts.py:
##########
@@ -0,0 +1,776 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+from copy import deepcopy
+from unittest.mock import MagicMock, patch
+
+import pytest
+from pydantic import ValidationError
+
+from superset.mcp_service.chart.chart_helpers import (
+ build_query_context_from_form_data,
+ build_query_dicts_from_form_data,
+)
+from superset.mcp_service.chart.chart_utils import (
+ map_config_to_form_data,
+ merge_form_data_for_update,
+ scrub_dataset_bound_form_data,
+)
+from superset.mcp_service.chart.schemas import (
+ GenerateChartRequest,
+ SunburstChartConfig,
+ TableChartConfig,
+ UpdateChartPreviewRequest,
+ UpdateChartRequest,
+)
+
+
[email protected](
+ "form_data,expected_columns,expected_metrics,expected_queries",
+ [
+ (
+ {
+ "viz_type": "histogram_v2",
+ "column": "revenue",
+ "groupby": ["region"],
+ "bins": 8,
+ },
+ ["region", "revenue"],
+ [],
+ 1,
+ ),
+ (
+ {
+ "viz_type": "pivot_table_v2",
+ "groupbyRows": ["region"],
+ "groupbyColumns": ["product"],
+ "metrics": ["sum_revenue"],
+ },
+ ["region", "product"],
+ ["sum_revenue"],
+ 1,
+ ),
+ (
+ {
+ "viz_type": "waterfall",
+ "x_axis": "month",
+ "groupby": ["region"],
+ "metric": "sum_revenue",
+ },
+ ["month", "region"],
+ ["sum_revenue"],
+ 1,
+ ),
+ (
+ {
+ "viz_type": "gantt_chart",
+ "start_time": "started_at",
+ "end_time": "ended_at",
+ "y_axis": "task",
+ "series": ["team"],
+ "tooltip_columns": ["owner"],
+ "tooltip_metrics": ["duration"],
+ "order_by_cols": ['["started_at", true]'],
+ },
+ ["started_at", "ended_at", "task", "team", "owner"],
+ ["duration"],
+ 1,
+ ),
+ (
+ {
+ "viz_type": "mixed_timeseries",
+ "x_axis": "ds",
+ "groupby": ["region"],
+ "metrics": ["revenue"],
+ "groupby_b": ["product"],
+ "metrics_b": ["profit"],
+ },
+ ["ds", "region"],
+ ["revenue"],
+ 2,
+ ),
+ (
+ {
+ "viz_type": "table",
+ "query_mode": "raw",
+ "all_columns": ["region", "revenue"],
+ "order_by_cols": ['["revenue", false]'],
+ },
+ ["region", "revenue"],
+ [],
+ 1,
+ ),
+ (
+ {
+ "viz_type": "echarts_timeseries_line",
+ "x_axis": "ds",
+ "groupby": ["region"],
+ "metrics": ["revenue"],
+ "series_limit": 5,
+ "series_limit_metric": "revenue",
+ "order_desc": False,
+ },
+ ["ds", "region"],
+ ["revenue"],
+ 1,
+ ),
+ (
+ {
+ "viz_type": "box_plot",
+ "columns": ["revenue"],
+ "groupby": ["region"],
+ "metrics": ["avg_revenue"],
+ "whiskerOptions": "Tukey",
+ },
+ ["revenue", "region"],
+ ["avg_revenue"],
+ 1,
+ ),
+ (
+ {
+ "viz_type": "ag-grid-pivot-table",
+ "groupby": ["region", "product"],
+ "metrics": ["revenue"],
+ },
+ ["region", "product"],
+ ["revenue"],
+ 1,
+ ),
+ ],
+ ids=[
+ "histogram",
+ "pivot",
+ "waterfall",
+ "gantt",
+ "mixed",
+ "raw-table",
+ "xy",
+ "box-plot",
+ "interactive-pivot",
+ ],
+)
+def test_product_query_context_uses_registered_frontend_adapters(
+ form_data: dict[str, object],
+ expected_columns: list[object],
+ expected_metrics: list[object],
+ expected_queries: int,
+) -> None:
+ """Exercise production QueryObject dictionaries at the factory boundary."""
+ factory = MagicMock()
+ factory.create.return_value = object()
+ query_form_data = {"datasource": "7__table", **deepcopy(form_data)}
+ with (
+ patch(
+
"superset.mcp_service.chart.chart_helpers.resolve_datasource_engine",
+ return_value="base",
+ ),
+ patch(
+ "superset.common.query_context_factory.QueryContextFactory",
+ return_value=factory,
+ ),
+ ):
+ build_query_context_from_form_data(query_form_data)
+
+ queries = factory.create.call_args.kwargs["queries"]
+ assert len(queries) == expected_queries
+ if (
+ isinstance(form_data.get("x_axis"), str)
+ and form_data["x_axis"] in expected_columns
+ ):
+ expected_columns = [
+ {
+ "columnType": "BASE_AXIS",
+ "sqlExpression": form_data["x_axis"],
+ "label": form_data["x_axis"],
+ "expressionType": "SQL",
+ "isColumnReference": True,
+ }
+ if column == form_data["x_axis"]
+ else column
+ for column in expected_columns
+ ]
+ assert queries[0]["columns"] == expected_columns
+ assert queries[0]["metrics"] == expected_metrics
+
+ if form_data["viz_type"] == "histogram_v2":
+ assert queries[0]["post_processing"][0]["operation"] == "histogram"
+ if form_data["viz_type"] == "box_plot":
+ assert queries[0]["post_processing"][0]["operation"] == "boxplot"
+ if form_data["viz_type"] == "echarts_timeseries_line":
+ assert queries[0]["series_columns"] == ["region"]
+ assert queries[0]["orderby"] == [["revenue", True]]
+ assert [rule["operation"] for rule in queries[0]["post_processing"]]
== [
+ "pivot",
+ "flatten",
+ ]
+ if form_data["viz_type"] == "waterfall":
+ assert queries[0]["orderby"] == [["month", True], ["region", True]]
+ if form_data["viz_type"] == "gantt_chart":
+ assert queries[0]["series_columns"] == ["team"]
+ assert queries[0]["orderby"] == [["started_at", True]]
+ if form_data["viz_type"] == "mixed_timeseries":
+ assert queries[1]["columns"] == [
+ {
+ "columnType": "BASE_AXIS",
+ "sqlExpression": "ds",
+ "label": "ds",
+ "expressionType": "SQL",
+ "isColumnReference": True,
+ },
+ "product",
+ ]
+ assert queries[1]["metrics"] == ["profit"]
+
+
[email protected]("secondary_key", ["adhoc_filters_b", "filters_b"])
+def test_mixed_secondary_explicit_filter_clear_never_inherits_primary(
+ secondary_key: str,
+) -> None:
+ primary_filter = {
+ "clause": "WHERE",
+ "expressionType": "SIMPLE",
+ "subject": "region",
+ "operator": "==",
+ "comparator": "EMEA",
+ }
+ form_data = {
+ "viz_type": "mixed_timeseries",
+ "x_axis": "ds",
+ "metrics": ["revenue"],
+ "metrics_b": ["profit"],
+ "adhoc_filters": [primary_filter],
+ secondary_key: [],
+ "time_range": "Last year",
+ "time_range_b": None,
+ }
+ with patch(
+ "superset.mcp_service.chart.chart_helpers.resolve_datasource_engine",
+ return_value="base",
+ ):
+ primary, secondary = build_query_dicts_from_form_data(form_data, 7,
"table")
+
+ assert primary["filters"] == [{"col": "region", "op": "==", "val": "EMEA"}]
+ assert secondary["filters"] == []
+ assert secondary.get("time_range") is None
+
+
[email protected](
+ "viz_type,roles",
+ [
+ ("table", {"all_columns": ["old"], "order_by_cols": ['["old",
true]']}),
+ ("pivot_table_v2", {"groupbyRows": ["old"], "groupbyColumns":
["old2"]}),
+ ("histogram_v2", {"column": "old", "groupby": ["old2"]}),
+ ("waterfall", {"x_axis": "old", "groupby": ["old2"]}),
+ ("echarts_timeseries_line", {"x_axis": "old", "series_columns":
["old2"]}),
+ ("mixed_timeseries", {"metrics_b": ["old"], "adhoc_filters_b": []}),
+ ("gantt_chart", {"start_time": "old", "tooltip_columns": ["old2"]}),
+ ("world_map", {"entity": "old", "metric": "old_metric"}),
+ ("deck_scatter", {"spatial": {"type": "latlong", "lonCol": "old"}}),
+ ],
+)
+def test_dataset_rebind_scrubs_complete_query_role_contracts(
+ viz_type: str, roles: dict[str, object]
+) -> None:
+ scrubbed = scrub_dataset_bound_form_data(
+ {"viz_type": viz_type, **roles, "show_legend": True}
+ )
+ assert set(roles).isdisjoint(scrubbed)
+ assert scrubbed == {"viz_type": viz_type, "show_legend": True}
+
+
+def test_dataset_rebind_fails_closed_without_a_complete_viz_contract() -> None:
+ with pytest.raises(ValueError, match="no complete dataset role contract"):
+ scrub_dataset_bound_form_data(
+ {"viz_type": "third_party_unknown", "mystery_column": "old"}
+ )
+
+
+_NATIVE_METRICS = [
+ "saved_revenue",
+ {
+ "expressionType": "SIMPLE",
+ "column": {"column_name": "revenue"},
+ "aggregate": "SUM",
+ "label": "SUM(revenue)",
+ "hasCustomLabel": False,
+ "optionName": "metric_revenue",
+ "datasourceWarning": False,
+ "sqlExpression": None,
+ },
+ {
+ "expressionType": "SQL",
+ "column": None,
+ "aggregate": None,
+ "sqlExpression": "COUNT(*)",
+ "label": "Count",
+ "hasCustomLabel": True,
+ "optionName": "metric_count",
+ "datasourceWarning": False,
+ },
+]
+
+
[email protected]("request_type", ["generate", "update", "preview"])
[email protected]("metric", _NATIVE_METRICS, ids=["saved", "simple",
"sql"])
+def test_native_xy_form_data_round_trips_through_every_request_model(
+ request_type: str, metric: object
+) -> None:
+ config = {
+ "viz_type": "echarts_timeseries_bar",
+ "x_axis": "ds",
+ "x_axis_title": "Date",
+ "x_axis_format": "smart_date",
+ "y_axis_title": "Revenue",
+ "y_axis_scale": "log",
+ "metrics": [metric],
+ "groupby": ["region"],
+ "row_limit": 123,
+ }
+ payload: dict[str, object]
+ request_model: type[
+ GenerateChartRequest | UpdateChartRequest | UpdateChartPreviewRequest
+ ]
+ if request_type == "generate":
+ request_model = GenerateChartRequest
+ payload = {"dataset_id": 7, "config": config}
+ elif request_type == "update":
+ request_model = UpdateChartRequest
+ payload = {"identifier": 19, "config": config}
+ else:
+ request_model = UpdateChartPreviewRequest
+ payload = {"dataset_id": 7, "config": config}
+
+ request = request_model.model_validate(payload)
+ xy = request.config
+ assert xy is not None
+ assert xy.x is not None
+ assert xy.x.name == "ds"
+ assert xy.x_axis is not None
+ assert xy.x_axis.title == "Date"
+ assert xy.y_axis is not None
+ assert xy.y_axis.scale == "log"
+
+ with patch(
+ "superset.mcp_service.chart.chart_utils.is_column_truly_temporal",
+ return_value=True,
+ ):
+ mapped = map_config_to_form_data(xy, dataset_id=7)
+ assert mapped["x_axis"] == "ds"
+ assert mapped["x_axis_title"] == "Date"
+ assert mapped["x_axis_format"] == "smart_date"
+ assert mapped["metrics"][0]
+
+
+def test_native_xy_object_axis_config_remains_presentation_state() -> None:
+ request = GenerateChartRequest.model_validate(
+ {
+ "dataset_id": 7,
+ "config": {
+ "viz_type": "echarts_timeseries_line",
+ "x": {"name": "ds"},
+ "x_axis": {"title": "Date", "format": "smart_date"},
+ "metrics": ["saved_revenue"],
+ },
+ }
+ )
+ assert request.config.x is not None
+ assert request.config.x.name == "ds"
+ assert request.config.x_axis is not None
+ assert request.config.x_axis.title == "Date"
+
+
[email protected](
+ "x_axis",
+ [
+ {"title": "Date", "formt": "smart_date"},
+ {"column_name": "ds", "column_nmae": "typo"},
+ {
+ "expressionType": "SIMPLE",
+ "column": {"column_name": "ds", "column_nmae": "typo"},
+ },
+ ],
+)
+def test_native_xy_rejects_malformed_nested_axis_state(x_axis: object) -> None:
+ with pytest.raises(ValidationError, match="Unknown"):
+ GenerateChartRequest.model_validate(
+ {
+ "dataset_id": 7,
+ "config": {
+ "viz_type": "echarts_timeseries_line",
+ "x_axis": x_axis,
+ "metrics": ["saved_revenue"],
+ },
+ }
+ )
+
+
+def test_native_xy_explicit_axis_reset_survives_sparse_merge() -> None:
+ request = UpdateChartRequest.model_validate(
+ {
+ "identifier": 19,
+ "config": {
+ "viz_type": "echarts_timeseries_line",
+ "x_axis": "ds",
+ "x_axis_title": None,
+ "metrics": ["saved_revenue"],
+ },
+ }
+ )
+ assert request.config is not None
+ with patch(
+ "superset.mcp_service.chart.chart_utils.is_column_truly_temporal",
+ return_value=True,
+ ):
+ mapped = map_config_to_form_data(request.config, dataset_id=7)
+ merged = merge_form_data_for_update(
+ {
+ "viz_type": "echarts_timeseries_line",
+ "x_axis": "old_ds",
+ "metrics": ["old_metric"],
+ "x_axis_title": "Old title",
+ },
+ mapped,
+ request.config,
+ )
+ assert "x_axis_title" not in merged
+
+
+def test_mixed_typed_secondary_nulls_remain_explicit_query_clears() -> None:
+ request = GenerateChartRequest.model_validate(
+ {
+ "dataset_id": 7,
+ "config": {
+ "chart_type": "mixed_timeseries",
+ "x": {"name": "ds"},
+ "y": [{"name": "revenue", "aggregate": "SUM"}],
+ "y_secondary": [{"name": "profit", "aggregate": "SUM"}],
+ "adhoc_filters_b": None,
+ "time_range_b": None,
+ "annotation_layers_b": [],
+ },
+ }
+ )
+ with patch(
+ "superset.mcp_service.chart.chart_utils.is_column_truly_temporal",
+ return_value=True,
+ ):
+ form_data = map_config_to_form_data(request.config, dataset_id=7)
+ form_data.update(
+ {
+ "adhoc_filters": [
+ {
+ "clause": "WHERE",
+ "expressionType": "SIMPLE",
+ "subject": "region",
+ "operator": "==",
+ "comparator": "EMEA",
+ }
+ ],
+ "time_range": "Last year",
+ }
+ )
+ with patch(
+ "superset.mcp_service.chart.chart_helpers.resolve_datasource_engine",
+ return_value="base",
+ ):
+ _, secondary = build_query_dicts_from_form_data(form_data, 7, "table")
+ assert secondary["filters"] == []
+ assert "time_range" not in secondary
+ assert secondary["annotation_layers"] == []
+
+
+def test_mixed_secondary_receives_dashboard_extra_form_data() -> None:
+ form_data = {
+ "viz_type": "mixed_timeseries",
+ "x_axis": "ds",
+ "metrics": ["revenue"],
+ "metrics_b": ["profit"],
+ }
+ extra_form_data = {"filters": [{"col": "region", "op": "IN", "val":
["EMEA"]}]}
+ with patch(
+ "superset.mcp_service.chart.chart_helpers.resolve_datasource_engine",
+ return_value="base",
+ ):
+ primary, secondary = build_query_dicts_from_form_data(
+ form_data, 7, "table", extra_form_data=extra_form_data
+ )
+ assert primary["filters"] == secondary["filters"]
+ assert secondary["filters"] == extra_form_data["filters"]
+
+
+def test_gantt_adapter_accepts_native_adhoc_axis_objects() -> None:
+ y_axis = {
+ "label": "Task",
+ "sqlExpression": "task_name",
+ "expressionType": "SQL",
+ }
+ with patch(
+ "superset.mcp_service.chart.chart_helpers.resolve_datasource_engine",
+ return_value="base",
+ ):
+ query = build_query_dicts_from_form_data(
+ {
+ "viz_type": "gantt_chart",
+ "start_time": "started_at",
+ "end_time": "ended_at",
+ "y_axis": y_axis,
+ "series": "team",
+ "tooltip_metrics": ["duration"],
+ "order_by_cols": ['["started_at", true]'],
+ },
+ 7,
+ "table",
+ )[0]
+ assert query["columns"] == ["started_at", "ended_at", y_axis, "team"]
+ assert query["series_columns"] == ["team"]
+
+
+def test_pivot_non_additive_metrics_preserve_grouping_sets_contract() -> None:
+ with patch(
+ "superset.mcp_service.chart.chart_helpers.resolve_datasource_engine",
+ return_value="base",
+ ):
+ query = build_query_dicts_from_form_data(
+ {
+ "viz_type": "pivot_table_v2",
+ "groupbyRows": ["region"],
+ "groupbyColumns": ["product"],
+ "metrics": ["saved_revenue"],
+ "rowTotals": True,
+ "colTotals": True,
+ },
+ 7,
+ "table",
+ )[0]
+ assert query["grouping_sets"] == [
+ [],
+ ["product"],
+ ["region"],
+ ["region", "product"],
+ ]
+
+
+def test_big_number_raw_aggregation_preserves_two_query_contract() -> None:
+ with patch(
+ "superset.mcp_service.chart.chart_helpers.resolve_datasource_engine",
+ return_value="base",
+ ):
+ trend, overall = build_query_dicts_from_form_data(
+ {
+ "viz_type": "big_number",
+ "metric": "saved_ratio",
+ "granularity_sqla": "ds",
+ "aggregation": "raw",
+ },
+ 7,
+ "table",
+ )
+ assert trend["columns"] == []
+ assert trend["is_timeseries"] is True
+ assert trend["post_processing"][0]["options"]["index"] == ["__timestamp"]
+ assert [rule["operation"] for rule in trend["post_processing"]] == [
+ "pivot",
+ "flatten",
+ ]
+ assert overall["columns"] == []
+ assert overall["is_timeseries"] is False
+ assert overall["post_processing"] == []
+
+
+def _sunburst_replacement_config() -> SunburstChartConfig:
+ return SunburstChartConfig(
+ hierarchy=[{"name": "region"}, {"name": "country"}],
+ metric={"name": "sales", "aggregate": "SUM"},
+ )
+
+
+def test_cached_table_rebind_drops_legacy_sql_predicates() -> None:
+ """Top-level ``where``/``having`` reference the previous dataset."""
+ cached = {
+ "viz_type": "table",
+ "datasource": "10__table",
+ "query_mode": "aggregate",
+ "groupby": ["region"],
+ "metrics": ["count"],
+ "where": "region = 'EMEA'",
+ "having": "COUNT(*) > 5",
+ "show_cell_bars": True,
+ }
+ config = TableChartConfig(columns=[{"name": "region"}])
+ new_form_data = map_config_to_form_data(config)
+
+ merged = merge_form_data_for_update(
+ cached, new_form_data, config, dataset_rebind=True
+ )
+
+ assert "where" not in merged
+ assert "having" not in merged
+ with patch(
+ "superset.mcp_service.chart.chart_helpers.resolve_datasource_engine",
+ return_value="base",
+ ):
+ (query,) = build_query_dicts_from_form_data(merged, 99, "table")
+ assert "region = 'EMEA'" not in str(query)
+ assert "COUNT(*) > 5" not in str(query)
+
+
+def test_cross_viz_rebind_from_unregistered_viz_uses_mapped_target() -> None:
+ """A complete config replaces a source viz that has no role contract."""
+ saved = {
+ "viz_type": "word_cloud",
+ "datasource": "10__table",
+ "series": "old_word",
+ "metric": "old_count",
+ "rotation": "square",
+ "adhoc_filters": [{"subject": "old_word"}],
+ "color_scheme": "supersetColors",
+ }
+ config = _sunburst_replacement_config()
+ new_form_data = map_config_to_form_data(config)
+
+ merged = merge_form_data_for_update(
+ saved, new_form_data, config, dataset_rebind=True
+ )
+
+ assert merged["viz_type"] == "sunburst_v2"
+ assert merged["columns"] == ["region", "country"]
+ assert merged["color_scheme"] == "supersetColors"
+ assert {"series", "rotation", "adhoc_filters"}.isdisjoint(merged)
+ assert "old_word" not in str(merged)
+ assert "old_count" not in str(merged)
+
+
+def test_cross_viz_dataset_only_scrub_still_requires_role_contract() -> None:
+ """Without a replacement viz, an unknown source still fails closed."""
+ with pytest.raises(ValueError, match="no complete dataset role contract"):
+ scrub_dataset_bound_form_data({"viz_type": "word_cloud", "series":
"old"})
+
+
+def test_explicit_empty_filters_clear_legacy_predicates_on_cross_viz_update()
-> None:
+ """``filters=[]`` removes adhoc, legacy structured, and free-form SQL
state."""
+ saved = {
+ "viz_type": "table",
+ "datasource": "10__table",
+ "query_mode": "aggregate",
+ "groupby": ["region"],
+ "metrics": ["count"],
+ "adhoc_filters": [
+ {
+ "clause": "WHERE",
+ "expressionType": "SIMPLE",
+ "subject": "region",
+ "operator": "==",
+ "comparator": "EMEA",
+ }
+ ],
+ "filters": [{"col": "region", "op": "==", "val": "EMEA"}],
+ "where": "region = 'EMEA'",
+ "having": "COUNT(*) > 5",
+ }
+ config = SunburstChartConfig(
+ hierarchy=[{"name": "region"}, {"name": "country"}],
+ metric={"name": "sales", "aggregate": "SUM"},
+ filters=[],
+ )
+ new_form_data = map_config_to_form_data(config)
+
+ merged = merge_form_data_for_update(saved, new_form_data, config)
+
+ assert {"adhoc_filters", "filters", "where", "having"}.isdisjoint(merged)
+ with patch(
+ "superset.mcp_service.chart.chart_helpers.resolve_datasource_engine",
+ return_value="base",
+ ):
+ (query,) = build_query_dicts_from_form_data(merged, 10, "table")
+ assert query["filters"] == []
+ assert "EMEA" not in str(query)
+
+
[email protected]("chart_type", ["xy", "bar", "echarts_timeseries_bar"])
+def test_typed_xy_metrics_alias_keeps_column_semantics(chart_type: str) ->
None:
+ """Only native Explore payloads read ``metrics`` strings as saved
metrics."""
+ request = GenerateChartRequest.model_validate(
+ {
+ "dataset_id": 7,
+ "config": {
+ "chart_type": chart_type,
+ "x": {"name": "region"},
+ "metrics": ["revenue"],
+ },
+ }
+ )
+
+ assert request.config is not None
+ (metric,) = request.config.y
+ assert metric.name == "revenue"
+ assert metric.saved_metric is False
+ mapped = map_config_to_form_data(request.config, dataset_id=7)
+ (mapped_metric,) = mapped["metrics"]
+ assert mapped_metric["expressionType"] == "SIMPLE"
+ assert mapped_metric["aggregate"] == "SUM"
+ assert mapped_metric["column"]["column_name"] == "revenue"
+
+
+def test_native_xy_metrics_strings_remain_saved_metric_references() -> None:
+ request = GenerateChartRequest.model_validate(
+ {
+ "dataset_id": 7,
+ "config": {
+ "viz_type": "echarts_timeseries_bar",
+ "x_axis": "region",
+ "metrics": ["revenue"],
+ },
+ }
+ )
+
+ assert request.config is not None
+ (metric,) = request.config.y
+ assert metric.name == "revenue"
+ assert metric.saved_metric is True
+
+
+def test_replacement_filters_drop_legacy_predicates_on_cross_viz_update() ->
None:
+ """Typed filters replace every saved predicate source, not only adhoc
ones."""
+ saved = {
+ "viz_type": "table",
+ "datasource": "10__table",
+ "query_mode": "aggregate",
+ "groupby": ["region"],
+ "metrics": ["count"],
+ "filters": [{"col": "region", "op": "==", "val": "EMEA"}],
+ "extra_filters": [{"col": "region", "op": "in", "val": ["EMEA"]}],
+ "where": "region = 'EMEA'",
+ "having": "COUNT(*) > 5",
+ }
+ config = SunburstChartConfig(
+ hierarchy=[{"name": "region"}, {"name": "country"}],
+ metric={"name": "sales", "aggregate": "SUM"},
+ filters=[{"column": "region", "op": "=", "value": "APAC"}],
+ )
+ new_form_data = map_config_to_form_data(config)
+
+ merged = merge_form_data_for_update(saved, new_form_data, config)
+
+ assert {"filters", "extra_filters", "where", "having"}.isdisjoint(merged)
Review Comment:
This replacement test goes Table → Sunburst, where the cross-viz allowlist
already excludes `where`/`having`, so the assertions here pass even if `where`
and `having` are removed from `_SAVED_PREDICATE_FORM_DATA_KEYS`. The same-viz
path that the new `requested_filters is not None` branch actually serves has no
case: update an aggregate Table on the same dataset that has saved `filters`,
`where="region = 'EMEA'"` and `having="COUNT(*) > 5"` with `filters=[APAC]`,
then assert via `merge_form_data_for_update` and the query builder that only
APAC remains and neither SQL clause survives.
Could you add that same-viz case?
##########
superset/common/form_data_query_context.py:
##########
@@ -288,71 +461,1272 @@ def _pie_contribution_post_processing(metrics:
list[Any]) -> list[dict[str, Any]
]
-def build_query_context_from_form_data(
- form_data: dict[str, Any],
- datasource: dict[str, Any],
- viz_type: str | None = None,
+def _as_list(value: Any) -> list[Any]:
+ """Return the frontend ``ensureIsArray`` representation of a value."""
+ if value is None:
+ return []
+ return list(value) if isinstance(value, (list, tuple)) else [value]
+
+
+def _label(value: Any, *, metric: bool = False) -> str:
+ """Resolve a frontend-compatible query-field label."""
+ try:
+ return get_metric_name(value) if metric else get_column_name(value)
+ except (AttributeError, KeyError, TypeError, ValueError):
+ if isinstance(value, Mapping):
+ return str(
+ value.get("label")
+ or value.get("column_name")
+ or value.get("sqlExpression")
+ or value
+ )
+ return str(value)
+
+
+def _deduplicate_fields(values: list[Any], *, metric: bool = False) ->
list[Any]:
+ """Deduplicate query fields by their frontend-visible label."""
+ result: list[Any] = []
+ labels: set[str] = set()
+ for value in values:
+ if value is None or value == "":
+ continue
+ label = _label(value, metric=metric)
+ if label in labels:
+ continue
+ labels.add(label)
+ result.append(value)
+ return result
+
+
+def retain_mixed_timeseries_secondary_form_data(
+ form_data: Mapping[str, Any],
) -> dict[str, Any]:
- """
- Build a query-context payload (the JSON shape
``ChartDataQueryContextSchema``
- loads) from a chart's form data and datasource reference.
+ """Mirror ``retainFormDataSuffix(formData, '_b')`` exactly.
- :param form_data: The chart's saved ``params`` parsed to a dict.
- :param datasource: ``{"id": <int>, "type": "table"}`` datasource reference.
- :param viz_type: The chart's viz type, used for viz-specific handling.
- :returns: A single-query query-context dict.
+ Suffixed values are installed first, including falsey values, and shared
+ unsuffixed controls fill only keys that query B did not explicitly set.
"""
- columns, metrics = _columns_and_metrics(form_data, viz_type)
-
- # SIMPLE adhoc filters (+ legacy top-level ``filters``) become query
filters;
- # free-form SQL predicates go into ``extras``. Only ``WHERE``-clause SIMPLE
- # filters are applied (matching the chart), so the export never filters on
a
- # ``HAVING`` clause the chart itself ignores.
- filters = adhoc_filters_to_query_filters(
- form_data.get("adhoc_filters", []), where_only=True
- )
- for flt in form_data.get("filters") or []:
- if isinstance(flt, dict) and flt.get("col") is not None:
- filters.append(flt)
+ secondary: dict[str, Any] = {}
+ for key, value in form_data.items():
+ if key.endswith("_b"):
+ secondary[key[:-2]] = value
+ for key, value in form_data.items():
+ if not key.endswith("_b") and key not in secondary:
+ secondary[key] = value
+ secondary_filter_keys = {
+ "adhoc_filters": "adhoc_filters_b",
+ "extra_filters": "extra_filters_b",
+ "filters": "filters_b",
+ "having": "having_b",
+ "where": "where_b",
+ }
+ if any(suffixed in form_data for suffixed in
secondary_filter_keys.values()):
+ # The frontend exposes adhoc_filters_b, while saved/server payloads can
+ # carry equivalent legacy aliases. Treat the family atomically: an
+ # explicit clear in any B alias must not be repopulated by query A's
+ # differently named filter representation.
+ for primary, suffixed in secondary_filter_keys.items():
+ if suffixed not in form_data:
+ secondary.pop(primary, None)
+ return secondary
- extras = freeform_where_having(form_data)
- if form_data.get("time_grain_sqla"):
- extras["time_grain_sqla"] = form_data["time_grain_sqla"]
- # Prefer the modern ``time_range``; fall back to the legacy
``since``/``until``
- # pair (older charts store the range that way) before defaulting to no
filter.
- time_range = form_data.get("time_range")
- if not time_range and (form_data.get("since") or form_data.get("until")):
- time_range = f"{form_data.get('since') or ''} :
{form_data.get('until') or ''}"
- time_range = time_range or "No filter"
+def _base_query_object( # noqa: C901
+ form_data: dict[str, Any],
+ *,
+ row_limit: int | None,
+ order_desc: bool | None,
+ filters_prepared: bool,
+) -> dict[str, Any]:
+ """Build the shared frontend-equivalent portion of a QueryObject."""
+ columns, metrics, orderby = query_fields_from_form_data(form_data)
query: dict[str, Any] = {
"columns": columns,
"metrics": metrics,
- "orderby": orderby_from_form_data(form_data, metrics, viz_type),
- "filters": filters,
- "time_range": time_range,
}
+ if orderby:
+ query["orderby"] = orderby
+
+ if filters_prepared:
+ query["filters"] = list(form_data.get("filters") or [])
+ for clause in ("where", "having"):
+ if form_data.get(clause):
+ query[clause] = form_data[clause]
+ if form_data.get("extras"):
+ query["extras"] = dict(form_data["extras"])
+ else:
+ filters = adhoc_filters_to_query_filters(
+ form_data.get("adhoc_filters", []), where_only=True
+ )
+ filters.extend(
+ filter_
+ for filter_ in form_data.get("filters") or []
+ if isinstance(filter_, dict) and filter_.get("col") is not None
+ )
+ query["filters"] = filters
+ if extras := freeform_where_having(form_data):
+ query["extras"] = extras
+
+ extras = dict(query.get("extras") or {})
+ if form_data.get("time_grain_sqla") is not None:
+ extras["time_grain_sqla"] = form_data["time_grain_sqla"]
if extras:
query["extras"] = extras
- if viz_type == "pie" and (
- post_processing := _pie_contribution_post_processing(metrics)
+
+ effective_limit = row_limit if row_limit is not None else
form_data.get("row_limit")
+ if effective_limit is not None:
+ query["row_limit"] = effective_limit
+ if form_data.get("row_offset") is not None:
+ query["row_offset"] = form_data["row_offset"]
+ if order_desc is not None:
+ query["order_desc"] = order_desc
+ elif "order_desc" in form_data and form_data["order_desc"] is not None:
+ query["order_desc"] = form_data["order_desc"]
+
+ time_range = form_data.get("time_range")
+ if not time_range and (form_data.get("since") or form_data.get("until")):
+ time_range = f"{form_data.get('since') or ''} :
{form_data.get('until') or ''}"
+ if time_range:
+ query["time_range"] = time_range
+ for key in ("since", "until", "annotation_layers", "url_params",
"custom_params"):
+ if form_data.get(key) is not None:
+ query[key] = form_data[key]
+
+ granularity = form_data.get("granularity") or
form_data.get("granularity_sqla")
+ if granularity:
+ query["granularity"] = granularity
+ series_limit = form_data.get("series_limit", form_data.get("limit"))
+ if series_limit is not None:
+ query["series_limit"] = series_limit
+ series_limit_metric = form_data.get("series_limit_metric")
+ if series_limit_metric is None:
+ series_limit_metric = form_data.get("timeseries_limit_metric")
+ if series_limit_metric is not None:
+ query["series_limit_metric"] = series_limit_metric
+ if form_data.get("group_others_when_limit_reached") is not None:
+ query["group_others_when_limit_reached"] = form_data[
+ "group_others_when_limit_reached"
+ ]
+ return query
+
+
+def _temporalized_columns(form_data: dict[str, Any], columns: list[Any]) ->
list[Any]:
+ """Apply the pivot BASE_AXIS temporal-column contract."""
+ time_grain = form_data.get("time_grain_sqla")
+ temporal_lookup = form_data.get("temporal_columns_lookup") or {}
+ result: list[Any] = []
+ for column in columns:
+ if (
+ isinstance(column, str)
+ and time_grain
+ and (
+ temporal_lookup.get(column)
+ or form_data.get("granularity_sqla") == column
+ )
+ ):
+ result.append(
+ {
+ "timeGrain": time_grain,
+ "columnType": "BASE_AXIS",
+ "sqlExpression": column,
+ "label": column,
+ "expressionType": "SQL",
+ }
+ )
+ else:
+ result.append(column)
+ return result
+
+
+def _box_temporalized_columns(
+ form_data: dict[str, Any], columns: list[Any]
+) -> list[Any]:
+ """Convert only physical columns confirmed temporal by Box Plot
metadata."""
+ time_grain = form_data.get("time_grain_sqla")
+ temporal_lookup = form_data.get("temporal_columns_lookup")
+ if not time_grain or not isinstance(temporal_lookup, Mapping):
+ return columns
+ return [
+ {
+ "timeGrain": time_grain,
+ "columnType": "BASE_AXIS",
+ "sqlExpression": column,
+ "label": column,
+ "expressionType": "SQL",
+ }
+ if isinstance(column, str) and temporal_lookup.get(column) is True
+ else column
+ for column in columns
+ ]
+
+
+def _table_temporalized_columns(
+ form_data: dict[str, Any], columns: list[Any]
+) -> list[Any]:
+ """Promote the first temporal table group-by to the frontend BASE_AXIS.
+
+ Table's builder treats only physical columns named in
+ ``temporal_columns_lookup`` as temporal and moves the first match to the
+ front. Later temporal dimensions remain ordinary group-bys.
+ """
+ time_grain = form_data.get("time_grain_sqla")
+ temporal_lookup = form_data.get("temporal_columns_lookup") or {}
+ if not time_grain or not isinstance(temporal_lookup, Mapping):
+ return columns
+
+ temporal_column: dict[str, Any] | None = None
+ remaining: list[Any] = []
+ for column in columns:
+ if (
+ temporal_column is None
+ and isinstance(column, str)
+ and temporal_lookup.get(column)
+ ):
+ temporal_column = {
+ "timeGrain": time_grain,
+ "columnType": "BASE_AXIS",
+ "sqlExpression": column,
+ "label": column,
+ "expressionType": "SQL",
+ }
+ else:
+ remaining.append(column)
+ return [temporal_column, *remaining] if temporal_column else columns
+
+
+def _histogram_query(form_data: dict[str, Any], query: dict[str, Any]) -> None:
+ groupby = _as_list(form_data.get("groupby"))
+ column = form_data.get("column")
+ query["columns"] = [*groupby, *([column] if column is not None else [])]
+ query["post_processing"] = [
+ {
+ "operation": "histogram",
+ "options": {
+ "column": _label(column),
+ "groupby": [_label(value) for value in groupby],
+ "bins": int(form_data.get("bins", 5)),
+ "cumulative": form_data.get("cumulative", False),
+ "normalize": form_data.get("normalize", False),
+ },
+ }
+ ]
+ if any(
+ isinstance(filter_, dict) and filter_.get("clause") == "HAVING"
+ for filter_ in form_data.get("adhoc_filters") or []
+ ):
+ query["metrics"] = [
+ {
+ "expressionType": "SQL",
+ "sqlExpression": "COUNT(*)",
+ "label": "COUNT(*)",
+ }
+ ]
+ else:
+ query["metrics"] = []
+
+
+def _box_plot_query(form_data: dict[str, Any], query: dict[str, Any]) -> None:
+ distributed = _as_list(form_data.get("columns"))
+ if not distributed and form_data.get("granularity_sqla"):
+ distributed = [form_data["granularity_sqla"]]
+ groupby = _as_list(form_data.get("groupby"))
+ query["columns"] = [*_box_temporalized_columns(form_data, distributed),
*groupby]
+ query["series_columns"] = groupby
+ whisker = form_data.get("whiskerOptions")
+ if not whisker:
+ query["post_processing"] = []
+ return
+ whisker_type = "tukey"
+ percentiles: list[int] | None = None
+ if whisker == "Min/max (no outliers)":
+ whisker_type = "min/max"
+ elif isinstance(whisker, str) and whisker.endswith(" percentiles"):
+ low, high = whisker.removesuffix(" percentiles").split("/", 1)
+ whisker_type = "percentile"
+ percentiles = [int(low), int(high)]
+ query["post_processing"] = [
+ {
+ "operation": "boxplot",
+ "options": {
+ "whisker_type": whisker_type,
+ "percentiles": percentiles,
+ "groupby": [_label(value) for value in groupby],
+ "metrics": [_label(value, metric=True) for value in
query["metrics"]],
+ },
+ }
+ ]
+
+
+_PIVOT_ADDITIVE_AGGREGATES = frozenset({"SUM", "COUNT", "MIN", "MAX"})
+
+
+def _all_metrics_additive(metrics: list[Any]) -> bool:
+ """Mirror Pivot's conservative additive-metric fast-path."""
+ return bool(metrics) and all(
+ isinstance(metric, Mapping)
+ and metric.get("expressionType") == "SIMPLE"
+ and metric.get("aggregate") in _PIVOT_ADDITIVE_AGGREGATES
+ for metric in metrics
+ )
+
+
+def _pivot_grouping_sets(
+ form_data: dict[str, Any], rows: list[Any], columns: list[Any]
+) -> list[list[str]]:
+ """Enumerate the rollup levels requested by Pivot's frontend builder."""
+ row_prefixes = [[], *(rows[: index + 1] for index in range(len(rows)))]
+ column_prefixes = [
+ [],
+ *(columns[: index + 1] for index in range(len(columns))),
+ ]
+ show_values_as = form_data.get("showValuesAs")
+ needs_rows_collapsed = show_values_as in {"percent_col", "percent_total"}
+ needs_columns_collapsed = show_values_as in {"percent_row",
"percent_total"}
+
+ def row_prefix_needed(prefix: list[Any]) -> bool:
+ if len(prefix) == len(rows):
+ return True
+ if not prefix:
+ return bool(form_data.get("colTotals")) or needs_rows_collapsed
+ return bool(form_data.get("rowSubTotals"))
+
+ def column_prefix_needed(prefix: list[Any]) -> bool:
+ if len(prefix) == len(columns):
+ return True
+ if not prefix:
+ return bool(form_data.get("rowTotals")) or needs_columns_collapsed
+ return bool(form_data.get("colSubTotals"))
+
+ levels = [
+ (row_prefix, column_prefix)
+ for row_prefix in row_prefixes
+ if row_prefix_needed(row_prefix)
+ for column_prefix in column_prefixes
+ if column_prefix_needed(column_prefix)
+ ]
+ if form_data.get("combineMetric"):
+ metrics_layout = form_data.get("metricsLayout")
+
+ def forced_denominator(level: tuple[list[Any], list[Any]]) -> bool:
+ row_prefix, column_prefix = level
+ return (needs_rows_collapsed and not row_prefix) or (
+ needs_columns_collapsed and not column_prefix
+ )
+
+ if metrics_layout == "ROWS":
+ levels = [
+ level
+ for level in levels
+ if len(level[0]) == len(rows) or forced_denominator(level)
+ ]
+ else:
+ levels = [
+ level
+ for level in levels
+ if len(level[1]) == len(columns) or forced_denominator(level)
+ ]
+
+ return [
+ [_label(value) for value in _deduplicate_fields([*row_prefix,
*column_prefix])]
+ for row_prefix, column_prefix in levels
+ ]
+
+
+def _pivot_query(form_data: dict[str, Any], query: dict[str, Any]) -> None:
+ rows = _as_list(form_data.get("groupbyRows"))
+ columns = _as_list(form_data.get("groupbyColumns"))
+ if form_data.get("transposePivot"):
+ rows, columns = columns, rows
+ query["columns"] = _temporalized_columns(
+ form_data, _deduplicate_fields([*rows, *columns])
+ )
+ metric = query.get("series_limit_metric") or next(
+ iter(query.get("metrics") or []), None
+ )
+ query["orderby"] = (
+ [[metric, not bool(query.get("order_desc", True))]]
+ if metric is not None
+ else []
+ )
+ if not _all_metrics_additive(query.get("metrics") or []):
+ query["grouping_sets"] = _pivot_grouping_sets(form_data, rows, columns)
+
+
+def _waterfall_query(form_data: dict[str, Any], query: dict[str, Any]) -> None:
+ x_axis = form_data.get("x_axis") or form_data.get("granularity_sqla")
+ columns = [*_as_list(x_axis), *_as_list(form_data.get("groupby"))]
+ query["columns"] = _deduplicate_fields(columns)
+ query["orderby"] = [[column, True] for column in query["columns"]]
+
+
+def _gantt_query(form_data: dict[str, Any], query: dict[str, Any]) -> None:
+ groupby = _as_list(form_data.get("series"))
+ orderby = query_fields_from_form_data(form_data)[2]
+ columns = [
+ form_data.get("start_time"),
+ form_data.get("end_time"),
+ form_data.get("y_axis"),
+ *groupby,
+ *_as_list(form_data.get("tooltip_columns")),
+ *(entry[0] for entry in orderby if entry),
+ ]
+ query["columns"] = _deduplicate_fields(columns)
+ query["metrics"] = _as_list(form_data.get("tooltip_metrics"))
+ query["orderby"] = orderby
+ query["series_columns"] = groupby
+
+
+def _normalize_query_orderby(query: dict[str, Any]) -> None:
+ """Mirror ``normalizeOrderBy`` while retaining limit-direction controls."""
+ 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
+ metric = (
+ query.get("series_limit_metric")
+ or query.get("legacy_order_by")
+ or next(iter(query.get("metrics") or []), None)
+ )
+ if metric is None:
+ query.pop("orderby", None)
+ return
+ query["orderby"] = [[metric, not bool(query.get("order_desc", True))]]
+
+
+_TIME_COMPARISON_TYPES = frozenset({"values", "difference", "percentage",
"ratio"})
+
+
+def _metric_offset_map(
+ form_data: dict[str, Any],
+ metric_labels: list[str],
+ offsets: list[Any] | None = None,
+) -> dict[str, str]:
+ """Return the frontend time-comparison metric label map."""
+ if form_data.get("comparison_type") not in _TIME_COMPARISON_TYPES:
+ return {}
+ return {
+ f"{metric}__{offset}": metric
+ for metric in metric_labels
+ for offset in (
+ offsets if offsets is not None else
_as_list(form_data.get("time_compare"))
+ )
+ }
+
+
+def _table_time_offsets(form_data: dict[str, Any]) -> list[Any]:
+ """Resolve Table custom/inherited shifts like its frontend query
adapter."""
+ raw_offsets = _as_list(form_data.get("time_compare"))
+ offsets = [offset for offset in raw_offsets if offset not in {"custom",
"inherit"}]
+ if "custom" in raw_offsets and form_data.get("start_date_offset") is not
None:
+ offsets.append(form_data["start_date_offset"])
+ extra_form_data = form_data.get("extra_form_data")
+ if isinstance(extra_form_data, Mapping) and
extra_form_data.get("time_compare"):
+ inherited = extra_form_data["time_compare"]
+ if inherited not in offsets:
+ offsets = [inherited]
+ return offsets
+
+
+def _x_axis_column(form_data: Mapping[str, Any]) -> Any | None:
+ """Return a supported x-axis column, excluding legacy granularity.
+
+ ``column_name`` mappings are retained for old server/native payloads. Big
+ Number uses the stricter frontend predicate below.
+ """
+ x_axis = form_data.get("x_axis")
+ if isinstance(x_axis, str):
+ return x_axis if x_axis else None
+ if isinstance(x_axis, Mapping):
+ if isinstance(column_name := x_axis.get("column_name"), str) and
column_name:
+ return column_name
+ # Frontend SQL adhoc columns remain objects in the QueryObject.
+ return x_axis if x_axis else None
+ return None
+
+
+def _frontend_x_axis_column(form_data: Mapping[str, Any]) -> Any | None:
+ """Mirror ``isQueryFormColumn`` for physical and SQL adhoc columns."""
+ x_axis = form_data.get("x_axis")
+ if isinstance(x_axis, str):
+ return x_axis if x_axis else None
+ if (
+ isinstance(x_axis, Mapping)
+ and "sqlExpression" in x_axis
+ and "label" in x_axis
+ and x_axis.get("expressionType") in {None, "SQL"}
+ ):
+ return x_axis
+ return None
+
+
+def normalize_time_column(
+ form_data: Mapping[str, Any], query: dict[str, Any]
+) -> dict[str, Any]:
+ """Apply the final shared frontend ``normalizeTimeColumn`` mutator."""
+ x_axis = _frontend_x_axis_column(form_data)
+ columns = query.get("columns")
+ if x_axis is None or not isinstance(columns, list):
+ return query
+
+ axis_index: int | None = None
+ for index, column in enumerate(columns):
+ if isinstance(x_axis, str) and isinstance(column, str) and column ==
x_axis:
+ axis_index = index
+ break
+ if (
+ isinstance(x_axis, Mapping)
+ and isinstance(column, Mapping)
+ and column.get("sqlExpression") == x_axis.get("sqlExpression")
+ ):
+ axis_index = index
+ break
+ if axis_index is None:
+ return query
+
+ normalized = dict(query)
+ normalized_columns = list(columns)
+ grain = (query.get("extras") or {}).get("time_grain_sqla")
+ if isinstance(columns[axis_index], Mapping):
+ normalized_axis = {
+ "columnType": "BASE_AXIS",
+ **({"timeGrain": grain} if grain is not None else {}),
+ **columns[axis_index],
+ }
+ else:
+ normalized_axis = {
+ "columnType": "BASE_AXIS",
+ "sqlExpression": x_axis,
+ "label": x_axis,
+ "expressionType": "SQL",
+ "isColumnReference": True,
+ **({"timeGrain": grain} if grain is not None else {}),
+ }
+ normalized_columns[axis_index] = normalized_axis
+ normalized["columns"] = normalized_columns
+ normalized.pop("is_timeseries", None)
+ return normalized
+
+
+def _finalize_query_objects(
+ form_data: Mapping[str, Any], queries: list[dict[str, Any]]
+) -> list[dict[str, Any]]:
+ """Run shared query-context mutators after every visualization adapter."""
+ return [normalize_time_column(form_data, query) for query in queries]
+
+
+def _x_axis_label(
+ form_data: Mapping[str, Any], *, frontend_strict: bool = False
+) -> str | None:
+ """Mirror getXAxisColumn/getXAxisLabel for explicit and legacy axes."""
+ explicit = (
+ _frontend_x_axis_column(form_data)
+ if frontend_strict
+ else _x_axis_column(form_data)
+ )
+ if explicit:
+ return _label(explicit)
+ if form_data.get("granularity_sqla"):
+ return DTTM_ALIAS
+ return None
+
+
+def _rename_operator(
+ form_data: dict[str, Any],
+ query: dict[str, Any],
+ *,
+ x_axis_label: str | None,
+) -> dict[str, Any] | None:
+ """Mirror the ECharts ``renameOperator`` for Timeseries and Mixed
charts."""
+ metrics = list(query.get("metrics") or [])
+ metric_labels = [_label(metric, metric=True) for metric in metrics]
+ series_columns = query.get("series_columns")
+ columns = _as_list(
+ series_columns if series_columns is not None else query.get("columns")
+ )
+ time_offsets = _as_list(form_data.get("time_compare"))
+ offset_map = _metric_offset_map(form_data, metric_labels)
+ is_time_comparison = bool(offset_map)
+ truncate_metric = form_data.get("truncate_metric")
+
+ should_rename = (
+ bool(metrics)
+ and bool(x_axis_label)
+ and (
+ is_time_comparison
+ or (
+ (bool(columns) or len(time_offsets) > 1)
+ and "truncate_metric" in form_data
+ and bool(truncate_metric)
+ )
+ )
+ )
+ if not should_rename:
+ return None
+
+ renamed: dict[str, str | None] = {}
+ comparison_type = form_data.get("comparison_type")
+ if is_time_comparison:
+ for metric_with_offset, metric_only in offset_map.items():
+ offset_label = next(
+ (
+ str(offset)
+ for offset in time_offsets
+ if metric_with_offset.endswith(f"__{offset}")
+ ),
+ None,
+ )
+ source = (
+ metric_with_offset
+ if comparison_type == "values"
+ else f"{comparison_type}__{metric_only}__{metric_with_offset}"
+ )
+ renamed[source] = (
+ f"{metric_only}, {offset_label}" if len(metrics) > 1 else
offset_label
+ )
+
+ if (
+ comparison_type not in {"difference", "percentage", "ratio"}
+ and len(metrics) == 1
+ and not renamed
+ ):
+ renamed[metric_labels[0]] = None
+ if not renamed:
+ return None
+ return {
+ "operation": "rename",
+ "options": {"columns": renamed, "level": 0, "inplace": True},
+ }
+
+
+def _timeseries_post_processing( # noqa: C901
+ form_data: dict[str, Any],
+ query: dict[str, Any],
+ *,
+ x_axis_label: str | None,
+ groupby: list[Any],
+ mixed: bool,
+) -> tuple[list[dict[str, Any]], list[Any]]:
+ """Build the Timeseries/Mixed operator pipeline in frontend order."""
+ metric_labels = [_label(value, metric=True) for value in
query.get("metrics") or []]
+ offset_map = _metric_offset_map(form_data, metric_labels)
+ time_offsets = _as_list(form_data.get("time_compare")) if offset_map else
[]
+ post_processing: list[dict[str, Any]] = []
+
+ if x_axis_label and metric_labels:
+ aggregate_labels = (
+ [*offset_map.values(), *offset_map] if offset_map else
metric_labels
+ )
+ post_processing.append(
+ {
+ "operation": "pivot",
+ "options": {
+ "index": [x_axis_label],
+ "columns": [_label(value) for value in groupby],
+ "aggregates": {
+ label: {"operator": "mean"} for label in
aggregate_labels
+ },
+ "drop_missing_columns": not form_data.get(
+ "show_empty_columns", False
+ ),
+ },
+ }
+ )
+
+ if form_data.get("resample_method") and form_data.get("resample_rule"):
+ zero_fill = form_data["resample_method"] == "zerofill"
+ post_processing.append(
+ {
+ "operation": "resample",
+ "options": {
+ "method": "asfreq" if zero_fill else
form_data["resample_method"],
+ "rule": form_data["resample_rule"],
+ "fill_value": 0 if zero_fill else None,
+ },
+ }
+ )
+
+ rolling_labels = (
+ [*offset_map.values(), *offset_map] if offset_map else metric_labels
+ )
+ columns_map = {label: label for label in rolling_labels}
+ rolling_type = form_data.get("rolling_type")
+ if rolling_type == "cumsum":
+ post_processing.append(
+ {
+ "operation": "cum",
+ "options": {"operator": "sum", "columns": columns_map},
+ }
+ )
+ elif rolling_type in {"sum", "mean", "std"}:
+ post_processing.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": columns_map,
+ },
+ }
+ )
+
+ comparison_type = form_data.get("comparison_type")
+ if offset_map and comparison_type != "values":
+ post_processing.append(
+ {
+ "operation": "compare",
+ "options": {
+ "source_columns": list(offset_map.values()),
+ "compare_columns": list(offset_map),
+ "compare_type": comparison_type,
+ "drop_original_columns": True,
+ },
+ }
+ )
+
+ if not mixed and form_data.get("contributionMode"):
+ post_processing.append(
+ {
+ "operation": "contribution",
+ "options": {
+ "orientation": form_data["contributionMode"],
+ "time_shifts": time_offsets,
+ },
+ }
+ )
+
+ if rename := _rename_operator(form_data, query, x_axis_label=x_axis_label):
+ post_processing.append(rename)
+
+ if not mixed:
+ sortable = {
+ x_axis_label or "",
+ *metric_labels,
+ }
+ if (
+ "x_axis_sort" in form_data
+ and "x_axis_sort_asc" in form_data
+ and form_data.get("x_axis_sort") in sortable
+ and not groupby
+ ):
+ options: dict[str, Any] = {"ascending":
form_data.get("x_axis_sort_asc")}
+ if form_data.get("x_axis_sort") == x_axis_label:
+ options["is_sort_index"] = True
+ else:
+ options["by"] = form_data.get("x_axis_sort")
+ post_processing.append({"operation": "sort", "options": options})
+
+ post_processing.append({"operation": "flatten"})
+ if not mixed and form_data.get("forecastEnabled") and x_axis_label:
+ post_processing.append(
+ {
+ "operation": "prophet",
+ "options": {
+ "time_grain": form_data.get("time_grain_sqla"),
+ "periods": int(form_data.get("forecastPeriods") or 0),
+ "confidence_interval": float(
+ form_data.get("forecastInterval") or 0
+ ),
+ "yearly_seasonality":
form_data.get("forecastSeasonalityYearly"),
+ "weekly_seasonality":
form_data.get("forecastSeasonalityWeekly"),
+ "daily_seasonality":
form_data.get("forecastSeasonalityDaily"),
+ "index": x_axis_label,
+ },
+ }
+ )
+ return post_processing, time_offsets
+
+
+def _timeseries_query(form_data: dict[str, Any], query: dict[str, Any]) ->
None:
+ groupby = _as_list(form_data.get("groupby"))
+ x_axis = _x_axis_column(form_data)
+ x_axis_label = _x_axis_label(form_data)
+ query["columns"] = _deduplicate_fields([*_as_list(x_axis), *groupby])
+ query["series_columns"] = groupby
+ if not x_axis:
+ query["is_timeseries"] = True
+
+ # Timeseries includes its sort-only metric in the SELECT when no series is
+ # present. This lets the post-processing sort operator use a metric not
+ # otherwise displayed.
+ sort_metric = form_data.get("timeseries_limit_metric")
+ if isinstance(sort_metric, list):
+ sort_metric = next(iter(sort_metric), None)
+ if (
+ not groupby
+ and sort_metric is not None
+ and _label(sort_metric, metric=True) == form_data.get("x_axis_sort")
+ and _label(sort_metric, metric=True)
+ not in {_label(metric, metric=True) for metric in query.get("metrics")
or []}
+ ):
+ query.setdefault("metrics", []).append(sort_metric)
+ _normalize_query_orderby(query)
+ post_processing, time_offsets = _timeseries_post_processing(
+ form_data,
+ query,
+ x_axis_label=x_axis_label,
+ groupby=groupby,
+ mixed=form_data.get("viz_type") == "mixed_timeseries",
+ )
+ query["post_processing"] = post_processing
+ query["time_offsets"] = time_offsets
+ if form_data.get("viz_type") != "mixed_timeseries":
+ query["time_compare_full_range"] = bool(
+ time_offsets and form_data.get("time_compare_full_range")
+ )
+
+
+def _big_number_queries(
+ form_data: dict[str, Any], query: dict[str, Any]
+) -> list[dict[str, Any]]:
+ """Mirror Big Number with Trendline's one/two-query contract."""
+ # Saved/native Big Number payloads can carry the temporal binding as a
+ # ``{"column_name": ...}`` mapping, which the strict frontend predicate
does
+ # not recognize; keep grouping by it rather than falling back to a total.
+ frontend_x_axis = _frontend_x_axis_column(form_data)
+ explicit_x_axis = frontend_x_axis or _x_axis_column(form_data)
+ time_column = _as_list(explicit_x_axis)
+ x_axis_label = _x_axis_label(form_data, frontend_strict=True)
+ query["columns"] = time_column
+ if time_column and frontend_x_axis is None:
+ # A native ``{"column_name": ...}`` axis groups by its temporal column
+ # but is not rewritten by normalize_time_column, so drop the legacy
+ # granularity binding here rather than bucketing the same dimension
+ # twice.
+ query.pop("granularity", None)
+ extras = query.get("extras")
+ if isinstance(extras, dict):
+ extras.pop("time_grain_sqla", None)
+ if not extras:
+ query.pop("extras", None)
+ elif not time_column:
+ query["is_timeseries"] = True
+ metric_labels = [_label(value, metric=True) for value in
query.get("metrics") or []]
+ post_processing: list[dict[str, Any]] = []
+ if x_axis_label and metric_labels:
+ post_processing.append(
+ {
+ "operation": "pivot",
+ "options": {
+ "index": [x_axis_label],
+ "columns": [],
+ "aggregates": {
+ label: {"operator": "mean"} for label in metric_labels
+ },
+ "drop_missing_columns": not form_data.get(
+ "show_empty_columns", False
+ ),
+ },
+ }
+ )
+ if form_data.get("resample_method") and form_data.get("resample_rule"):
+ zero_fill = form_data["resample_method"] == "zerofill"
+ post_processing.append(
+ {
+ "operation": "resample",
+ "options": {
+ "method": "asfreq" if zero_fill else
form_data["resample_method"],
+ "rule": form_data["resample_rule"],
+ "fill_value": 0 if zero_fill else None,
+ },
+ }
+ )
+ rolling_type = form_data.get("rolling_type")
+ columns_map = {label: label for label in metric_labels}
+ if rolling_type == "cumsum":
+ post_processing.append(
+ {"operation": "cum", "options": {"operator": "sum", "columns":
columns_map}}
+ )
+ elif rolling_type in {"sum", "mean", "std"}:
+ post_processing.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": columns_map,
+ },
+ }
+ )
+ post_processing.append({"operation": "flatten"})
+ query["post_processing"] = post_processing
+ queries = [query]
+ if form_data.get("aggregation") == "raw":
+ overall = dict(query)
+ overall.update(
+ {
+ "columns": [],
+ "is_timeseries": False,
+ "post_processing": [],
+ }
+ )
+ queries.append(overall)
+ return queries
+
+
+def _table_queries( # noqa: C901
+ form_data: dict[str, Any], query: dict[str, Any]
+) -> list[dict[str, Any]]:
+ if is_raw_query_mode(form_data):
+ # The extractor already applies the raw-mode contract, including native
+ # ``order_by_cols`` parsing. Do not synthesize metric ordering.
+ query["columns"] = list(
+ form_data.get("all_columns") or form_data.get("columns") or []
+ )
+ query["metrics"] = []
+ if raw_orderby := orderby_from_form_data(form_data, [], "table"):
+ query["orderby"] = raw_orderby
+ else:
+ query.pop("orderby", None)
+ return [query]
+
+ metrics = list(query.get("metrics") or [])
+ query["columns"] = _table_temporalized_columns(
+ form_data, list(query.get("columns") or [])
+ )
+ percent_metrics = _as_list(form_data.get("percent_metrics"))
+ for metric in percent_metrics:
+ if _label(metric, metric=True) not in {
+ _label(existing, metric=True) for existing in metrics
+ }:
+ metrics.append(metric)
+ query["metrics"] = metrics
+ query["orderby"] = orderby_from_form_data(form_data, metrics, "table")
+ post_processing: list[dict[str, Any]] = []
+ resolved_offsets = _table_time_offsets(form_data)
+ comparison_enabled = (
+ form_data.get("comparison_type") in _TIME_COMPARISON_TYPES
+ and bool(metrics)
+ and bool(_as_list(form_data.get("time_compare")))
+ )
+ contribution: dict[str, Any] | None = None
+ if percent_metrics:
+ base_labels = [_label(metric, metric=True) for metric in
percent_metrics]
+ labels = [
+ label
+ for metric_label in base_labels
+ for label in (
+ [
+ metric_label,
+ *(f"{metric_label}__{offset}" for offset in
resolved_offsets),
+ ]
+ if comparison_enabled
+ else [metric_label]
+ )
+ ]
+ labels = list(dict.fromkeys(labels))
+ contribution = {
+ "operation": "contribution",
+ "options": {
+ "columns": labels,
+ "rename_columns": [f"%{label}" for label in labels],
+ },
+ }
+ post_processing.append(contribution)
+
+ metric_labels = [_label(metric, metric=True) for metric in metrics]
+ offset_map = _metric_offset_map(form_data, metric_labels, resolved_offsets)
+ time_offsets = resolved_offsets if offset_map else []
+ if offset_map and form_data.get("comparison_type") != "values":
+ post_processing.append(
+ {
+ "operation": "compare",
+ "options": {
+ "source_columns": list(offset_map.values()),
+ "compare_columns": list(offset_map),
+ "compare_type": form_data.get("comparison_type"),
+ "drop_original_columns": True,
+ },
+ }
+ )
+ if post_processing:
query["post_processing"] = post_processing
- # ``granularity`` does two jobs downstream: it names the temporal column
the
- # time range filters on, and it is the column ``time_grain_sqla`` buckets
- # (``models/helpers.py`` swaps a selected column for its timestamp
expression
- # when that column equals ``granularity``). Only the first job depends on
- # there being an active range, so set it whenever form data carries one —
- # matching ``extractExtras.ts``, which sets it unconditionally. Gating it
on
- # ``time_range`` dropped the bucketing, so an ordinary "all-time totals by
- # month" chart exported one row per raw timestamp instead of one per month.
- if granularity := form_data.get("granularity") or
form_data.get("granularity_sqla"):
+ else:
+ query.pop("post_processing", None)
+ query["time_offsets"] = time_offsets
+
+ is_download = form_data.get("result_format") in {"csv", "xlsx"} or (
+ form_data.get("result_format") == "json"
+ and form_data.get("result_type") == "results"
+ )
+ if is_download:
+ if form_data.get("row_limit") is not None:
+ query["row_limit"] = int(form_data["row_limit"])
+ query["row_offset"] = 0
+ elif form_data.get("server_pagination"):
+ page_size = int(form_data.get("server_page_length") or 0)
+ configured_limit = int(form_data.get("row_limit") or 0)
+ query["row_limit"] = (
+ min(page_size, configured_limit) if configured_limit else page_size
+ )
+ query["row_offset"] = 0
+
+ queries = [query]
+ if form_data.get("percent_metric_calculation") == "all_records" and
percent_metrics:
+ all_records = dict(query)
+ all_records.update(
+ {
+ "columns": [],
+ "metrics": percent_metrics,
+ "post_processing": [],
+ "row_limit": 0,
+ "row_offset": 0,
+ "orderby": [],
+ "is_timeseries": False,
+ }
+ )
+ queries.append(all_records)
+ if form_data.get("show_totals") and metrics:
+ totals_aggregate = form_data.get("totals_aggregate")
+ if totals_aggregate not in {"SUM", "AVG"}:
+ totals_aggregate = None
+ totals_metrics = [
+ {
+ **metric,
+ "aggregate": totals_aggregate,
+ }
+ if totals_aggregate
+ and isinstance(metric, dict)
+ and metric.get("expressionType") == "SIMPLE"
+ else metric
+ for metric in metrics
+ ]
+ totals = dict(query)
+ totals.update(
+ {
+ "columns": [],
+ "metrics": totals_metrics,
+ "row_limit": 0,
+ "row_offset": 0,
+ "post_processing": [contribution] if contribution else [],
+ }
+ )
+ totals.pop("order_desc", None)
+ totals.pop("orderby", None)
+ queries.append(totals)
+ if form_data.get("server_pagination") and not is_download:
+ rowcount = dict(query)
+ rowcount.update(
+ {
+ "time_offsets": [],
+ "row_limit": int(form_data.get("row_limit") or 0),
+ "row_offset": 0,
+ "post_processing": [],
+ "is_rowcount": True,
+ }
+ )
+ queries.insert(1, rowcount)
+ return queries
+
+
+def _treemap_query(form_data: dict[str, Any], query: dict[str, Any]) -> None:
+ """Apply Treemap's bounded metric and hierarchy ordering contract."""
+ hierarchy = _as_list(form_data.get("groupby"))
+ metric = form_data.get("metric")
+ query["columns"] = hierarchy
+ query["metrics"] = [metric] if metric is not None else []
+ granularity = form_data.get("granularity",
form_data.get("granularity_sqla"))
+ if granularity is not None:
query["granularity"] = granularity
- if form_data.get("row_limit"):
- query["row_limit"] = form_data["row_limit"]
+ ordering = [[metric, False]] if form_data.get("sort_by_metric") and metric
else []
+ ordering.extend(
+ [column, True] for column in hierarchy if isinstance(column, str) and
column
+ )
+ try:
+ bounded = float(query.get("row_limit") or 0) != 0
+ except (ValueError, TypeError):
+ bounded = True
+ query.pop("orderby", None)
+ if bounded and ordering:
+ query["orderby"] = ordering
+
+
+def _bubble_query(form_data: dict[str, Any], query: dict[str, Any]) -> None:
+ """Preserve Bubble's entity/series roles and explicit sort-metric
direction."""
+ query["columns"] = _deduplicate_fields(
+ [*_as_list(form_data.get("entity")),
*_as_list(form_data.get("series"))]
+ )
+ query.pop("orderby", None)
+ if sort_metric := next(iter(_as_list(form_data.get("orderby"))), None):
+ query["orderby"] = [[sort_metric, not query.get("order_desc", True)]]
+
+
+def _entity_map_query(
+ form_data: dict[str, Any], query: dict[str, Any], *, world_map: bool
+) -> None:
+ """Group World/Country Map queries by their ``entity`` role.
+
+ Mirrors the World Map and Country Map ``buildQuery`` transforms: the
+ ``entity`` control is the only grouping column, Country Map selects one
+ ``metric``, and World Map adds a distinct ``secondary_metric`` and orders
by
+ the primary metric only when ``sort_by_metric`` is enabled.
+ """
+ entity = form_data.get("entity")
+ query["columns"] = [entity] if entity else []
+ metric = form_data.get("metric")
+ metrics = [metric] if metric else []
+ if world_map:
+ secondary_metric = form_data.get("secondary_metric")
+ if secondary_metric and (
+ not metric
+ or _label(secondary_metric, metric=True) != _label(metric,
metric=True)
+ ):
+ metrics.append(secondary_metric)
+ query.pop("orderby", None)
+ if form_data.get("sort_by_metric") and metric:
+ query["orderby"] = [[metric, False]]
+ query["metrics"] = metrics
+
+
+def build_query_objects_from_form_data( # noqa: C901
+ form_data: dict[str, Any],
+ *,
+ viz_type: str | None = None,
+ row_limit: int | None = None,
+ order_desc: bool | None = None,
+ filters_prepared: bool = False,
+ secondary_form_data: dict[str, Any] | None = None,
+) -> list[dict[str, Any]]:
+ """Build frontend-equivalent QueryObject dictionaries from form data.
+
+ Every real MCP product path and the legacy common fallback share this
+ extractor and the chart adapters below. This prevents compile/SQL/preview
+ and saved/fallback data paths from silently selecting different fields.
+ """
+ effective_viz = viz_type or str(form_data.get("viz_type") or "")
+ extraction_form_data = form_data
+ if is_raw_query_mode(form_data):
+ # ``extractQueryFields`` is strict, while the legacy server fallback
has
+ # always kept old saved tables executable by dropping malformed order
+ # entries. Normalize that bounded raw-table input before invoking the
+ # shared extractor; valid entries still use the centralized contract.
+ extraction_form_data = {
+ **form_data,
+ "query_mode": "raw",
+ "order_by_cols": orderby_from_form_data(form_data, [],
effective_viz),
+ }
+ query = _base_query_object(
+ extraction_form_data,
+ row_limit=row_limit,
+ order_desc=order_desc,
+ filters_prepared=filters_prepared,
+ )
+
+ if effective_viz in {"table", "ag-grid-table"}:
+ return _finalize_query_objects(form_data, _table_queries(form_data,
query))
+ if effective_viz == "histogram_v2":
+ _histogram_query(form_data, query)
+ elif effective_viz == "box_plot":
+ _box_plot_query(form_data, query)
+ elif effective_viz == "pivot_table_v2":
+ _pivot_query(form_data, query)
+ elif effective_viz == "waterfall":
+ _waterfall_query(form_data, query)
+ elif effective_viz in {"gantt", "gantt_chart"}:
+ _gantt_query(form_data, query)
+ elif effective_viz.startswith("echarts_timeseries") or effective_viz in {
+ "echarts_area",
+ "mixed_timeseries",
+ }:
+ _timeseries_query(form_data, query)
+ elif effective_viz == "big_number":
+ return _finalize_query_objects(form_data,
_big_number_queries(form_data, query))
+ elif effective_viz == "big_number_total":
+ query["columns"] = []
+ elif effective_viz == "handlebars":
+ _normalize_query_orderby(query)
+ elif effective_viz == "pie":
+ if form_data.get("sort_by_metric") and form_data.get("metric") is not
None:
+ query["orderby"] = [[form_data["metric"], False]]
+ query["post_processing"] = _pie_contribution_post_processing(
+ list(query.get("metrics") or [])
+ )
+ elif effective_viz == "gauge_chart":
+ if form_data.get("sort_by_metric") and form_data.get("metric") is not
None:
+ query["orderby"] = [[form_data["metric"], False]]
+ elif effective_viz == "treemap_v2":
+ _treemap_query(form_data, query)
+ elif effective_viz == "sunburst_v2":
+ if not form_data.get("sort_by_metric"):
+ query["orderby"] = []
+ elif form_data.get("metric") is not None:
+ query["orderby"] = [[form_data["metric"], False]]
+ elif effective_viz in {"bubble", "bubble_v2"}:
+ _bubble_query(form_data, query)
+ elif effective_viz in {"world_map", "country_map"}:
+ _entity_map_query(form_data, query, world_map=effective_viz ==
"world_map")
+ elif effective_viz == "ag-grid-pivot-table":
+ query["columns"] = _temporalized_columns(
+ form_data, _as_list(form_data.get("groupby"))
+ )
+
+ if not effective_viz and not query.get("orderby"):
Review Comment:
The metric-ordering fallback only runs when `effective_viz` is empty. Any
saved chart whose viz type has no branch above (Funnel, for example) now gets
no `orderby` at all, whereas the previous `orderby_from_form_data(form_data,
metrics, viz_type)` call applied to every type. A Funnel with
`groupby=["stage"]`, `metric="revenue"`, `sort_by_metric=true`, `row_limit=2`
rebuilt through the fallback `get_chart_data` or a preview now issues `LIMIT 2`
with no `ORDER BY`, so it can return different stages than Explore.
Should this fallback also run when a viz type is set but no adapter handled
ordering?
--
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]