mirror of
https://github.com/apache/superset.git
synced 2026-07-14 02:35:44 +00:00
1464 lines
51 KiB
Python
1464 lines
51 KiB
Python
# 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.
|
|
|
|
"""
|
|
Shared chart utilities for MCP tools
|
|
|
|
This module contains shared logic for chart configuration mapping and explore link
|
|
generation that can be used by both generate_chart and generate_explore_link tools.
|
|
"""
|
|
|
|
import hashlib
|
|
import logging
|
|
from dataclasses import dataclass
|
|
from typing import Any, Dict
|
|
|
|
from superset.constants import NO_TIME_RANGE
|
|
from superset.mcp_service.chart.schemas import (
|
|
BigNumberChartConfig,
|
|
ChartCapabilities,
|
|
ChartSemantics,
|
|
ColumnRef,
|
|
CurrencyFormat,
|
|
FilterConfig,
|
|
HandlebarsChartConfig,
|
|
MixedTimeseriesChartConfig,
|
|
PieChartConfig,
|
|
PivotTableChartConfig,
|
|
SortByConfig,
|
|
TableChartConfig,
|
|
XYChartConfig,
|
|
)
|
|
from superset.mcp_service.utils.url_utils import get_superset_base_url
|
|
from superset.utils import json
|
|
from superset.utils.core import FilterOperator
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
@dataclass
|
|
class DatasetValidationResult:
|
|
"""Result of dataset accessibility validation."""
|
|
|
|
is_valid: bool
|
|
dataset_id: int | str | None
|
|
dataset_name: str | None
|
|
warnings: list[str]
|
|
error: str | None = None
|
|
|
|
|
|
def validate_chart_dataset(
|
|
chart: Any,
|
|
check_access: bool = True,
|
|
) -> DatasetValidationResult:
|
|
"""
|
|
Validate that a chart's dataset exists and is accessible.
|
|
|
|
This shared utility should be called by MCP tools after creating or retrieving
|
|
charts to detect issues like missing or deleted datasets early.
|
|
|
|
Args:
|
|
chart: A chart-like object with datasource_id, datasource_type attributes
|
|
check_access: Whether to also check user permissions (default True)
|
|
|
|
Returns:
|
|
DatasetValidationResult with validation status and any warnings
|
|
"""
|
|
from sqlalchemy.exc import SQLAlchemyError
|
|
|
|
from superset.daos.dataset import DatasetDAO
|
|
from superset.mcp_service.auth import has_dataset_access
|
|
|
|
warnings: list[str] = []
|
|
datasource_id = getattr(chart, "datasource_id", None)
|
|
|
|
# Check if chart has a datasource reference
|
|
if datasource_id is None:
|
|
return DatasetValidationResult(
|
|
is_valid=False,
|
|
dataset_id=None,
|
|
dataset_name=None,
|
|
warnings=[],
|
|
error="Chart has no dataset reference (datasource_id is None)",
|
|
)
|
|
|
|
# Try to look up the dataset
|
|
try:
|
|
dataset = DatasetDAO.find_by_id(datasource_id)
|
|
|
|
if dataset is None:
|
|
return DatasetValidationResult(
|
|
is_valid=False,
|
|
dataset_id=datasource_id,
|
|
dataset_name=None,
|
|
warnings=[],
|
|
error=(
|
|
f"Dataset (ID: {datasource_id}) has been deleted or does not "
|
|
f"exist. The chart will not render correctly. "
|
|
f"Consider updating the chart to use a different dataset."
|
|
),
|
|
)
|
|
|
|
dataset_name = getattr(dataset, "table_name", None) or getattr(
|
|
dataset, "name", None
|
|
)
|
|
|
|
# Check if it's a virtual dataset (SQL Lab query)
|
|
is_virtual = bool(getattr(dataset, "sql", None))
|
|
if is_virtual:
|
|
warnings.append(
|
|
f"This chart uses a virtual dataset (SQL-based). "
|
|
f"If the dataset '{dataset_name}' is deleted, this chart will break."
|
|
)
|
|
|
|
# Check access permissions if requested
|
|
if check_access and not has_dataset_access(dataset):
|
|
return DatasetValidationResult(
|
|
is_valid=False,
|
|
dataset_id=datasource_id,
|
|
dataset_name=dataset_name,
|
|
warnings=warnings,
|
|
error=(
|
|
f"Access denied to dataset '{dataset_name}' (ID: {datasource_id}). "
|
|
f"You do not have permission to view this dataset."
|
|
),
|
|
)
|
|
|
|
return DatasetValidationResult(
|
|
is_valid=True,
|
|
dataset_id=datasource_id,
|
|
dataset_name=dataset_name,
|
|
warnings=warnings,
|
|
error=None,
|
|
)
|
|
|
|
except (AttributeError, ValueError, RuntimeError, SQLAlchemyError) as e:
|
|
logger.exception("Error validating chart dataset %s: %s", datasource_id, e)
|
|
return DatasetValidationResult(
|
|
is_valid=False,
|
|
dataset_id=datasource_id,
|
|
dataset_name=None,
|
|
warnings=[],
|
|
error=f"Error validating dataset (ID: {datasource_id}): {str(e)}",
|
|
)
|
|
|
|
|
|
def generate_explore_link(
|
|
dataset_id: int | str,
|
|
form_data: Dict[str, Any],
|
|
prefer_permalink: bool = True,
|
|
) -> str:
|
|
"""Generate an explore link for the given dataset and form data.
|
|
|
|
Prefers a durable explore permalink (DB-backed key-value store, does not
|
|
expire) over an ephemeral form_data_key (Redis cache, expires in ~24h).
|
|
Falls back to the form_data_key approach if permalink creation fails, then
|
|
to a plain dataset URL as a last resort.
|
|
|
|
Set ``prefer_permalink=False`` for callers that depend on a ``form_data_key``
|
|
in the returned URL (e.g. preview flows that extract and re-cache the key);
|
|
this skips the permalink path and returns an ``/explore/?form_data_key=...``
|
|
URL directly.
|
|
"""
|
|
from sqlalchemy.exc import SQLAlchemyError
|
|
|
|
from superset.commands.exceptions import CommandException
|
|
from superset.commands.explore.form_data.parameters import CommandParameters
|
|
from superset.commands.explore.permalink.create import CreateExplorePermalinkCommand
|
|
from superset.daos.dataset import DatasetDAO
|
|
from superset.exceptions import SupersetException
|
|
from superset.explore.permalink.exceptions import ExplorePermalinkCreateFailedError
|
|
from superset.mcp_service.commands.create_form_data import (
|
|
MCPCreateFormDataCommand,
|
|
)
|
|
from superset.utils.core import DatasourceType
|
|
|
|
base_url = get_superset_base_url()
|
|
numeric_dataset_id = None
|
|
dataset = None
|
|
|
|
try:
|
|
if isinstance(dataset_id, int) or (
|
|
isinstance(dataset_id, str) and dataset_id.isdigit()
|
|
):
|
|
numeric_dataset_id = (
|
|
int(dataset_id) if isinstance(dataset_id, str) else dataset_id
|
|
)
|
|
dataset = DatasetDAO.find_by_id(numeric_dataset_id)
|
|
else:
|
|
# Try UUID lookup using DAO flexible method
|
|
dataset = DatasetDAO.find_by_id(dataset_id, id_column="uuid")
|
|
if dataset:
|
|
numeric_dataset_id = dataset.id
|
|
|
|
if not dataset or numeric_dataset_id is None:
|
|
# Fallback to basic explore URL
|
|
return (
|
|
f"{base_url}/explore/?datasource_type=table&datasource_id={dataset_id}"
|
|
)
|
|
|
|
# Add datasource to form_data
|
|
form_data_with_datasource = {
|
|
**form_data,
|
|
"datasource": f"{numeric_dataset_id}__table",
|
|
}
|
|
|
|
# Try durable permalink first (DB-backed key-value store, does not expire).
|
|
# CreateExplorePermalinkCommand wraps its internal failures (encode/create/
|
|
# SQLAlchemy errors) into ExplorePermalinkCreateFailedError, so catch only
|
|
# those expected modes here — letting programming errors (TypeError, etc.)
|
|
# surface instead of being silently masked by the form_data_key fallback.
|
|
# Callers that need a form_data_key URL opt out via prefer_permalink=False.
|
|
if prefer_permalink:
|
|
try:
|
|
state = {"formData": form_data_with_datasource}
|
|
permalink_key = CreateExplorePermalinkCommand(state=state).run()
|
|
return f"{base_url}/explore/p/{permalink_key}/"
|
|
except (
|
|
ExplorePermalinkCreateFailedError,
|
|
SQLAlchemyError,
|
|
) as permalink_e:
|
|
logger.debug(
|
|
"Permalink generation failed, falling back to form_data_key: %s",
|
|
permalink_e,
|
|
)
|
|
|
|
# Fall back to ephemeral form_data_key (Redis-backed cache)
|
|
cmd_params = CommandParameters(
|
|
datasource_type=DatasourceType.TABLE,
|
|
datasource_id=numeric_dataset_id,
|
|
chart_id=0, # 0 for new charts
|
|
tab_id=None,
|
|
form_data=json.dumps(form_data_with_datasource),
|
|
)
|
|
form_data_key = MCPCreateFormDataCommand(cmd_params).run()
|
|
return f"{base_url}/explore/?form_data_key={form_data_key}"
|
|
|
|
except (
|
|
CommandException,
|
|
SupersetException,
|
|
SQLAlchemyError,
|
|
) as e:
|
|
# Fallback to basic explore URL with numeric ID if available. Only the
|
|
# expected failure modes of dataset lookup / form_data creation are caught
|
|
# here; programming errors propagate to the tool handler so they aren't
|
|
# silently masked behind a fallback URL.
|
|
logger.debug("Explore link generation fallback due to: %s", e)
|
|
if numeric_dataset_id is not None:
|
|
return (
|
|
f"{base_url}/explore/?datasource_type=table"
|
|
f"&datasource_id={numeric_dataset_id}"
|
|
)
|
|
return f"{base_url}/explore/?datasource_type=table&datasource_id={dataset_id}"
|
|
|
|
|
|
def is_column_truly_temporal(column_name: str, dataset_id: int | str | None) -> bool:
|
|
"""
|
|
Check if a column is truly temporal based on its SQL data type.
|
|
|
|
This is important because Superset may mark columns as is_dttm=True based on
|
|
column name heuristics (e.g., "year", "month"), but if the actual SQL type is
|
|
BIGINT or INTEGER, DATE_TRUNC will fail.
|
|
|
|
Uses the database engine spec's column type mapping to determine the actual
|
|
GenericDataType, bypassing the is_dttm flag which may be set incorrectly.
|
|
|
|
Args:
|
|
column_name: Name of the column to check
|
|
dataset_id: Dataset ID to look up column metadata
|
|
|
|
Returns:
|
|
True if the column has a real temporal SQL type, False otherwise
|
|
"""
|
|
from superset.daos.dataset import DatasetDAO
|
|
from superset.utils.core import GenericDataType
|
|
|
|
if not dataset_id:
|
|
return True # Default to temporal if we can't check (backward compatible)
|
|
|
|
try:
|
|
# Find dataset
|
|
if isinstance(dataset_id, int) or (
|
|
isinstance(dataset_id, str) and dataset_id.isdigit()
|
|
):
|
|
dataset = DatasetDAO.find_by_id(int(dataset_id))
|
|
else:
|
|
dataset = DatasetDAO.find_by_id(dataset_id, id_column="uuid")
|
|
|
|
if not dataset:
|
|
return True # Default to temporal if dataset not found
|
|
|
|
# Find the column and check its actual type using db_engine_spec
|
|
column_lower = column_name.lower()
|
|
for col in dataset.columns:
|
|
if col.column_name.lower() == column_lower:
|
|
col_type = col.type
|
|
if not col_type:
|
|
# No type info, trust is_dttm flag
|
|
return getattr(col, "is_dttm", False)
|
|
|
|
# Use the db_engine_spec to get the actual GenericDataType
|
|
# This bypasses the is_dttm flag and checks the real SQL type
|
|
db_engine_spec = dataset.database.db_engine_spec
|
|
column_spec = db_engine_spec.get_column_spec(col_type)
|
|
|
|
if column_spec:
|
|
is_temporal = column_spec.generic_type == GenericDataType.TEMPORAL
|
|
if not is_temporal:
|
|
logger.debug(
|
|
"Column '%s' has type '%s' (generic: %s), "
|
|
"treating as non-temporal",
|
|
column_name,
|
|
col_type,
|
|
column_spec.generic_type,
|
|
)
|
|
return is_temporal
|
|
|
|
# If no column_spec, trust is_dttm flag
|
|
return getattr(col, "is_dttm", False)
|
|
|
|
return True # Default if column not found
|
|
|
|
except (ValueError, AttributeError) as e:
|
|
logger.warning(
|
|
"Error checking column type for '%s' in dataset %s: %s",
|
|
column_name,
|
|
dataset_id,
|
|
e,
|
|
)
|
|
return True # Default to temporal on error (backward compatible)
|
|
|
|
|
|
def map_config_to_form_data(
|
|
config: TableChartConfig
|
|
| XYChartConfig
|
|
| PieChartConfig
|
|
| PivotTableChartConfig
|
|
| MixedTimeseriesChartConfig
|
|
| HandlebarsChartConfig
|
|
| BigNumberChartConfig,
|
|
dataset_id: int | str | None = None,
|
|
) -> Dict[str, Any]:
|
|
"""Map chart config to Superset form_data via the plugin registry.
|
|
|
|
The previous if/elif chain across all 7 chart types has been replaced by a
|
|
single registry lookup. Cross-field constraints (e.g. BigNumber trendline
|
|
temporal check) are now owned by each plugin's post_map_validate() method
|
|
rather than being baked into this dispatcher.
|
|
"""
|
|
# Local import: plugins call map_*_config from their to_form_data() methods,
|
|
# so chart_utils is loaded before plugins finish registering. A top-level
|
|
# import of registry here would trigger plugin loading mid-import = cycle.
|
|
from superset.mcp_service.chart.registry import get_registry
|
|
|
|
chart_type = getattr(config, "chart_type", None)
|
|
plugin = get_registry().get(chart_type) if chart_type else None
|
|
|
|
if plugin is None:
|
|
if chart_type is None:
|
|
raise ValueError(f"Unsupported config type: {type(config)}")
|
|
raise ValueError(
|
|
f"Unsupported config type: {type(config)} (chart_type={chart_type!r})"
|
|
)
|
|
|
|
form_data = plugin.to_form_data(config, dataset_id=dataset_id)
|
|
|
|
# Run post-map validation (e.g. BigNumber trendline temporal type check).
|
|
# Raise ValueError to preserve backward-compatible error handling in callers.
|
|
# Include details and suggestions so callers logging str(e) surface actionable
|
|
# context (e.g. BigNumber trendline guidance) rather than just the headline.
|
|
error = plugin.post_map_validate(config, form_data, dataset_id=dataset_id)
|
|
if error is not None:
|
|
parts = [error.message]
|
|
if error.details:
|
|
parts.append(error.details)
|
|
if error.suggestions:
|
|
parts.append("Suggestions: " + "; ".join(error.suggestions))
|
|
raise ValueError(" ".join(parts))
|
|
|
|
return form_data
|
|
|
|
|
|
def _add_adhoc_filters(
|
|
form_data: Dict[str, Any], filters: list[FilterConfig] | None
|
|
) -> None:
|
|
"""Add adhoc filters to form_data if any are specified."""
|
|
if filters:
|
|
form_data["adhoc_filters"] = [
|
|
{
|
|
"clause": "WHERE",
|
|
"expressionType": "SIMPLE",
|
|
"subject": filter_config.column,
|
|
"operator": map_filter_operator(filter_config.op),
|
|
"comparator": filter_config.value,
|
|
}
|
|
for filter_config in filters
|
|
if filter_config is not None
|
|
]
|
|
|
|
|
|
def adhoc_filters_to_query_filters(
|
|
adhoc_filters: list[Dict[str, Any]],
|
|
) -> list[Dict[str, Any]]:
|
|
"""Convert adhoc filter format to QueryObject filter format.
|
|
|
|
Adhoc filters use ``{subject, operator, comparator}`` keys while
|
|
``QueryContextFactory`` expects ``{col, op, val}`` (QueryObjectFilterClause).
|
|
"""
|
|
result: list[Dict[str, Any]] = []
|
|
for f in adhoc_filters:
|
|
if f.get("expressionType") == "SIMPLE":
|
|
result.append(
|
|
{
|
|
"col": f.get("subject"),
|
|
"op": f.get("operator"),
|
|
"val": f.get("comparator"),
|
|
}
|
|
)
|
|
return result
|
|
|
|
|
|
def map_table_config(config: TableChartConfig) -> Dict[str, Any]:
|
|
"""Map table chart config to form_data with defensive validation."""
|
|
# Early validation to prevent empty charts
|
|
if not config.columns:
|
|
raise ValueError("Table chart must have at least one column")
|
|
|
|
# Use the viz_type from config (defaults to "table", can be "ag-grid-table")
|
|
form_data: Dict[str, Any] = {
|
|
"viz_type": config.viz_type,
|
|
}
|
|
|
|
# When query_mode is explicitly set to "raw", force raw mode for all columns.
|
|
# Aggregate settings on individual columns are ignored in this case.
|
|
if config.query_mode == "raw":
|
|
column_names = [col.name for col in config.columns]
|
|
form_data.update(
|
|
{
|
|
"all_columns": column_names,
|
|
"columns": column_names,
|
|
"query_mode": "raw",
|
|
"include_time": False,
|
|
"order_desc": True,
|
|
}
|
|
)
|
|
else:
|
|
# Auto-detect or explicit "aggregate": separate columns with aggregates
|
|
# from raw columns and build the appropriate form_data.
|
|
raw_columns = []
|
|
aggregated_metrics = []
|
|
|
|
for col in config.columns:
|
|
if col.is_metric:
|
|
# Saved metric or column with aggregation - treat as metric
|
|
aggregated_metrics.append(create_metric_object(col))
|
|
else:
|
|
# No aggregation - treat as raw column
|
|
raw_columns.append(col.name)
|
|
|
|
# Final validation - ensure we have some data to display
|
|
if not raw_columns and not aggregated_metrics:
|
|
raise ValueError(
|
|
"Table chart configuration resulted in no displayable columns"
|
|
)
|
|
|
|
# Handle raw columns (no aggregation)
|
|
if raw_columns and not aggregated_metrics:
|
|
# Pure raw columns - show individual rows
|
|
# Include both "all_columns" (Superset table viz) and "columns"
|
|
# (QueryContextFactory validation) to avoid "Empty query?" errors
|
|
form_data.update(
|
|
{
|
|
"all_columns": raw_columns,
|
|
"columns": raw_columns,
|
|
"query_mode": "raw",
|
|
"include_time": False,
|
|
"order_desc": True,
|
|
}
|
|
)
|
|
|
|
# Handle aggregated columns only
|
|
elif aggregated_metrics and not raw_columns:
|
|
# Pure aggregation - show totals
|
|
form_data.update(
|
|
{
|
|
"metrics": aggregated_metrics,
|
|
"query_mode": "aggregate",
|
|
}
|
|
)
|
|
|
|
# Handle mixed columns (raw + aggregated)
|
|
else:
|
|
# Mixed mode - group by raw columns, aggregate metrics
|
|
form_data.update(
|
|
{
|
|
"all_columns": raw_columns,
|
|
"metrics": aggregated_metrics,
|
|
"groupby": raw_columns,
|
|
"query_mode": "aggregate",
|
|
}
|
|
)
|
|
|
|
_add_adhoc_filters(form_data, config.filters)
|
|
|
|
if config.sort_by:
|
|
form_data["order_by_cols"] = [
|
|
json.dumps(
|
|
[entry.column, entry.ascending]
|
|
if isinstance(entry, SortByConfig)
|
|
else [entry, False]
|
|
)
|
|
for entry in config.sort_by
|
|
]
|
|
|
|
form_data["row_limit"] = config.row_limit
|
|
add_color_scheme(form_data, config.color_scheme)
|
|
|
|
return form_data
|
|
|
|
|
|
def create_metric_object(col: ColumnRef) -> Dict[str, Any] | str:
|
|
"""Create a metric object for a column with enhanced validation.
|
|
|
|
For saved metrics, returns the metric name as a plain string which
|
|
Superset's query engine resolves via its metrics_by_name lookup.
|
|
For custom SQL metrics, returns a SQL adhoc dict (expressionType="SQL").
|
|
For ad-hoc column metrics, returns a SIMPLE expression dict.
|
|
"""
|
|
if col.sql_expression:
|
|
return {
|
|
"aggregate": None,
|
|
"column": None,
|
|
"expressionType": "SQL",
|
|
"sqlExpression": col.sql_expression,
|
|
"label": col.label,
|
|
"optionName": (
|
|
"metric_sql_"
|
|
+ hashlib.md5(
|
|
col.sql_expression.encode("utf-8"), usedforsecurity=False
|
|
).hexdigest()[:8]
|
|
),
|
|
"hasCustomLabel": True,
|
|
"datasourceWarning": False,
|
|
}
|
|
|
|
if col.saved_metric:
|
|
return col.name # type: ignore[return-value]
|
|
|
|
# Ensure aggregate is valid - default to SUM if not specified or invalid
|
|
valid_aggregates = {
|
|
"SUM",
|
|
"COUNT",
|
|
"AVG",
|
|
"MIN",
|
|
"MAX",
|
|
"COUNT_DISTINCT",
|
|
"STDDEV",
|
|
"VAR",
|
|
"MEDIAN",
|
|
"PERCENTILE",
|
|
}
|
|
aggregate = col.aggregate or "SUM"
|
|
|
|
# Validate aggregate function (final safety check)
|
|
if aggregate.upper() not in valid_aggregates:
|
|
aggregate = "SUM" # Safe fallback
|
|
|
|
return {
|
|
"aggregate": aggregate.upper(),
|
|
"column": {
|
|
"column_name": col.name,
|
|
},
|
|
"expressionType": "SIMPLE",
|
|
"label": col.label or f"{aggregate.upper()}({col.name})",
|
|
"optionName": f"metric_{col.name}",
|
|
"sqlExpression": None,
|
|
"hasCustomLabel": bool(col.label),
|
|
"datasourceWarning": False,
|
|
}
|
|
|
|
|
|
def add_axis_config(form_data: Dict[str, Any], config: XYChartConfig) -> None:
|
|
"""Add axis configurations to form_data."""
|
|
if config.x_axis:
|
|
if config.x_axis.title:
|
|
form_data["x_axis_title"] = config.x_axis.title
|
|
if config.x_axis.format:
|
|
form_data["x_axis_format"] = config.x_axis.format
|
|
|
|
if config.y_axis:
|
|
if config.y_axis.title:
|
|
form_data["y_axis_title"] = config.y_axis.title
|
|
if config.y_axis.format:
|
|
form_data["y_axis_format"] = config.y_axis.format
|
|
if config.y_axis.scale == "log":
|
|
form_data["y_axis_scale"] = "log"
|
|
|
|
|
|
def add_legend_config(form_data: Dict[str, Any], config: XYChartConfig) -> None:
|
|
"""Add legend configuration to form_data."""
|
|
if config.legend:
|
|
if not config.legend.show:
|
|
form_data["show_legend"] = False
|
|
if config.legend.position:
|
|
# Canonical form_data key is camelCase; the echarts plugins read
|
|
# `legendOrientation` directly off form_data.
|
|
form_data["legendOrientation"] = config.legend.position
|
|
|
|
|
|
def add_color_scheme(form_data: Dict[str, Any], color_scheme: str | None) -> None:
|
|
"""Add color scheme to form_data when set."""
|
|
if color_scheme:
|
|
form_data["color_scheme"] = color_scheme
|
|
|
|
|
|
def add_currency_format(
|
|
form_data: Dict[str, Any],
|
|
currency_format: CurrencyFormat | None,
|
|
key: str = "currency_format",
|
|
) -> None:
|
|
"""Add currency format to form_data under the given key when set."""
|
|
if currency_format:
|
|
form_data[key] = currency_format.to_form_data()
|
|
|
|
|
|
def add_xy_data_label_options(
|
|
form_data: Dict[str, Any], config: XYChartConfig, x_is_temporal: bool
|
|
) -> None:
|
|
"""Apply XY-specific data-label and time-format options when set."""
|
|
if config.x_axis_time_format and x_is_temporal:
|
|
form_data["x_axis_time_format"] = config.x_axis_time_format
|
|
if config.show_value:
|
|
form_data["show_value"] = True
|
|
|
|
|
|
def add_orientation_config(form_data: Dict[str, Any], config: XYChartConfig) -> None:
|
|
"""Add orientation configuration to form_data for bar charts.
|
|
|
|
Only applies when kind='bar' and an explicit orientation is set.
|
|
When orientation is None (the default), Superset uses its own default
|
|
(vertical bars).
|
|
"""
|
|
if config.kind == "bar" and config.orientation:
|
|
form_data["orientation"] = config.orientation
|
|
|
|
|
|
def configure_temporal_handling(
|
|
form_data: Dict[str, Any],
|
|
x_is_temporal: bool,
|
|
time_grain: str | None,
|
|
) -> None:
|
|
"""Configure form_data based on whether x-axis column is temporal.
|
|
|
|
For temporal columns, enables standard time series handling.
|
|
For non-temporal columns (e.g., BIGINT year), disables DATE_TRUNC
|
|
by setting categorical sorting options.
|
|
|
|
Stores any warnings in ``form_data["_mcp_warnings"]``.
|
|
"""
|
|
if x_is_temporal:
|
|
form_data["granularity_sqla"] = form_data.get("x_axis")
|
|
if time_grain:
|
|
form_data["time_grain_sqla"] = time_grain
|
|
else:
|
|
# Non-temporal column - disable temporal handling to prevent DATE_TRUNC
|
|
form_data["x_axis_sort_series_type"] = "name"
|
|
form_data["x_axis_sort_series_ascending"] = True
|
|
form_data["time_grain_sqla"] = None
|
|
form_data["granularity_sqla"] = None
|
|
if time_grain:
|
|
form_data.setdefault("_mcp_warnings", []).append(
|
|
f"time_grain='{time_grain}' was ignored because the x-axis "
|
|
f"column is not a temporal type. time_grain only applies to "
|
|
f"DATE/DATETIME/TIMESTAMP columns."
|
|
)
|
|
|
|
|
|
def _ensure_temporal_adhoc_filter(form_data: Dict[str, Any], column: str) -> None:
|
|
"""Ensure a TEMPORAL_RANGE adhoc filter exists for the given column.
|
|
|
|
Mirrors the Explore UI behavior: when a temporal column is set as
|
|
the x-axis, a TEMPORAL_RANGE filter must be present so dashboard
|
|
time-range filters can bind to it. Without this filter, Explore
|
|
shows a warning dialog asking the user to add it manually.
|
|
"""
|
|
existing = form_data.get("adhoc_filters", [])
|
|
if any(
|
|
f.get("operator") == FilterOperator.TEMPORAL_RANGE.value
|
|
and f.get("subject") == column
|
|
for f in existing
|
|
):
|
|
return
|
|
existing.append(
|
|
{
|
|
"clause": "WHERE",
|
|
"expressionType": "SIMPLE",
|
|
"subject": column,
|
|
"operator": FilterOperator.TEMPORAL_RANGE.value,
|
|
"comparator": NO_TIME_RANGE,
|
|
}
|
|
)
|
|
form_data["adhoc_filters"] = existing
|
|
|
|
|
|
def _resolve_default_x_axis(
|
|
config: XYChartConfig, dataset_id: int | str | None
|
|
) -> XYChartConfig:
|
|
"""Resolve x-axis to the dataset's main_dttm_col when x is omitted."""
|
|
if config.x is not None:
|
|
return config
|
|
|
|
if not dataset_id:
|
|
raise ValueError("x-axis column is required when dataset_id is not provided")
|
|
from superset.daos.dataset import DatasetDAO
|
|
|
|
if isinstance(dataset_id, int) or (
|
|
isinstance(dataset_id, str) and dataset_id.isdigit()
|
|
):
|
|
dataset = DatasetDAO.find_by_id(int(dataset_id))
|
|
else:
|
|
dataset = DatasetDAO.find_by_id(dataset_id, id_column="uuid")
|
|
|
|
if not dataset or not dataset.main_dttm_col:
|
|
raise ValueError(
|
|
"x-axis column is required: dataset has no primary datetime "
|
|
"column (main_dttm_col). Please specify the x-axis column "
|
|
"explicitly."
|
|
)
|
|
from superset.mcp_service.chart.schemas import ColumnRef
|
|
|
|
return config.model_copy(update={"x": ColumnRef(name=dataset.main_dttm_col)})
|
|
|
|
|
|
def _add_xy_limits(form_data: Dict[str, Any], config: XYChartConfig) -> None:
|
|
form_data["row_limit"] = config.row_limit
|
|
if config.series_limit is not None:
|
|
form_data["series_limit"] = config.series_limit
|
|
|
|
|
|
def map_xy_config( # noqa: C901
|
|
config: XYChartConfig, dataset_id: int | str | None = None
|
|
) -> Dict[str, Any]:
|
|
"""Map XY chart config to form_data with defensive validation."""
|
|
# Early validation to prevent empty charts
|
|
if not config.y:
|
|
raise ValueError("XY chart must have at least one Y-axis metric")
|
|
|
|
# Resolve x-axis default: use dataset's main_dttm_col when x is omitted.
|
|
config = _resolve_default_x_axis(config, dataset_id)
|
|
|
|
# ``_resolve_default_x_axis`` guarantees x is set.
|
|
if config.x is None or config.x.name is None:
|
|
raise ValueError("XY chart requires an x-axis with a resolvable column name")
|
|
|
|
# Check if x-axis column is truly temporal (based on actual SQL type)
|
|
x_is_temporal = is_column_truly_temporal(config.x.name, dataset_id)
|
|
|
|
# Map chart kind to viz_type - always use the same viz types
|
|
# The temporal vs non-temporal handling is done via form_data configuration
|
|
viz_type_map = {
|
|
"line": "echarts_timeseries_line",
|
|
"bar": "echarts_timeseries_bar",
|
|
"area": "echarts_area",
|
|
"scatter": "echarts_timeseries_scatter",
|
|
}
|
|
|
|
if not x_is_temporal:
|
|
logger.info(
|
|
"X-axis column '%s' is not temporal (dataset_id=%s), "
|
|
"configuring as categorical dimension",
|
|
config.x.name,
|
|
dataset_id,
|
|
)
|
|
|
|
# Convert Y columns to metrics with validation
|
|
metrics = []
|
|
for col in config.y:
|
|
# SQL metrics carry sql_expression instead of name.
|
|
if not col.sql_expression and not (col.name and col.name.strip()):
|
|
raise ValueError("Y-axis column name cannot be empty")
|
|
metrics.append(create_metric_object(col))
|
|
|
|
# Final validation - ensure we have metrics to display
|
|
if not metrics:
|
|
raise ValueError("XY chart configuration resulted in no displayable metrics")
|
|
|
|
form_data: Dict[str, Any] = {
|
|
"viz_type": viz_type_map.get(config.kind, "echarts_timeseries_line"),
|
|
"metrics": metrics,
|
|
"x_axis": config.x.name,
|
|
}
|
|
|
|
# Configure temporal handling based on whether column is truly temporal
|
|
configure_temporal_handling(form_data, x_is_temporal, config.time_grain)
|
|
|
|
# Only add groupby columns that differ from x_axis to avoid
|
|
# "Duplicate column/metric labels" errors in Superset.
|
|
if config.group_by:
|
|
groupby_columns = [c.name for c in config.group_by if c.name != config.x.name]
|
|
if groupby_columns:
|
|
form_data["groupby"] = groupby_columns
|
|
|
|
_add_adhoc_filters(form_data, config.filters)
|
|
|
|
if x_is_temporal:
|
|
_ensure_temporal_adhoc_filter(form_data, config.x.name)
|
|
|
|
_add_xy_limits(form_data, config)
|
|
|
|
# Add stacking configuration
|
|
if getattr(config, "stacked", False):
|
|
form_data["stack"] = "Stack"
|
|
|
|
# Add configurations
|
|
add_axis_config(form_data, config)
|
|
add_legend_config(form_data, config)
|
|
add_orientation_config(form_data, config)
|
|
add_color_scheme(form_data, config.color_scheme)
|
|
add_currency_format(form_data, config.currency_format)
|
|
add_xy_data_label_options(form_data, config, x_is_temporal)
|
|
|
|
return form_data
|
|
|
|
|
|
def map_pie_config(config: PieChartConfig) -> Dict[str, Any]:
|
|
"""Map pie chart config to Superset form_data."""
|
|
metric = create_metric_object(config.metric)
|
|
|
|
form_data: Dict[str, Any] = {
|
|
"viz_type": "pie",
|
|
"groupby": [config.dimension.name],
|
|
"metric": metric,
|
|
"color_scheme": config.color_scheme or "supersetColors",
|
|
"show_labels": config.show_labels,
|
|
"show_legend": config.show_legend,
|
|
"legendOrientation": config.legend_orientation,
|
|
"label_type": config.label_type,
|
|
"number_format": config.number_format,
|
|
"date_format": config.date_format,
|
|
"sort_by_metric": config.sort_by_metric,
|
|
"row_limit": config.row_limit,
|
|
"donut": config.donut,
|
|
"show_total": config.show_total,
|
|
"labels_outside": config.labels_outside,
|
|
"outerRadius": config.outer_radius,
|
|
"innerRadius": config.inner_radius,
|
|
}
|
|
|
|
add_currency_format(form_data, config.currency_format)
|
|
_add_adhoc_filters(form_data, config.filters)
|
|
|
|
return form_data
|
|
|
|
|
|
def map_big_number_config(config: BigNumberChartConfig) -> Dict[str, Any]:
|
|
"""Map big number chart config to Superset form_data."""
|
|
# Determine viz_type: big_number (with trendline) or big_number_total
|
|
if config.show_trendline and config.temporal_column:
|
|
viz_type = "big_number"
|
|
else:
|
|
viz_type = "big_number_total"
|
|
|
|
metric = create_metric_object(config.metric)
|
|
form_data: Dict[str, Any] = {
|
|
"viz_type": viz_type,
|
|
"metric": metric,
|
|
}
|
|
|
|
if config.subheader:
|
|
form_data["subheader"] = config.subheader
|
|
|
|
if config.y_axis_format:
|
|
form_data["y_axis_format"] = config.y_axis_format
|
|
|
|
add_color_scheme(form_data, config.color_scheme)
|
|
add_currency_format(form_data, config.currency_format)
|
|
|
|
# Trendline-specific fields
|
|
if viz_type == "big_number":
|
|
# Big Number with trendline uses granularity_sqla for the temporal column
|
|
# (unlike XY charts which use x_axis). This is how Superset's
|
|
# big_number viz determines the time column for the trendline.
|
|
form_data["granularity_sqla"] = config.temporal_column
|
|
form_data["show_trend_line"] = True
|
|
form_data["start_y_axis_at_zero"] = config.start_y_axis_at_zero
|
|
|
|
if config.time_grain:
|
|
form_data["time_grain_sqla"] = config.time_grain
|
|
|
|
if config.compare_lag is not None:
|
|
form_data["compare_lag"] = config.compare_lag
|
|
|
|
if config.time_format:
|
|
form_data["time_format"] = config.time_format
|
|
|
|
if config.aggregation is not None:
|
|
form_data["aggregation"] = config.aggregation
|
|
|
|
_add_adhoc_filters(form_data, config.filters)
|
|
|
|
return form_data
|
|
|
|
|
|
def map_handlebars_config(config: HandlebarsChartConfig) -> Dict[str, Any]:
|
|
"""Map handlebars chart config to Superset form_data."""
|
|
form_data: Dict[str, Any] = {
|
|
"viz_type": "handlebars",
|
|
"handlebars_template": config.handlebars_template,
|
|
"row_limit": config.row_limit,
|
|
"order_desc": config.order_desc,
|
|
}
|
|
|
|
if config.style_template:
|
|
form_data["styleTemplate"] = config.style_template
|
|
|
|
if config.query_mode == "raw":
|
|
form_data["query_mode"] = "raw"
|
|
if config.columns:
|
|
form_data["all_columns"] = [col.name for col in config.columns]
|
|
else:
|
|
form_data["query_mode"] = "aggregate"
|
|
if config.groupby:
|
|
form_data["groupby"] = [col.name for col in config.groupby]
|
|
if config.metrics:
|
|
form_data["metrics"] = [create_metric_object(col) for col in config.metrics]
|
|
if config.filters:
|
|
form_data["adhoc_filters"] = [
|
|
{
|
|
"clause": "WHERE",
|
|
"expressionType": "SIMPLE",
|
|
"subject": filter_config.column,
|
|
"operator": map_filter_operator(filter_config.op),
|
|
"comparator": filter_config.value,
|
|
}
|
|
for filter_config in config.filters
|
|
if filter_config is not None
|
|
]
|
|
|
|
return form_data
|
|
|
|
|
|
def map_pivot_table_config(config: PivotTableChartConfig) -> Dict[str, Any]:
|
|
"""Map pivot table config to Superset form_data."""
|
|
if not config.rows:
|
|
raise ValueError("Pivot table must have at least one row grouping column")
|
|
if not config.metrics:
|
|
raise ValueError("Pivot table must have at least one metric")
|
|
|
|
metrics = [create_metric_object(col) for col in config.metrics]
|
|
|
|
form_data: Dict[str, Any] = {
|
|
"viz_type": "pivot_table_v2",
|
|
"groupbyRows": [col.name for col in config.rows],
|
|
"groupbyColumns": [col.name for col in config.columns]
|
|
if config.columns
|
|
else [],
|
|
"metrics": metrics,
|
|
"aggregateFunction": config.aggregate_function,
|
|
"rowTotals": config.show_row_totals,
|
|
"colTotals": config.show_column_totals,
|
|
"transposePivot": config.transpose,
|
|
"combineMetric": config.combine_metric,
|
|
"valueFormat": config.value_format,
|
|
"metricsLayout": "COLUMNS",
|
|
"rowOrder": "key_a_to_z",
|
|
"colOrder": "key_a_to_z",
|
|
"row_limit": config.row_limit,
|
|
}
|
|
|
|
if config.date_format:
|
|
form_data["date_format"] = config.date_format
|
|
|
|
add_currency_format(form_data, config.currency_format)
|
|
_add_adhoc_filters(form_data, config.filters)
|
|
|
|
return form_data
|
|
|
|
|
|
_MIXED_SERIES_TYPE_MAP = {
|
|
"line": "line",
|
|
"bar": "bar",
|
|
"area": "line", # area uses line type with area=True
|
|
"scatter": "scatter",
|
|
}
|
|
|
|
|
|
def _apply_axis_to_form_data(
|
|
form_data: Dict[str, Any],
|
|
axis_config: Any,
|
|
title_key: str,
|
|
format_key: str,
|
|
log_key: str | None = None,
|
|
) -> None:
|
|
"""Apply a single axis configuration to form_data."""
|
|
if not axis_config:
|
|
return
|
|
if axis_config.title:
|
|
form_data[title_key] = axis_config.title
|
|
if axis_config.format:
|
|
form_data[format_key] = axis_config.format
|
|
if log_key and axis_config.scale == "log":
|
|
form_data[log_key] = True
|
|
|
|
|
|
def _add_mixed_axis_config(
|
|
form_data: Dict[str, Any],
|
|
config: MixedTimeseriesChartConfig,
|
|
) -> None:
|
|
"""Add axis configurations to mixed timeseries form_data."""
|
|
_apply_axis_to_form_data(
|
|
form_data, config.x_axis, "xAxisTitle", "x_axis_time_format"
|
|
)
|
|
_apply_axis_to_form_data(
|
|
form_data, config.y_axis, "yAxisTitle", "y_axis_format", "logAxis"
|
|
)
|
|
_apply_axis_to_form_data(
|
|
form_data,
|
|
config.y_axis_secondary,
|
|
"yAxisTitleSecondary",
|
|
"y_axis_format_secondary",
|
|
"logAxisSecondary",
|
|
)
|
|
|
|
|
|
def map_mixed_timeseries_config(
|
|
config: MixedTimeseriesChartConfig,
|
|
dataset_id: int | str | None = None,
|
|
) -> Dict[str, Any]:
|
|
"""Map mixed timeseries chart config to Superset form_data."""
|
|
if not config.y:
|
|
raise ValueError("Mixed timeseries must have at least one primary metric")
|
|
if not config.y_secondary:
|
|
raise ValueError("Mixed timeseries must have at least one secondary metric")
|
|
|
|
# x rejects sql_expression at validation, so name is set.
|
|
if config.x.name is None:
|
|
raise ValueError("Mixed timeseries chart requires an x-axis column name")
|
|
x_is_temporal = is_column_truly_temporal(config.x.name, dataset_id)
|
|
|
|
form_data: Dict[str, Any] = {
|
|
"viz_type": "mixed_timeseries",
|
|
"x_axis": config.x.name,
|
|
# Query A
|
|
"metrics": [create_metric_object(col) for col in config.y],
|
|
"seriesType": _MIXED_SERIES_TYPE_MAP.get(config.primary_kind, "line"),
|
|
"area": config.primary_kind == "area",
|
|
"yAxisIndex": 0,
|
|
# Query B
|
|
"metrics_b": [create_metric_object(col) for col in config.y_secondary],
|
|
"seriesTypeB": _MIXED_SERIES_TYPE_MAP.get(config.secondary_kind, "bar"),
|
|
"areaB": config.secondary_kind == "area",
|
|
"yAxisIndexB": 1,
|
|
# Display
|
|
"show_legend": config.show_legend,
|
|
"legendOrientation": config.legend_orientation,
|
|
"zoomable": True,
|
|
"rich_tooltip": True,
|
|
}
|
|
|
|
if config.show_value:
|
|
form_data["show_value"] = True
|
|
|
|
add_color_scheme(form_data, config.color_scheme)
|
|
add_currency_format(form_data, config.currency_format)
|
|
add_currency_format(
|
|
form_data, config.currency_format_secondary, key="currency_format_secondary"
|
|
)
|
|
|
|
# Configure temporal handling
|
|
configure_temporal_handling(form_data, x_is_temporal, config.time_grain)
|
|
|
|
# Primary groupby (Query A)
|
|
if config.group_by:
|
|
groupby = [c.name for c in config.group_by if c.name != config.x.name]
|
|
if groupby:
|
|
form_data["groupby"] = groupby
|
|
|
|
# Secondary groupby (Query B)
|
|
if config.group_by_secondary:
|
|
groupby_b = [
|
|
c.name for c in config.group_by_secondary if c.name != config.x.name
|
|
]
|
|
if groupby_b:
|
|
form_data["groupby_b"] = groupby_b
|
|
|
|
form_data["row_limit"] = config.row_limit
|
|
|
|
_add_mixed_axis_config(form_data, config)
|
|
|
|
_add_adhoc_filters(form_data, config.filters)
|
|
|
|
return form_data
|
|
|
|
|
|
def map_filter_operator(op: str) -> str:
|
|
"""Map filter operator to Superset format."""
|
|
operator_map = {
|
|
"=": "==",
|
|
">": ">",
|
|
"<": "<",
|
|
">=": ">=",
|
|
"<=": "<=",
|
|
"!=": "!=",
|
|
"LIKE": "LIKE",
|
|
"ILIKE": "ILIKE",
|
|
"NOT LIKE": "NOT LIKE",
|
|
"IN": "IN",
|
|
"NOT IN": "NOT IN",
|
|
}
|
|
return operator_map.get(op, op)
|
|
|
|
|
|
def _humanize_column(col: ColumnRef) -> str:
|
|
"""Return a human-readable label for a column reference."""
|
|
if col.label:
|
|
return col.label
|
|
if col.sql_expression:
|
|
return col.sql_expression
|
|
name = (col.name or "").replace("_", " ").title()
|
|
if col.saved_metric:
|
|
return name
|
|
if col.aggregate:
|
|
return f"{col.aggregate.capitalize()}({name})"
|
|
return name
|
|
|
|
|
|
def _summarize_filters(
|
|
filters: list[FilterConfig] | None,
|
|
) -> str | None:
|
|
"""Extract a short context string from filter configs."""
|
|
if not filters:
|
|
return None
|
|
parts: list[str] = []
|
|
for f in filters[:2]:
|
|
col = getattr(f, "column", "")
|
|
val = getattr(f, "value", "")
|
|
if isinstance(val, list):
|
|
val = ", ".join(str(v) for v in val[:3])
|
|
parts.append(f"{str(col).replace('_', ' ').title()} {val}")
|
|
return ", ".join(parts) if parts else None
|
|
|
|
|
|
def _truncate(name: str, max_length: int = 60) -> str:
|
|
"""Truncate to *max_length*, preserving the en-dash context portion."""
|
|
if len(name) <= max_length:
|
|
return name
|
|
if " \u2013 " in name:
|
|
what, _context = name.split(" \u2013 ", 1)
|
|
if len(what) <= max_length:
|
|
return what
|
|
return name[: max_length - 1] + "\u2026"
|
|
|
|
|
|
def _table_chart_what(config: TableChartConfig, dataset_name: str | None) -> str:
|
|
"""Build the descriptive fragment for a table chart."""
|
|
has_agg = any(col.is_metric for col in config.columns)
|
|
if has_agg:
|
|
metrics = [col for col in config.columns if col.is_metric]
|
|
what = ", ".join(_humanize_column(m) for m in metrics[:2])
|
|
return f"{what} Summary"
|
|
if dataset_name:
|
|
return f"{dataset_name} Records"
|
|
cols = ", ".join(_humanize_column(c) for c in config.columns[:3])
|
|
return f"{cols} Table"
|
|
|
|
|
|
def _xy_chart_what(config: XYChartConfig) -> str:
|
|
"""Build the descriptive fragment for an XY chart."""
|
|
primary_metric = _humanize_column(config.y[0]) if config.y else "Value"
|
|
dimension = _humanize_column(config.x) if config.x else "Dimension"
|
|
|
|
if config.kind in ("line", "area") and not config.group_by:
|
|
return f"{primary_metric} Over Time"
|
|
if config.group_by:
|
|
group_label = _humanize_column(config.group_by[0])
|
|
return f"{primary_metric} by {group_label}"
|
|
if config.kind == "scatter":
|
|
return f"{primary_metric} vs {dimension}"
|
|
return f"{primary_metric} by {dimension}"
|
|
|
|
|
|
_GRAIN_MAP: dict[str, str] = {
|
|
"PT1H": "Hourly",
|
|
"P1D": "Daily",
|
|
"P1W": "Weekly",
|
|
"P1M": "Monthly",
|
|
"P3M": "Quarterly",
|
|
"P1Y": "Yearly",
|
|
}
|
|
|
|
|
|
def _xy_chart_context(config: XYChartConfig) -> str | None:
|
|
"""Build context (time grain / filters) for an XY chart name."""
|
|
parts: list[str] = []
|
|
if config.time_grain:
|
|
grain_val = (
|
|
config.time_grain.value
|
|
if hasattr(config.time_grain, "value")
|
|
else str(config.time_grain)
|
|
)
|
|
grain_str = _GRAIN_MAP.get(grain_val, grain_val)
|
|
parts.append(grain_str)
|
|
if filter_ctx := _summarize_filters(config.filters):
|
|
parts.append(filter_ctx)
|
|
return ", ".join(parts) if parts else None
|
|
|
|
|
|
def _pie_chart_what(config: PieChartConfig) -> str:
|
|
"""Build the 'what' portion for a pie chart name."""
|
|
dim = config.dimension.name
|
|
metric_label = (
|
|
config.metric.label or config.metric.name or config.metric.sql_expression
|
|
)
|
|
return f"{dim} by {metric_label}"
|
|
|
|
|
|
def _pivot_table_what(config: PivotTableChartConfig) -> str:
|
|
"""Build the 'what' portion for a pivot table chart name."""
|
|
# Pivot rows reject sql_expression at validation, so name is set.
|
|
row_names = ", ".join(r.name or "" for r in config.rows)
|
|
return f"Pivot Table \u2013 {row_names}"
|
|
|
|
|
|
def _mixed_timeseries_what(config: MixedTimeseriesChartConfig) -> str:
|
|
"""Build the 'what' portion for a mixed timeseries chart name."""
|
|
primary = (
|
|
(config.y[0].label or config.y[0].name or config.y[0].sql_expression)
|
|
if config.y
|
|
else "primary"
|
|
)
|
|
secondary = (
|
|
(
|
|
config.y_secondary[0].label
|
|
or config.y_secondary[0].name
|
|
or config.y_secondary[0].sql_expression
|
|
)
|
|
if config.y_secondary
|
|
else "secondary"
|
|
)
|
|
return f"{primary} + {secondary}"
|
|
|
|
|
|
def _handlebars_chart_what(config: HandlebarsChartConfig) -> str:
|
|
"""Build the 'what' portion for a handlebars chart name.
|
|
|
|
Uses parentheses instead of en-dash to avoid collision with
|
|
``generate_chart_name``'s ``\u2013`` context separator.
|
|
"""
|
|
if config.query_mode == "raw" and config.columns:
|
|
# Raw columns reject sql_expression at validation, so col.name is set.
|
|
cols = ", ".join(col.name or "" for col in config.columns[:3])
|
|
return f"Handlebars ({cols})"
|
|
elif config.metrics:
|
|
# Prefer raw column name for back-compat with existing chart names;
|
|
# SQL metrics fall back to label, then the expression itself.
|
|
metrics = ", ".join(
|
|
col.name or col.label or col.sql_expression or ""
|
|
for col in config.metrics[:3]
|
|
)
|
|
return f"Handlebars ({metrics})"
|
|
return "Handlebars Chart"
|
|
|
|
|
|
def _big_number_chart_what(config: BigNumberChartConfig) -> str:
|
|
"""Build the 'what' portion for a big number chart name.
|
|
|
|
Uses parentheses instead of en-dash to avoid collision with
|
|
``generate_chart_name``'s ``\u2013`` context separator.
|
|
"""
|
|
if config.metric.label:
|
|
metric_label = config.metric.label
|
|
elif config.metric.sql_expression:
|
|
metric_label = config.metric.sql_expression
|
|
elif config.metric.aggregate:
|
|
metric_label = f"{config.metric.aggregate}({config.metric.name})"
|
|
else:
|
|
metric_label = config.metric.name or ""
|
|
if config.show_trendline:
|
|
return f"Big Number ({metric_label}, trendline)"
|
|
return f"Big Number ({metric_label})"
|
|
|
|
|
|
def generate_chart_name(
|
|
config: Any,
|
|
dataset_name: str | None = None,
|
|
) -> str:
|
|
"""Generate a descriptive chart name following a standard format.
|
|
|
|
Delegates to each plugin's ``generate_name()`` method.
|
|
See each plugin's ``generate_name`` for chart-type-specific format conventions.
|
|
An en-dash followed by context (filters / time grain) is appended by the plugin
|
|
when such information is available.
|
|
"""
|
|
from superset.mcp_service.chart.registry import get_registry
|
|
|
|
plugin = get_registry().get(getattr(config, "chart_type", ""))
|
|
if plugin is None:
|
|
return "Chart"
|
|
return _truncate(plugin.generate_name(config, dataset_name))
|
|
|
|
|
|
def _resolve_viz_type(config: Any) -> str:
|
|
"""Resolve the Superset viz_type from a chart config object."""
|
|
from superset.mcp_service.chart.registry import get_registry
|
|
|
|
plugin = get_registry().get(getattr(config, "chart_type", ""))
|
|
if plugin is None:
|
|
return "unknown"
|
|
return plugin.resolve_viz_type(config)
|
|
|
|
|
|
TABLE_VIZ_TYPE_LABELS = {
|
|
"table": "table chart",
|
|
"ag-grid-table": "interactive table chart",
|
|
}
|
|
|
|
|
|
def get_table_chart_type_label(viz_type: str | None) -> str | None:
|
|
"""Return a user-facing label for table-family Superset viz types."""
|
|
return TABLE_VIZ_TYPE_LABELS.get(viz_type) if viz_type is not None else None
|
|
|
|
|
|
def analyze_chart_capabilities(chart: Any | None, config: Any) -> ChartCapabilities:
|
|
"""Analyze chart capabilities based on type and configuration."""
|
|
if chart:
|
|
viz_type = getattr(chart, "viz_type", "unknown")
|
|
else:
|
|
viz_type = _resolve_viz_type(config)
|
|
|
|
# Determine interaction capabilities based on chart type
|
|
interactive_types = [
|
|
"echarts_timeseries_line",
|
|
"echarts_timeseries_bar",
|
|
"echarts_area",
|
|
"echarts_timeseries_scatter",
|
|
"deck_scatter",
|
|
"deck_hex",
|
|
"ag-grid-table", # AG Grid tables are interactive
|
|
]
|
|
|
|
supports_interaction = viz_type in interactive_types
|
|
supports_drill_down = viz_type in ["table", "pivot_table_v2", "ag-grid-table"]
|
|
supports_real_time = viz_type in [
|
|
"echarts_timeseries_line",
|
|
"echarts_timeseries_bar",
|
|
]
|
|
|
|
# Determine optimal formats
|
|
optimal_formats = ["url"] # Always include static image
|
|
if supports_interaction:
|
|
optimal_formats.extend(["interactive", "vega_lite"])
|
|
optimal_formats.extend(["ascii", "table"])
|
|
|
|
# Classify data types
|
|
data_types = []
|
|
if hasattr(config, "x") and config.x:
|
|
data_types.append("categorical" if not config.x.is_metric else "metric")
|
|
if hasattr(config, "y") and config.y:
|
|
data_types.extend(["metric"] * len(config.y))
|
|
if "time" in viz_type or "timeseries" in viz_type:
|
|
data_types.append("time_series")
|
|
|
|
return ChartCapabilities(
|
|
supports_interaction=supports_interaction,
|
|
supports_real_time=supports_real_time,
|
|
supports_drill_down=supports_drill_down,
|
|
supports_export=True, # All charts can be exported
|
|
optimal_formats=optimal_formats,
|
|
data_types=list(set(data_types)),
|
|
)
|
|
|
|
|
|
def analyze_chart_semantics(chart: Any | None, config: Any) -> ChartSemantics:
|
|
"""Generate semantic understanding of the chart."""
|
|
if chart:
|
|
viz_type = getattr(chart, "viz_type", "unknown")
|
|
else:
|
|
viz_type = _resolve_viz_type(config)
|
|
|
|
# Generate primary insight based on chart type
|
|
insights_map = {
|
|
"echarts_timeseries_line": "Shows trends and changes over time",
|
|
"echarts_timeseries_bar": "Compares values across categories or time periods",
|
|
"table": "Displays detailed data in tabular format",
|
|
"ag-grid-table": (
|
|
"Interactive table with advanced features like column resizing, "
|
|
"sorting, filtering, and server-side pagination"
|
|
),
|
|
"pie": "Shows proportional relationships within a dataset",
|
|
"echarts_area": "Emphasizes cumulative totals and part-to-whole relationships",
|
|
"pivot_table_v2": (
|
|
"Cross-tabulates data with rows, columns, and aggregated metrics "
|
|
"for multi-dimensional analysis"
|
|
),
|
|
"mixed_timeseries": (
|
|
"Combines two different chart types on the same time axis "
|
|
"for comparing related metrics with different scales"
|
|
),
|
|
"handlebars": (
|
|
"Renders data using a custom Handlebars HTML template for "
|
|
"fully flexible layouts like KPI cards, leaderboards, and reports"
|
|
),
|
|
"big_number": (
|
|
"Displays a key metric with a trendline showing "
|
|
"how the value changes over time"
|
|
),
|
|
"big_number_total": (
|
|
"Highlights a single key metric value as a prominent number"
|
|
),
|
|
}
|
|
|
|
primary_insight = insights_map.get(
|
|
viz_type, f"Visualizes data using {viz_type} format"
|
|
)
|
|
|
|
# Generate data story
|
|
columns = []
|
|
if hasattr(config, "x") and config.x:
|
|
columns.append(config.x.name)
|
|
if hasattr(config, "y") and config.y:
|
|
# SQL metrics have no name; fall back to label or the expression.
|
|
columns.extend(
|
|
[col.name or col.label or col.sql_expression for col in config.y]
|
|
)
|
|
|
|
if columns:
|
|
ellipsis = "..." if len(columns) > 3 else ""
|
|
data_story = (
|
|
f"This {viz_type} chart analyzes {', '.join(columns[:3])}{ellipsis}"
|
|
)
|
|
else:
|
|
data_story = "This chart provides insights into the selected dataset"
|
|
|
|
# Generate recommended actions
|
|
recommended_actions = [
|
|
"Review data patterns and trends",
|
|
"Consider filtering or drilling down for more detail",
|
|
"Export chart for reporting or sharing",
|
|
]
|
|
|
|
if viz_type in ["echarts_timeseries_line", "echarts_timeseries_bar"]:
|
|
recommended_actions.append("Analyze seasonal patterns or cyclical trends")
|
|
|
|
return ChartSemantics(
|
|
primary_insight=primary_insight,
|
|
data_story=data_story,
|
|
recommended_actions=recommended_actions,
|
|
anomalies=[], # Would need actual data analysis to populate
|
|
statistical_summary={}, # Would need actual data analysis to populate
|
|
)
|