mirror of
https://github.com/apache/superset.git
synced 2026-05-07 08:54:23 +00:00
feat(mcp): add Handlebars chart type support to MCP service (#38402)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -123,6 +123,10 @@ Chart Types You Can CREATE with generate_chart/generate_explore_link:
|
||||
- chart_type="pie": Pie chart for proportional data (set donut=True for donut)
|
||||
- chart_type="pivot_table": Interactive pivot table for cross-tabulation
|
||||
- chart_type="mixed_timeseries": Dual-series chart combining two chart types
|
||||
- chart_type="handlebars": Custom HTML template chart (KPI cards, leaderboards, reports)
|
||||
Requires handlebars_template with Handlebars HTML template string.
|
||||
Supports query_mode="aggregate" (with metrics/groupby) or "raw" (with columns).
|
||||
Data available as {{{{data}}}} array; helpers: dateFormat, formatNumber, stringify.
|
||||
|
||||
Time grain for temporal x-axis (time_grain parameter):
|
||||
- PT1H (hourly), P1D (daily), P1W (weekly), P1M (monthly), P1Y (yearly)
|
||||
|
||||
@@ -31,6 +31,7 @@ from superset.mcp_service.chart.schemas import (
|
||||
ChartSemantics,
|
||||
ColumnRef,
|
||||
FilterConfig,
|
||||
HandlebarsChartConfig,
|
||||
MixedTimeseriesChartConfig,
|
||||
PieChartConfig,
|
||||
PivotTableChartConfig,
|
||||
@@ -309,7 +310,8 @@ def map_config_to_form_data(
|
||||
| XYChartConfig
|
||||
| PieChartConfig
|
||||
| PivotTableChartConfig
|
||||
| MixedTimeseriesChartConfig,
|
||||
| MixedTimeseriesChartConfig
|
||||
| HandlebarsChartConfig,
|
||||
dataset_id: int | str | None = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Map chart config to Superset form_data."""
|
||||
@@ -323,6 +325,8 @@ def map_config_to_form_data(
|
||||
return map_pivot_table_config(config)
|
||||
elif isinstance(config, MixedTimeseriesChartConfig):
|
||||
return map_mixed_timeseries_config(config, dataset_id=dataset_id)
|
||||
elif isinstance(config, HandlebarsChartConfig):
|
||||
return map_handlebars_config(config)
|
||||
else:
|
||||
raise ValueError(f"Unsupported config type: {type(config)}")
|
||||
|
||||
@@ -638,6 +642,44 @@ def map_pie_config(config: PieChartConfig) -> Dict[str, Any]:
|
||||
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:
|
||||
@@ -908,12 +950,28 @@ def _mixed_timeseries_what(config: MixedTimeseriesChartConfig) -> str:
|
||||
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:
|
||||
cols = ", ".join(col.name for col in config.columns[:3])
|
||||
return f"Handlebars ({cols})"
|
||||
elif config.metrics:
|
||||
metrics = ", ".join(col.name for col in config.metrics[:3])
|
||||
return f"Handlebars ({metrics})"
|
||||
return "Handlebars Chart"
|
||||
|
||||
|
||||
def generate_chart_name(
|
||||
config: TableChartConfig
|
||||
| XYChartConfig
|
||||
| PieChartConfig
|
||||
| PivotTableChartConfig
|
||||
| MixedTimeseriesChartConfig,
|
||||
| MixedTimeseriesChartConfig
|
||||
| HandlebarsChartConfig,
|
||||
dataset_name: str | None = None,
|
||||
) -> str:
|
||||
"""Generate a descriptive chart name following a standard format.
|
||||
@@ -944,6 +1002,9 @@ def generate_chart_name(
|
||||
elif isinstance(config, MixedTimeseriesChartConfig):
|
||||
what = _mixed_timeseries_what(config)
|
||||
context = _summarize_filters(config.filters)
|
||||
elif isinstance(config, HandlebarsChartConfig):
|
||||
what = _handlebars_chart_what(config)
|
||||
context = _summarize_filters(getattr(config, "filters", None))
|
||||
else:
|
||||
return "Chart"
|
||||
|
||||
@@ -953,6 +1014,31 @@ def generate_chart_name(
|
||||
return _truncate(name)
|
||||
|
||||
|
||||
def _resolve_viz_type(config: Any) -> str:
|
||||
"""Resolve the Superset viz_type from a chart config object."""
|
||||
chart_type = getattr(config, "chart_type", "unknown")
|
||||
if chart_type == "xy":
|
||||
kind = getattr(config, "kind", "line")
|
||||
viz_type_map = {
|
||||
"line": "echarts_timeseries_line",
|
||||
"bar": "echarts_timeseries_bar",
|
||||
"area": "echarts_area",
|
||||
"scatter": "echarts_timeseries_scatter",
|
||||
}
|
||||
return viz_type_map.get(kind, "echarts_timeseries_line")
|
||||
elif chart_type == "table":
|
||||
return getattr(config, "viz_type", "table")
|
||||
elif chart_type == "pie":
|
||||
return "pie"
|
||||
elif chart_type == "pivot_table":
|
||||
return "pivot_table_v2"
|
||||
elif chart_type == "mixed_timeseries":
|
||||
return "mixed_timeseries"
|
||||
elif chart_type == "handlebars":
|
||||
return "handlebars"
|
||||
return "unknown"
|
||||
|
||||
|
||||
def analyze_chart_capabilities(chart: Any | None, config: Any) -> ChartCapabilities:
|
||||
"""Analyze chart capabilities based on type and configuration."""
|
||||
if chart:
|
||||
@@ -1003,29 +1089,6 @@ def analyze_chart_capabilities(chart: Any | None, config: Any) -> ChartCapabilit
|
||||
)
|
||||
|
||||
|
||||
def _resolve_viz_type(config: Any) -> str:
|
||||
"""Resolve viz_type from a chart config object."""
|
||||
chart_type = getattr(config, "chart_type", "unknown")
|
||||
if chart_type == "xy":
|
||||
kind = getattr(config, "kind", "line")
|
||||
viz_type_map = {
|
||||
"line": "echarts_timeseries_line",
|
||||
"bar": "echarts_timeseries_bar",
|
||||
"area": "echarts_area",
|
||||
"scatter": "echarts_timeseries_scatter",
|
||||
}
|
||||
return viz_type_map.get(kind, "echarts_timeseries_line")
|
||||
elif chart_type == "table":
|
||||
return getattr(config, "viz_type", "table")
|
||||
elif chart_type == "pie":
|
||||
return "pie"
|
||||
elif chart_type == "pivot_table":
|
||||
return "pivot_table_v2"
|
||||
elif chart_type == "mixed_timeseries":
|
||||
return "mixed_timeseries"
|
||||
return "unknown"
|
||||
|
||||
|
||||
def analyze_chart_semantics(chart: Any | None, config: Any) -> ChartSemantics:
|
||||
"""Generate semantic understanding of the chart."""
|
||||
if chart:
|
||||
@@ -1052,6 +1115,10 @@ def analyze_chart_semantics(chart: Any | None, config: Any) -> ChartSemantics:
|
||||
"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"
|
||||
),
|
||||
}
|
||||
|
||||
primary_insight = insights_map.get(
|
||||
|
||||
@@ -659,6 +659,108 @@ class MixedTimeseriesChartConfig(BaseModel):
|
||||
return _normalize_group_by_input(v)
|
||||
|
||||
|
||||
class HandlebarsChartConfig(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
chart_type: Literal["handlebars"] = Field(
|
||||
...,
|
||||
description=(
|
||||
"Chart type discriminator - MUST be 'handlebars' for custom HTML "
|
||||
"template charts. Handlebars charts render query results using "
|
||||
"Handlebars templates, enabling fully custom layouts like KPI cards, "
|
||||
"leaderboards, and formatted reports."
|
||||
),
|
||||
)
|
||||
handlebars_template: str = Field(
|
||||
...,
|
||||
description=(
|
||||
"Handlebars HTML template string. Data is available as {{data}} array. "
|
||||
"Built-in helpers: {{dateFormat val format='YYYY-MM-DD'}}, "
|
||||
"{{formatNumber val}}, {{stringify obj}}. "
|
||||
"Example: '<ul>{{#each data}}<li>{{this.name}}: {{this.value}}</li>"
|
||||
"{{/each}}</ul>'"
|
||||
),
|
||||
min_length=1,
|
||||
max_length=50000,
|
||||
)
|
||||
query_mode: Literal["aggregate", "raw"] = Field(
|
||||
"aggregate",
|
||||
description=(
|
||||
"Query mode: 'aggregate' groups data with metrics, "
|
||||
"'raw' returns individual rows"
|
||||
),
|
||||
)
|
||||
columns: list[ColumnRef] | None = Field(
|
||||
None,
|
||||
description=(
|
||||
"Columns to display in raw mode (query_mode='raw'). "
|
||||
"Each column specifies a column name to include in the query results."
|
||||
),
|
||||
)
|
||||
groupby: list[ColumnRef] | None = Field(
|
||||
None,
|
||||
description=(
|
||||
"Columns to group by in aggregate mode (query_mode='aggregate'). "
|
||||
"These become the dimensions for aggregation."
|
||||
),
|
||||
)
|
||||
metrics: list[ColumnRef] | None = Field(
|
||||
None,
|
||||
description=(
|
||||
"Metrics to aggregate in aggregate mode. "
|
||||
"Each must have an aggregate function (e.g., SUM, COUNT)."
|
||||
),
|
||||
)
|
||||
filters: list[FilterConfig] | None = Field(None, description="Filters to apply")
|
||||
row_limit: int = Field(
|
||||
1000,
|
||||
description="Maximum number of rows",
|
||||
ge=1,
|
||||
le=50000,
|
||||
)
|
||||
order_desc: bool = Field(True, description="Sort in descending order")
|
||||
style_template: str | None = Field(
|
||||
None,
|
||||
description="Optional CSS styles to apply to the rendered template",
|
||||
max_length=10000,
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_query_fields(self) -> "HandlebarsChartConfig":
|
||||
"""Validate that the right fields are provided for the query mode."""
|
||||
if self.query_mode == "raw":
|
||||
if not self.columns:
|
||||
raise ValueError(
|
||||
"Handlebars chart in 'raw' query mode requires 'columns' field. "
|
||||
"Specify which columns to include in the query results."
|
||||
)
|
||||
if self.metrics:
|
||||
raise ValueError(
|
||||
"Handlebars chart in 'raw' query mode does not use 'metrics'. "
|
||||
"Remove 'metrics' or switch to 'aggregate' query mode."
|
||||
)
|
||||
if self.groupby:
|
||||
raise ValueError(
|
||||
"Handlebars chart in 'raw' query mode does not use 'groupby'. "
|
||||
"Remove 'groupby' or switch to 'aggregate' query mode."
|
||||
)
|
||||
if self.query_mode == "aggregate":
|
||||
if not self.metrics:
|
||||
raise ValueError(
|
||||
"Handlebars chart in 'aggregate' query mode requires 'metrics' "
|
||||
"field. Specify at least one metric with an aggregate function."
|
||||
)
|
||||
missing_agg = [m.name for m in self.metrics if not m.aggregate]
|
||||
if missing_agg:
|
||||
raise ValueError(
|
||||
f"Handlebars chart in 'aggregate' query mode requires an "
|
||||
f"aggregate function on every metric. Missing aggregate for: "
|
||||
f"{', '.join(missing_agg)}. "
|
||||
f"Use one of: SUM, COUNT, AVG, MIN, MAX, COUNT_DISTINCT, etc."
|
||||
)
|
||||
return self
|
||||
|
||||
|
||||
class TableChartConfig(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore", populate_by_name=True)
|
||||
|
||||
@@ -810,12 +912,13 @@ ChartConfig = Annotated[
|
||||
| TableChartConfig
|
||||
| PieChartConfig
|
||||
| PivotTableChartConfig
|
||||
| MixedTimeseriesChartConfig,
|
||||
| MixedTimeseriesChartConfig
|
||||
| HandlebarsChartConfig,
|
||||
Field(
|
||||
discriminator="chart_type",
|
||||
description=(
|
||||
"Chart configuration - specify chart_type as 'xy', 'table', "
|
||||
"'pie', 'pivot_table', or 'mixed_timeseries'"
|
||||
"'pie', 'pivot_table', 'mixed_timeseries', or 'handlebars'"
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -133,6 +133,8 @@ class SchemaValidator:
|
||||
"Add 'chart_type': 'pie' for pie or donut charts",
|
||||
"Add 'chart_type': 'pivot_table' for interactive pivot tables",
|
||||
"Add 'chart_type': 'mixed_timeseries' for dual-series time charts",
|
||||
"Add 'chart_type': 'handlebars' for custom HTML template charts",
|
||||
"Example: 'config': {'chart_type': 'xy', ...}",
|
||||
],
|
||||
error_code="MISSING_CHART_TYPE",
|
||||
)
|
||||
@@ -151,6 +153,7 @@ class SchemaValidator:
|
||||
"pie": SchemaValidator._pre_validate_pie_config,
|
||||
"pivot_table": SchemaValidator._pre_validate_pivot_table_config,
|
||||
"mixed_timeseries": SchemaValidator._pre_validate_mixed_timeseries_config,
|
||||
"handlebars": SchemaValidator._pre_validate_handlebars_config,
|
||||
}
|
||||
|
||||
if not isinstance(chart_type, str) or chart_type not in chart_type_validators:
|
||||
@@ -166,6 +169,7 @@ class SchemaValidator:
|
||||
"Use 'chart_type': 'pie' for pie or donut charts",
|
||||
"Use 'chart_type': 'pivot_table' for interactive pivot tables",
|
||||
"Use 'chart_type': 'mixed_timeseries' for dual-series time charts",
|
||||
"Use 'chart_type': 'handlebars' for custom HTML template charts",
|
||||
"Check spelling and ensure lowercase",
|
||||
],
|
||||
error_code="INVALID_CHART_TYPE",
|
||||
@@ -282,6 +286,82 @@ class SchemaValidator:
|
||||
|
||||
return True, None
|
||||
|
||||
@staticmethod
|
||||
def _pre_validate_handlebars_config(
|
||||
config: Dict[str, Any],
|
||||
) -> Tuple[bool, ChartGenerationError | None]:
|
||||
"""Pre-validate handlebars chart configuration."""
|
||||
if "handlebars_template" not in config:
|
||||
return False, ChartGenerationError(
|
||||
error_type="missing_handlebars_template",
|
||||
message="Handlebars chart missing required field: handlebars_template",
|
||||
details="Handlebars charts require a 'handlebars_template' string "
|
||||
"containing Handlebars HTML template markup",
|
||||
suggestions=[
|
||||
"Add 'handlebars_template' with a Handlebars HTML template",
|
||||
"Data is available as {{data}} array in the template",
|
||||
"Example: '<ul>{{#each data}}<li>{{this.name}}: "
|
||||
"{{this.value}}</li>{{/each}}</ul>'",
|
||||
],
|
||||
error_code="MISSING_HANDLEBARS_TEMPLATE",
|
||||
)
|
||||
|
||||
template = config.get("handlebars_template")
|
||||
if not isinstance(template, str) or not template.strip():
|
||||
return False, ChartGenerationError(
|
||||
error_type="invalid_handlebars_template",
|
||||
message="Handlebars template must be a non-empty string",
|
||||
details="The 'handlebars_template' field must be a non-empty string "
|
||||
"containing valid Handlebars HTML template markup",
|
||||
suggestions=[
|
||||
"Ensure handlebars_template is a non-empty string",
|
||||
"Example: '<ul>{{#each data}}<li>{{this.name}}</li>{{/each}}</ul>'",
|
||||
],
|
||||
error_code="INVALID_HANDLEBARS_TEMPLATE",
|
||||
)
|
||||
|
||||
query_mode = config.get("query_mode", "aggregate")
|
||||
if query_mode not in ("aggregate", "raw"):
|
||||
return False, ChartGenerationError(
|
||||
error_type="invalid_query_mode",
|
||||
message="Invalid query_mode for handlebars chart",
|
||||
details="query_mode must be either 'aggregate' or 'raw'",
|
||||
suggestions=[
|
||||
"Use 'aggregate' for aggregated data (default)",
|
||||
"Use 'raw' for individual rows",
|
||||
],
|
||||
error_code="INVALID_QUERY_MODE",
|
||||
)
|
||||
|
||||
if query_mode == "raw" and not config.get("columns"):
|
||||
return False, ChartGenerationError(
|
||||
error_type="missing_raw_columns",
|
||||
message="Handlebars chart in 'raw' mode requires 'columns'",
|
||||
details="When query_mode is 'raw', you must specify which columns "
|
||||
"to include in the query results",
|
||||
suggestions=[
|
||||
"Add 'columns': [{'name': 'column_name'}] for raw mode",
|
||||
"Or use query_mode='aggregate' with 'metrics' "
|
||||
"and optional 'groupby'",
|
||||
],
|
||||
error_code="MISSING_RAW_COLUMNS",
|
||||
)
|
||||
|
||||
if query_mode == "aggregate" and not config.get("metrics"):
|
||||
return False, ChartGenerationError(
|
||||
error_type="missing_aggregate_metrics",
|
||||
message="Handlebars chart in 'aggregate' mode requires 'metrics'",
|
||||
details="When query_mode is 'aggregate' (default), you must specify "
|
||||
"at least one metric with an aggregate function",
|
||||
suggestions=[
|
||||
"Add 'metrics': [{'name': 'column', 'aggregate': 'SUM'}]",
|
||||
"Or use query_mode='raw' with 'columns' for individual rows",
|
||||
],
|
||||
error_code="MISSING_AGGREGATE_METRICS",
|
||||
)
|
||||
|
||||
return True, None
|
||||
|
||||
@staticmethod
|
||||
def _pre_validate_pivot_table_config(
|
||||
config: Dict[str, Any],
|
||||
@@ -428,6 +508,24 @@ class SchemaValidator:
|
||||
],
|
||||
error_code="TABLE_VALIDATION_ERROR",
|
||||
)
|
||||
elif chart_type == "handlebars":
|
||||
return ChartGenerationError(
|
||||
error_type="handlebars_validation_error",
|
||||
message="Handlebars chart configuration validation failed",
|
||||
details="The handlebars chart configuration is missing "
|
||||
"required fields or has invalid structure",
|
||||
suggestions=[
|
||||
"Ensure 'handlebars_template' is a non-empty string",
|
||||
"For aggregate mode: add 'metrics' with aggregate "
|
||||
"functions",
|
||||
"For raw mode: set 'query_mode': 'raw' and add 'columns'",
|
||||
"Example: {'chart_type': 'handlebars', "
|
||||
"'handlebars_template': '<ul>{{#each data}}<li>"
|
||||
"{{this.name}}</li>{{/each}}</ul>', "
|
||||
"'metrics': [{'name': 'sales', 'aggregate': 'SUM'}]}",
|
||||
],
|
||||
error_code="HANDLEBARS_VALIDATION_ERROR",
|
||||
)
|
||||
|
||||
# Default enhanced error
|
||||
error_details = []
|
||||
|
||||
565
tests/unit_tests/mcp_service/chart/test_handlebars_chart.py
Normal file
565
tests/unit_tests/mcp_service/chart/test_handlebars_chart.py
Normal file
@@ -0,0 +1,565 @@
|
||||
# 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.
|
||||
|
||||
"""Tests for Handlebars chart type support in MCP service."""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from superset.mcp_service.chart.chart_utils import (
|
||||
_resolve_viz_type,
|
||||
analyze_chart_capabilities,
|
||||
analyze_chart_semantics,
|
||||
generate_chart_name,
|
||||
map_config_to_form_data,
|
||||
map_handlebars_config,
|
||||
)
|
||||
from superset.mcp_service.chart.schemas import (
|
||||
ColumnRef,
|
||||
FilterConfig,
|
||||
HandlebarsChartConfig,
|
||||
)
|
||||
from superset.mcp_service.chart.validation.schema_validator import SchemaValidator
|
||||
|
||||
|
||||
class TestHandlebarsChartConfig:
|
||||
"""Test HandlebarsChartConfig Pydantic schema."""
|
||||
|
||||
def test_minimal_aggregate_config(self) -> None:
|
||||
template = "<ul>{{#each data}}<li>{{this.name}}</li>{{/each}}</ul>"
|
||||
config = HandlebarsChartConfig(
|
||||
chart_type="handlebars",
|
||||
handlebars_template=template,
|
||||
metrics=[ColumnRef(name="sales", aggregate="SUM")],
|
||||
)
|
||||
assert config.chart_type == "handlebars"
|
||||
assert config.query_mode == "aggregate"
|
||||
assert config.row_limit == 1000
|
||||
|
||||
def test_minimal_raw_config(self) -> None:
|
||||
template = (
|
||||
"<table>{{#each data}}<tr><td>{{this.name}}</td></tr>{{/each}}</table>"
|
||||
)
|
||||
config = HandlebarsChartConfig(
|
||||
chart_type="handlebars",
|
||||
handlebars_template=template,
|
||||
query_mode="raw",
|
||||
columns=[ColumnRef(name="product"), ColumnRef(name="price")],
|
||||
)
|
||||
assert config.query_mode == "raw"
|
||||
assert config.columns is not None
|
||||
assert len(config.columns) == 2
|
||||
|
||||
def test_aggregate_mode_requires_metrics(self) -> None:
|
||||
with pytest.raises(ValueError, match="requires 'metrics'"):
|
||||
HandlebarsChartConfig(
|
||||
chart_type="handlebars",
|
||||
handlebars_template="<p>test</p>",
|
||||
query_mode="aggregate",
|
||||
)
|
||||
|
||||
def test_raw_mode_requires_columns(self) -> None:
|
||||
with pytest.raises(ValueError, match="requires 'columns'"):
|
||||
HandlebarsChartConfig(
|
||||
chart_type="handlebars",
|
||||
handlebars_template="<p>test</p>",
|
||||
query_mode="raw",
|
||||
)
|
||||
|
||||
def test_template_min_length(self) -> None:
|
||||
with pytest.raises(ValueError, match="at least 1 character"):
|
||||
HandlebarsChartConfig(
|
||||
chart_type="handlebars",
|
||||
handlebars_template="",
|
||||
metrics=[ColumnRef(name="sales", aggregate="SUM")],
|
||||
)
|
||||
|
||||
def test_raw_mode_rejects_metrics(self) -> None:
|
||||
with pytest.raises(ValueError, match="does not use 'metrics'"):
|
||||
HandlebarsChartConfig(
|
||||
chart_type="handlebars",
|
||||
handlebars_template="<p>test</p>",
|
||||
query_mode="raw",
|
||||
columns=[ColumnRef(name="product")],
|
||||
metrics=[ColumnRef(name="sales", aggregate="SUM")],
|
||||
)
|
||||
|
||||
def test_raw_mode_rejects_groupby(self) -> None:
|
||||
with pytest.raises(ValueError, match="does not use 'groupby'"):
|
||||
HandlebarsChartConfig(
|
||||
chart_type="handlebars",
|
||||
handlebars_template="<p>test</p>",
|
||||
query_mode="raw",
|
||||
columns=[ColumnRef(name="product")],
|
||||
groupby=[ColumnRef(name="region")],
|
||||
)
|
||||
|
||||
def test_aggregate_mode_requires_aggregate_function(self) -> None:
|
||||
with pytest.raises(ValueError, match="Missing aggregate for"):
|
||||
HandlebarsChartConfig(
|
||||
chart_type="handlebars",
|
||||
handlebars_template="<p>test</p>",
|
||||
query_mode="aggregate",
|
||||
metrics=[ColumnRef(name="sales")],
|
||||
)
|
||||
|
||||
def test_extra_fields_forbidden(self) -> None:
|
||||
with pytest.raises(ValueError, match="Extra inputs"):
|
||||
HandlebarsChartConfig(
|
||||
chart_type="handlebars",
|
||||
handlebars_template="<p>test</p>",
|
||||
metrics=[ColumnRef(name="sales", aggregate="SUM")],
|
||||
unknown_field="bad",
|
||||
)
|
||||
|
||||
def test_full_aggregate_config(self) -> None:
|
||||
template = (
|
||||
"<div>{{#each data}}<span>"
|
||||
"{{this.region}}: {{this.total}}"
|
||||
"</span>{{/each}}</div>"
|
||||
)
|
||||
config = HandlebarsChartConfig(
|
||||
chart_type="handlebars",
|
||||
handlebars_template=template,
|
||||
query_mode="aggregate",
|
||||
groupby=[ColumnRef(name="region")],
|
||||
metrics=[ColumnRef(name="sales", aggregate="SUM", label="total")],
|
||||
filters=[FilterConfig(column="status", op="=", value="active")],
|
||||
row_limit=500,
|
||||
order_desc=False,
|
||||
style_template="div { color: blue; }",
|
||||
)
|
||||
assert config.row_limit == 500
|
||||
assert config.order_desc is False
|
||||
assert config.style_template == "div { color: blue; }"
|
||||
assert config.filters is not None
|
||||
assert len(config.filters) == 1
|
||||
assert config.groupby is not None
|
||||
assert len(config.groupby) == 1
|
||||
|
||||
def test_row_limit_too_low(self) -> None:
|
||||
with pytest.raises(ValueError, match="greater than or equal to 1"):
|
||||
HandlebarsChartConfig(
|
||||
chart_type="handlebars",
|
||||
handlebars_template="<p>test</p>",
|
||||
metrics=[ColumnRef(name="x", aggregate="COUNT")],
|
||||
row_limit=0,
|
||||
)
|
||||
|
||||
def test_row_limit_too_high(self) -> None:
|
||||
with pytest.raises(ValueError, match="less than or equal to 50000"):
|
||||
HandlebarsChartConfig(
|
||||
chart_type="handlebars",
|
||||
handlebars_template="<p>test</p>",
|
||||
metrics=[ColumnRef(name="x", aggregate="COUNT")],
|
||||
row_limit=50001,
|
||||
)
|
||||
|
||||
|
||||
class TestMapHandlebarsConfig:
|
||||
"""Test map_handlebars_config function."""
|
||||
|
||||
def test_aggregate_mode_basic(self) -> None:
|
||||
config = HandlebarsChartConfig(
|
||||
chart_type="handlebars",
|
||||
handlebars_template="<p>{{data}}</p>",
|
||||
metrics=[ColumnRef(name="sales", aggregate="SUM")],
|
||||
)
|
||||
result = map_handlebars_config(config)
|
||||
|
||||
assert result["viz_type"] == "handlebars"
|
||||
assert result["handlebars_template"] == "<p>{{data}}</p>"
|
||||
assert result["query_mode"] == "aggregate"
|
||||
assert result["row_limit"] == 1000
|
||||
assert result["order_desc"] is True
|
||||
assert len(result["metrics"]) == 1
|
||||
assert result["metrics"][0]["aggregate"] == "SUM"
|
||||
|
||||
def test_aggregate_mode_with_groupby(self) -> None:
|
||||
config = HandlebarsChartConfig(
|
||||
chart_type="handlebars",
|
||||
handlebars_template="<p>{{data}}</p>",
|
||||
groupby=[ColumnRef(name="region")],
|
||||
metrics=[ColumnRef(name="sales", aggregate="SUM")],
|
||||
)
|
||||
result = map_handlebars_config(config)
|
||||
|
||||
assert result["groupby"] == ["region"]
|
||||
|
||||
def test_raw_mode(self) -> None:
|
||||
config = HandlebarsChartConfig(
|
||||
chart_type="handlebars",
|
||||
handlebars_template="<table>rows</table>",
|
||||
query_mode="raw",
|
||||
columns=[ColumnRef(name="product"), ColumnRef(name="price")],
|
||||
)
|
||||
result = map_handlebars_config(config)
|
||||
|
||||
assert result["query_mode"] == "raw"
|
||||
assert result["all_columns"] == ["product", "price"]
|
||||
assert "metrics" not in result
|
||||
assert "groupby" not in result
|
||||
|
||||
def test_with_filters(self) -> None:
|
||||
config = HandlebarsChartConfig(
|
||||
chart_type="handlebars",
|
||||
handlebars_template="<p>test</p>",
|
||||
metrics=[ColumnRef(name="count", aggregate="COUNT")],
|
||||
filters=[FilterConfig(column="status", op="=", value="active")],
|
||||
)
|
||||
result = map_handlebars_config(config)
|
||||
|
||||
assert "adhoc_filters" in result
|
||||
assert len(result["adhoc_filters"]) == 1
|
||||
assert result["adhoc_filters"][0]["subject"] == "status"
|
||||
assert result["adhoc_filters"][0]["operator"] == "=="
|
||||
assert result["adhoc_filters"][0]["comparator"] == "active"
|
||||
|
||||
def test_with_style_template(self) -> None:
|
||||
config = HandlebarsChartConfig(
|
||||
chart_type="handlebars",
|
||||
handlebars_template="<p>test</p>",
|
||||
metrics=[ColumnRef(name="x", aggregate="COUNT")],
|
||||
style_template="p { font-size: 24px; }",
|
||||
)
|
||||
result = map_handlebars_config(config)
|
||||
|
||||
assert result["styleTemplate"] == "p { font-size: 24px; }"
|
||||
|
||||
def test_without_style_template(self) -> None:
|
||||
config = HandlebarsChartConfig(
|
||||
chart_type="handlebars",
|
||||
handlebars_template="<p>test</p>",
|
||||
metrics=[ColumnRef(name="x", aggregate="COUNT")],
|
||||
)
|
||||
result = map_handlebars_config(config)
|
||||
|
||||
assert "styleTemplate" not in result
|
||||
|
||||
def test_custom_row_limit_and_order(self) -> None:
|
||||
config = HandlebarsChartConfig(
|
||||
chart_type="handlebars",
|
||||
handlebars_template="<p>test</p>",
|
||||
metrics=[ColumnRef(name="x", aggregate="COUNT")],
|
||||
row_limit=50,
|
||||
order_desc=False,
|
||||
)
|
||||
result = map_handlebars_config(config)
|
||||
|
||||
assert result["row_limit"] == 50
|
||||
assert result["order_desc"] is False
|
||||
|
||||
|
||||
class TestMapConfigToFormDataHandlebars:
|
||||
"""Test map_config_to_form_data dispatches to handlebars correctly."""
|
||||
|
||||
def test_dispatches_handlebars_config(self) -> None:
|
||||
config = HandlebarsChartConfig(
|
||||
chart_type="handlebars",
|
||||
handlebars_template="<p>test</p>",
|
||||
metrics=[ColumnRef(name="sales", aggregate="SUM")],
|
||||
)
|
||||
result = map_config_to_form_data(config)
|
||||
assert result["viz_type"] == "handlebars"
|
||||
|
||||
|
||||
class TestGenerateChartNameHandlebars:
|
||||
"""Test generate_chart_name for handlebars configs."""
|
||||
|
||||
def test_raw_mode_with_columns(self) -> None:
|
||||
config = HandlebarsChartConfig(
|
||||
chart_type="handlebars",
|
||||
handlebars_template="<p>test</p>",
|
||||
query_mode="raw",
|
||||
columns=[ColumnRef(name="product"), ColumnRef(name="price")],
|
||||
)
|
||||
name = generate_chart_name(config)
|
||||
assert "Handlebars" in name
|
||||
assert "product" in name
|
||||
assert "price" in name
|
||||
|
||||
def test_aggregate_mode_with_metrics(self) -> None:
|
||||
config = HandlebarsChartConfig(
|
||||
chart_type="handlebars",
|
||||
handlebars_template="<p>test</p>",
|
||||
metrics=[ColumnRef(name="sales", aggregate="SUM")],
|
||||
)
|
||||
name = generate_chart_name(config)
|
||||
assert "Handlebars" in name
|
||||
assert "sales" in name
|
||||
|
||||
def test_aggregate_mode_no_groupby(self) -> None:
|
||||
config = HandlebarsChartConfig(
|
||||
chart_type="handlebars",
|
||||
handlebars_template="<p>test</p>",
|
||||
metrics=[ColumnRef(name="count", aggregate="COUNT")],
|
||||
)
|
||||
name = generate_chart_name(config)
|
||||
assert "Handlebars" in name
|
||||
assert "count" in name
|
||||
|
||||
def test_truncates_to_three_columns(self) -> None:
|
||||
config = HandlebarsChartConfig(
|
||||
chart_type="handlebars",
|
||||
handlebars_template="<p>test</p>",
|
||||
query_mode="raw",
|
||||
columns=[
|
||||
ColumnRef(name="alpha"),
|
||||
ColumnRef(name="bravo"),
|
||||
ColumnRef(name="charlie"),
|
||||
ColumnRef(name="delta"),
|
||||
],
|
||||
)
|
||||
name = generate_chart_name(config)
|
||||
assert "alpha" in name
|
||||
assert "bravo" in name
|
||||
assert "charlie" in name
|
||||
assert "delta" not in name
|
||||
|
||||
|
||||
class TestResolveVizType:
|
||||
"""Test _resolve_viz_type helper."""
|
||||
|
||||
def test_xy_line(self) -> None:
|
||||
config = MagicMock(chart_type="xy", kind="line")
|
||||
assert _resolve_viz_type(config) == "echarts_timeseries_line"
|
||||
|
||||
def test_xy_bar(self) -> None:
|
||||
config = MagicMock(chart_type="xy", kind="bar")
|
||||
assert _resolve_viz_type(config) == "echarts_timeseries_bar"
|
||||
|
||||
def test_xy_area(self) -> None:
|
||||
config = MagicMock(chart_type="xy", kind="area")
|
||||
assert _resolve_viz_type(config) == "echarts_area"
|
||||
|
||||
def test_xy_scatter(self) -> None:
|
||||
config = MagicMock(chart_type="xy", kind="scatter")
|
||||
assert _resolve_viz_type(config) == "echarts_timeseries_scatter"
|
||||
|
||||
def test_table(self) -> None:
|
||||
config = MagicMock(chart_type="table", viz_type="table")
|
||||
assert _resolve_viz_type(config) == "table"
|
||||
|
||||
def test_ag_grid_table(self) -> None:
|
||||
config = MagicMock(chart_type="table", viz_type="ag-grid-table")
|
||||
assert _resolve_viz_type(config) == "ag-grid-table"
|
||||
|
||||
def test_handlebars(self) -> None:
|
||||
config = MagicMock(chart_type="handlebars")
|
||||
assert _resolve_viz_type(config) == "handlebars"
|
||||
|
||||
def test_unknown(self) -> None:
|
||||
config = MagicMock(chart_type="unknown_type")
|
||||
assert _resolve_viz_type(config) == "unknown"
|
||||
|
||||
|
||||
class TestAnalyzeChartCapabilitiesHandlebars:
|
||||
"""Test analyze_chart_capabilities for handlebars config."""
|
||||
|
||||
def test_handlebars_capabilities(self) -> None:
|
||||
config = HandlebarsChartConfig(
|
||||
chart_type="handlebars",
|
||||
handlebars_template="<p>test</p>",
|
||||
metrics=[ColumnRef(name="sales", aggregate="SUM")],
|
||||
)
|
||||
caps = analyze_chart_capabilities(None, config)
|
||||
|
||||
assert caps.supports_interaction is False
|
||||
assert caps.supports_drill_down is False
|
||||
assert caps.supports_export is True
|
||||
assert "url" in caps.optimal_formats
|
||||
|
||||
|
||||
class TestAnalyzeChartSemanticsHandlebars:
|
||||
"""Test analyze_chart_semantics for handlebars config."""
|
||||
|
||||
def test_handlebars_semantics(self) -> None:
|
||||
config = HandlebarsChartConfig(
|
||||
chart_type="handlebars",
|
||||
handlebars_template="<p>test</p>",
|
||||
metrics=[ColumnRef(name="sales", aggregate="SUM")],
|
||||
)
|
||||
semantics = analyze_chart_semantics(None, config)
|
||||
|
||||
assert (
|
||||
"Handlebars" in semantics.primary_insight
|
||||
or "template" in semantics.primary_insight
|
||||
)
|
||||
assert semantics.data_story is not None
|
||||
|
||||
|
||||
class TestSchemaValidatorHandlebars:
|
||||
"""Test SchemaValidator pre-validation for handlebars chart type."""
|
||||
|
||||
def test_handlebars_accepted_as_valid_chart_type(self) -> None:
|
||||
data = {
|
||||
"dataset_id": 1,
|
||||
"config": {
|
||||
"chart_type": "handlebars",
|
||||
"handlebars_template": "<p>{{data}}</p>",
|
||||
"metrics": [{"name": "sales", "aggregate": "SUM"}],
|
||||
},
|
||||
}
|
||||
is_valid, request, error = SchemaValidator.validate_request(data)
|
||||
assert is_valid is True
|
||||
assert request is not None
|
||||
assert error is None
|
||||
|
||||
def test_missing_handlebars_template(self) -> None:
|
||||
data = {
|
||||
"dataset_id": 1,
|
||||
"config": {
|
||||
"chart_type": "handlebars",
|
||||
"metrics": [{"name": "sales", "aggregate": "SUM"}],
|
||||
},
|
||||
}
|
||||
is_valid, _, error = SchemaValidator.validate_request(data)
|
||||
assert is_valid is False
|
||||
assert error is not None
|
||||
assert error.error_code == "MISSING_HANDLEBARS_TEMPLATE"
|
||||
|
||||
def test_empty_handlebars_template(self) -> None:
|
||||
data = {
|
||||
"dataset_id": 1,
|
||||
"config": {
|
||||
"chart_type": "handlebars",
|
||||
"handlebars_template": " ",
|
||||
"metrics": [{"name": "sales", "aggregate": "SUM"}],
|
||||
},
|
||||
}
|
||||
is_valid, _, error = SchemaValidator.validate_request(data)
|
||||
assert is_valid is False
|
||||
assert error is not None
|
||||
assert error.error_code == "INVALID_HANDLEBARS_TEMPLATE"
|
||||
|
||||
def test_non_string_handlebars_template(self) -> None:
|
||||
data = {
|
||||
"dataset_id": 1,
|
||||
"config": {
|
||||
"chart_type": "handlebars",
|
||||
"handlebars_template": 123,
|
||||
"metrics": [{"name": "sales", "aggregate": "SUM"}],
|
||||
},
|
||||
}
|
||||
is_valid, _, error = SchemaValidator.validate_request(data)
|
||||
assert is_valid is False
|
||||
assert error is not None
|
||||
assert error.error_code == "INVALID_HANDLEBARS_TEMPLATE"
|
||||
|
||||
def test_invalid_query_mode_rejected(self) -> None:
|
||||
data = {
|
||||
"dataset_id": 1,
|
||||
"config": {
|
||||
"chart_type": "handlebars",
|
||||
"handlebars_template": "<p>test</p>",
|
||||
"query_mode": "invalid",
|
||||
},
|
||||
}
|
||||
is_valid, _, error = SchemaValidator.validate_request(data)
|
||||
assert is_valid is False
|
||||
assert error is not None
|
||||
assert error.error_code == "INVALID_QUERY_MODE"
|
||||
|
||||
def test_raw_mode_missing_columns(self) -> None:
|
||||
data = {
|
||||
"dataset_id": 1,
|
||||
"config": {
|
||||
"chart_type": "handlebars",
|
||||
"handlebars_template": "<p>test</p>",
|
||||
"query_mode": "raw",
|
||||
},
|
||||
}
|
||||
is_valid, _, error = SchemaValidator.validate_request(data)
|
||||
assert is_valid is False
|
||||
assert error is not None
|
||||
assert error.error_code == "MISSING_RAW_COLUMNS"
|
||||
|
||||
def test_aggregate_mode_missing_metrics(self) -> None:
|
||||
data = {
|
||||
"dataset_id": 1,
|
||||
"config": {
|
||||
"chart_type": "handlebars",
|
||||
"handlebars_template": "<p>test</p>",
|
||||
"query_mode": "aggregate",
|
||||
},
|
||||
}
|
||||
is_valid, _, error = SchemaValidator.validate_request(data)
|
||||
assert is_valid is False
|
||||
assert error is not None
|
||||
assert error.error_code == "MISSING_AGGREGATE_METRICS"
|
||||
|
||||
def test_default_aggregate_mode_missing_metrics(self) -> None:
|
||||
data = {
|
||||
"dataset_id": 1,
|
||||
"config": {
|
||||
"chart_type": "handlebars",
|
||||
"handlebars_template": "<p>test</p>",
|
||||
},
|
||||
}
|
||||
is_valid, _, error = SchemaValidator.validate_request(data)
|
||||
assert is_valid is False
|
||||
assert error is not None
|
||||
assert error.error_code == "MISSING_AGGREGATE_METRICS"
|
||||
|
||||
def test_handlebars_raw_mode_valid(self) -> None:
|
||||
data = {
|
||||
"dataset_id": 1,
|
||||
"config": {
|
||||
"chart_type": "handlebars",
|
||||
"handlebars_template": "<p>{{#each data}}{{this.name}}{{/each}}</p>",
|
||||
"query_mode": "raw",
|
||||
"columns": [{"name": "product"}, {"name": "price"}],
|
||||
},
|
||||
}
|
||||
is_valid, request, error = SchemaValidator.validate_request(data)
|
||||
assert is_valid is True
|
||||
assert request is not None
|
||||
assert error is None
|
||||
|
||||
def test_non_string_chart_type_rejected(self) -> None:
|
||||
data = {
|
||||
"dataset_id": 1,
|
||||
"config": {"chart_type": ["handlebars"]},
|
||||
}
|
||||
is_valid, _, error = SchemaValidator.validate_request(data)
|
||||
assert is_valid is False
|
||||
assert error is not None
|
||||
assert error.error_code == "INVALID_CHART_TYPE"
|
||||
|
||||
def test_handlebars_in_error_messages(self) -> None:
|
||||
"""Verify 'handlebars' appears in missing chart_type suggestions."""
|
||||
data = {
|
||||
"dataset_id": 1,
|
||||
"config": {},
|
||||
}
|
||||
is_valid, _, error = SchemaValidator.validate_request(data)
|
||||
assert is_valid is False
|
||||
assert error is not None
|
||||
suggestions_text = " ".join(error.suggestions or [])
|
||||
assert "handlebars" in suggestions_text
|
||||
|
||||
def test_invalid_chart_type_mentions_handlebars(self) -> None:
|
||||
"""Invalid chart_type error should mention handlebars as option."""
|
||||
data = {
|
||||
"dataset_id": 1,
|
||||
"config": {"chart_type": "invalid"},
|
||||
}
|
||||
is_valid, _, error = SchemaValidator.validate_request(data)
|
||||
assert is_valid is False
|
||||
assert error is not None
|
||||
assert "handlebars" in (error.details or "")
|
||||
Reference in New Issue
Block a user