-
Notifications
You must be signed in to change notification settings - Fork 17.2k
fix(mcp): get_chart_sql drops x_axis on echarts_timeseries_* and only renders one query for mixed_timeseries #39865
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
d2add99
61aaed7
d099748
1507685
8b31f21
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -141,6 +141,21 @@ def _resolve_metrics_and_groupby( | |
| return _resolve_metrics(form_data, viz_type), _resolve_groupby(form_data) | ||
|
|
||
|
|
||
| def _extract_x_axis_col(form_data: dict[str, Any]) -> str | None: | ||
| """Return the x_axis column name from form_data, or None if not set. | ||
|
|
||
| ``x_axis`` may be stored as a plain column-name string or as an adhoc | ||
| column dict (``{"column_name": "...", ...}``). | ||
| """ | ||
| x_axis = form_data.get("x_axis") | ||
| if isinstance(x_axis, str) and x_axis: | ||
| return x_axis | ||
| if isinstance(x_axis, dict): | ||
| col_name = x_axis.get("column_name") | ||
| return col_name if isinstance(col_name, str) and col_name else None | ||
| return None | ||
|
|
||
|
|
||
| def _resolve_engine( | ||
| datasource_id: Any, | ||
| datasource_type: str, | ||
|
|
@@ -162,6 +177,58 @@ def _resolve_engine( | |
| return "base" | ||
|
|
||
|
|
||
| def _build_single_query_dict( | ||
| form_data: dict[str, Any], | ||
| columns: list[Any], | ||
| metrics: list[Any], | ||
| ) -> dict[str, Any]: | ||
| """Build one query entry for QueryContextFactory from form_data fields.""" | ||
| qd: dict[str, Any] = {"columns": columns, "metrics": metrics} | ||
| if time_range := form_data.get("time_range"): | ||
| qd["time_range"] = time_range | ||
| if filters := form_data.get("filters"): | ||
| qd["filters"] = filters | ||
| if (row_limit := form_data.get("row_limit")) is not None: | ||
| qd["row_limit"] = row_limit | ||
| return qd | ||
|
|
||
|
|
||
| def _build_mixed_timeseries_secondary( | ||
| form_data: dict[str, Any], | ||
| x_axis_col: str | None, | ||
| engine: str = "base", | ||
| ) -> dict[str, Any]: | ||
| """Build the secondary query dict for the ``mixed_timeseries`` viz type. | ||
|
|
||
| ``mixed_timeseries`` has two independent series layers; the secondary | ||
| layer uses ``metrics_b`` / ``groupby_b`` instead of the primary fields. | ||
| Secondary-specific overrides (``time_range_b``, ``row_limit_b``, | ||
| ``adhoc_filters_b``) replace the corresponding primary values so the | ||
| generated SQL accurately reflects each series' independent configuration. | ||
| """ | ||
| metrics_b: list[Any] = list(form_data.get("metrics_b") or []) | ||
| raw_b = form_data.get("groupby_b") or [] | ||
| groupby_b: list[Any] = [raw_b] if isinstance(raw_b, str) else list(raw_b) | ||
| if x_axis_col and x_axis_col not in groupby_b: | ||
| groupby_b = [x_axis_col] + groupby_b | ||
| qd = _build_single_query_dict(form_data, groupby_b, metrics_b) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Suggestion: The secondary query for Severity Level: Major
|
||
| if time_range_b := form_data.get("time_range_b"): | ||
| qd["time_range"] = time_range_b | ||
| if (row_limit_b := form_data.get("row_limit_b")) is not None: | ||
| qd["row_limit"] = row_limit_b | ||
| # Process adhoc_filters_b into concrete filter clauses for the secondary | ||
| # query, mirroring how split_adhoc_filters_into_base_filters handles the | ||
| # primary adhoc_filters in _build_query_context_from_form_data. | ||
| if adhoc_filters_b := form_data.get("adhoc_filters_b"): | ||
| from superset.utils.core import split_adhoc_filters_into_base_filters | ||
|
|
||
| secondary_fd: dict[str, Any] = {"adhoc_filters": adhoc_filters_b} | ||
| split_adhoc_filters_into_base_filters(secondary_fd, engine) | ||
| if secondary_filters := secondary_fd.get("filters"): | ||
| qd["filters"] = secondary_filters | ||
| return qd | ||
|
|
||
|
|
||
| def _build_query_context_from_form_data( | ||
| form_data: dict[str, Any], | ||
| chart: "Slice | None" = None, | ||
|
|
@@ -209,22 +276,33 @@ def _build_query_context_from_form_data( | |
| merge_extra_filters(form_data) | ||
| split_adhoc_filters_into_base_filters(form_data, engine) | ||
|
|
||
| # Build query dict with temporal and filter fields. | ||
| # QueryObjectFactory.create() accepts time_range as a top-level kwarg | ||
| # and converts it to from_dttm/to_dttm for the QueryObject. | ||
| query_dict: dict[str, Any] = { | ||
| "columns": groupby, | ||
| "metrics": metrics, | ||
| } | ||
|
|
||
| if time_range := form_data.get("time_range"): | ||
| query_dict["time_range"] = time_range | ||
|
|
||
| if filters := form_data.get("filters"): | ||
| query_dict["filters"] = filters | ||
| viz_type: str = ( | ||
| form_data.get("viz_type") | ||
| or (getattr(chart, "viz_type", "") if chart else "") | ||
| or "" | ||
| ) | ||
| is_timeseries = ( | ||
| viz_type.startswith("echarts_timeseries") or viz_type == "mixed_timeseries" | ||
| ) | ||
|
|
||
| if (row_limit := form_data.get("row_limit")) is not None: | ||
| query_dict["row_limit"] = row_limit | ||
| # For echarts_timeseries_* and mixed_timeseries charts the temporal | ||
| # column is stored in x_axis rather than groupby. Prepend it so the | ||
| # generated SQL includes the time axis. | ||
| x_axis_col: str | None = None | ||
| if is_timeseries: | ||
| x_axis_col = _extract_x_axis_col(form_data) | ||
| if x_axis_col and x_axis_col not in groupby: | ||
| groupby = [x_axis_col] + groupby | ||
|
|
||
| queries: list[dict[str, Any]] = [ | ||
| _build_single_query_dict(form_data, groupby, metrics) | ||
| ] | ||
|
|
||
| # mixed_timeseries exposes two independent query layers (primary and | ||
| # secondary). Build the second query from metrics_b / groupby_b so | ||
| # that get_chart_sql returns SQL for both and neither is silently lost. | ||
| if viz_type == "mixed_timeseries": | ||
| queries.append(_build_mixed_timeseries_secondary(form_data, x_axis_col, engine)) | ||
|
|
||
| # Ensure datasource fields satisfy DatasourceDict typing requirements. | ||
| # datasource_id must be int | str; datasource_type must be str. | ||
|
|
@@ -238,7 +316,7 @@ def _build_query_context_from_form_data( | |
|
|
||
| return factory.create( | ||
| datasource={"id": resolved_id, "type": resolved_type_str}, | ||
| queries=[query_dict], | ||
| queries=queries, | ||
| form_data=form_data, | ||
| result_type=ChartDataResultType.QUERY, | ||
| force=False, | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Suggestion: Query dict construction always reads
row_limitand never supportsrow_limit_b, so the secondarymixed_timeseriesquery cannot honor its own row limit. This causes incorrect SQL LIMIT behavior for query B. Use the secondary-specific limit when building the secondary query. [logic error]Severity Level: Major⚠️
Steps of Reproduction ✅
Fix in Cursor | Fix in VSCode Claude
(Use Cmd/Ctrl + Click for best experience)
Prompt for AI Agent 🤖