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:
Amin Ghadersohi
2026-03-24 12:25:39 -04:00
committed by GitHub
parent 6852349d24
commit c596df9294
5 changed files with 864 additions and 27 deletions

View File

@@ -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)

View File

@@ -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(

View File

@@ -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'"
),
),
]

View File

@@ -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 = []

View 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 "")