You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by mi...@apache.org on 2026/03/31 15:54:05 UTC
(superset) branch master updated: feat(mcp): add Big Number chart type support to MCP service (#38403)
This is an automated email from the ASF dual-hosted git repository.
misterg pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git
The following commit(s) were added to refs/heads/master by this push:
new 42457208512 feat(mcp): add Big Number chart type support to MCP service (#38403)
42457208512 is described below
commit 4245720851240a896556670004b2bc6171d5d060
Author: Amin Ghadersohi <am...@gmail.com>
AuthorDate: Tue Mar 31 17:53:53 2026 +0200
feat(mcp): add Big Number chart type support to MCP service (#38403)
Co-authored-by: Claude Opus 4.6 <no...@anthropic.com>
---
superset/mcp_service/app.py | 3 +
superset/mcp_service/chart/chart_utils.py | 88 +++-
superset/mcp_service/chart/preview_utils.py | 12 +-
superset/mcp_service/chart/schemas.py | 113 ++++-
superset/mcp_service/chart/tool/generate_chart.py | 12 +-
.../chart/validation/schema_validator.py | 89 ++++
.../mcp_service/chart/test_big_number_chart.py | 524 +++++++++++++++++++++
7 files changed, 835 insertions(+), 6 deletions(-)
diff --git a/superset/mcp_service/app.py b/superset/mcp_service/app.py
index 3fb388f8aaf..4b05b5d7184 100644
--- a/superset/mcp_service/app.py
+++ b/superset/mcp_service/app.py
@@ -118,6 +118,9 @@ Chart Types You Can CREATE with generate_chart/generate_explore_link:
- chart_type="xy", kind="bar": Bar chart for category comparison
- chart_type="xy", kind="area": Area chart for volume visualization
- chart_type="xy", kind="scatter": Scatter plot for correlation analysis
+- chart_type="big_number": Big Number display (single metric, header only)
+- chart_type="big_number", show_trendline=True,
+ temporal_column="<date_col>": Big Number with trendline
- chart_type="table": Data table for detailed views
- chart_type="table", viz_type="ag-grid-table": Interactive AG Grid table
- chart_type="pie": Pie chart for proportional data (set donut=True for donut)
diff --git a/superset/mcp_service/chart/chart_utils.py b/superset/mcp_service/chart/chart_utils.py
index 0a4b19b0cee..62704a0b5ef 100644
--- a/superset/mcp_service/chart/chart_utils.py
+++ b/superset/mcp_service/chart/chart_utils.py
@@ -27,6 +27,7 @@ from dataclasses import dataclass
from typing import Any, Dict
from superset.mcp_service.chart.schemas import (
+ BigNumberChartConfig,
ChartCapabilities,
ChartSemantics,
ColumnRef,
@@ -311,7 +312,8 @@ def map_config_to_form_data(
| PieChartConfig
| PivotTableChartConfig
| MixedTimeseriesChartConfig
- | HandlebarsChartConfig,
+ | HandlebarsChartConfig
+ | BigNumberChartConfig,
dataset_id: int | str | None = None,
) -> Dict[str, Any]:
"""Map chart config to Superset form_data."""
@@ -327,6 +329,14 @@ def map_config_to_form_data(
return map_mixed_timeseries_config(config, dataset_id=dataset_id)
elif isinstance(config, HandlebarsChartConfig):
return map_handlebars_config(config)
+ elif isinstance(config, BigNumberChartConfig):
+ if config.show_trendline and config.temporal_column:
+ if not is_column_truly_temporal(config.temporal_column, dataset_id):
+ raise ValueError(
+ f"Big Number trendline requires a temporal SQL column; "
+ f"'{config.temporal_column}' is not temporal."
+ )
+ return map_big_number_config(config)
else:
raise ValueError(f"Unsupported config type: {type(config)}")
@@ -650,6 +660,46 @@ def map_pie_config(config: PieChartConfig) -> Dict[str, Any]:
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
+
+ # 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
+
+ _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] = {
@@ -975,13 +1025,31 @@ def _handlebars_chart_what(config: HandlebarsChartConfig) -> str:
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.aggregate:
+ metric_label = f"{config.metric.aggregate}({config.metric.name})"
+ else:
+ metric_label = config.metric.name
+ if config.show_trendline:
+ return f"Big Number ({metric_label}, trendline)"
+ return f"Big Number ({metric_label})"
+
+
def generate_chart_name(
config: TableChartConfig
| XYChartConfig
| PieChartConfig
| PivotTableChartConfig
| MixedTimeseriesChartConfig
- | HandlebarsChartConfig,
+ | HandlebarsChartConfig
+ | BigNumberChartConfig,
dataset_name: str | None = None,
) -> str:
"""Generate a descriptive chart name following a standard format.
@@ -1015,6 +1083,9 @@ def generate_chart_name(
elif isinstance(config, HandlebarsChartConfig):
what = _handlebars_chart_what(config)
context = _summarize_filters(getattr(config, "filters", None))
+ elif isinstance(config, BigNumberChartConfig):
+ what = _big_number_chart_what(config)
+ context = _summarize_filters(getattr(config, "filters", None))
else:
return "Chart"
@@ -1046,6 +1117,12 @@ def _resolve_viz_type(config: Any) -> str:
return "mixed_timeseries"
elif chart_type == "handlebars":
return "handlebars"
+ elif chart_type == "big_number":
+ show_trendline = getattr(config, "show_trendline", False)
+ temporal_column = getattr(config, "temporal_column", None)
+ return (
+ "big_number" if show_trendline and temporal_column else "big_number_total"
+ )
return "unknown"
@@ -1129,6 +1206,13 @@ def analyze_chart_semantics(chart: Any | None, config: Any) -> ChartSemantics:
"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(
diff --git a/superset/mcp_service/chart/preview_utils.py b/superset/mcp_service/chart/preview_utils.py
index dcc8642f000..d46865c9d13 100644
--- a/superset/mcp_service/chart/preview_utils.py
+++ b/superset/mcp_service/chart/preview_utils.py
@@ -92,13 +92,23 @@ def generate_preview_from_form_data(
query_filters = adhoc_filters_to_query_filters(
form_data.get("adhoc_filters", [])
)
+
+ # Big Number charts use singular "metric" instead of "metrics"
+ metrics = form_data.get("metrics", [])
+ if not metrics and form_data.get("metric"):
+ metrics = [form_data["metric"]]
+
+ # Big Number with trendline uses granularity_sqla as the time column
+ if not columns and form_data.get("granularity_sqla"):
+ columns = [form_data["granularity_sqla"]]
+
factory = QueryContextFactory()
query_context_obj = factory.create(
datasource={"id": dataset_id, "type": "table"},
queries=[
{
"columns": columns,
- "metrics": form_data.get("metrics", []),
+ "metrics": metrics,
"orderby": form_data.get("orderby", []),
"row_limit": form_data.get("row_limit", 100),
"filters": query_filters,
diff --git a/superset/mcp_service/chart/schemas.py b/superset/mcp_service/chart/schemas.py
index 2da6c3ef804..ce7d1ea397f 100644
--- a/superset/mcp_service/chart/schemas.py
+++ b/superset/mcp_service/chart/schemas.py
@@ -36,6 +36,7 @@ from pydantic import (
model_validator,
PositiveInt,
)
+from typing_extensions import Self
from superset.constants import TimeGrain
from superset.daos.base import ColumnOperator, ColumnOperatorEnum
@@ -851,6 +852,112 @@ class HandlebarsChartConfig(UnknownFieldCheckMixin):
return self
+class BigNumberChartConfig(UnknownFieldCheckMixin):
+ model_config = ConfigDict(extra="ignore")
+
+ chart_type: Literal["big_number"] = Field(
+ ...,
+ description=(
+ "Chart type discriminator - MUST be 'big_number'. "
+ "Creates Big Number charts that display a single prominent "
+ "metric value. Set show_trendline=True with a temporal_column "
+ "for a number with trendline, or leave show_trendline=False "
+ "for a standalone number."
+ ),
+ )
+ metric: ColumnRef = Field(
+ ...,
+ description=(
+ "The metric to display as a big number. "
+ "Must include an aggregate function (e.g., SUM, COUNT)."
+ ),
+ )
+ temporal_column: str | None = Field(
+ None,
+ description=(
+ "Temporal column for the trendline x-axis. "
+ "Required when show_trendline is True."
+ ),
+ min_length=1,
+ max_length=255,
+ pattern=r"^[a-zA-Z0-9_][a-zA-Z0-9_\s\-\.]*$",
+ )
+ time_grain: TimeGrain | None = Field(
+ None,
+ description=(
+ "Time granularity for trendline data. "
+ "Common values: PT1H (hour), P1D (day), P1W (week), "
+ "P1M (month), P1Y (year)."
+ ),
+ )
+ show_trendline: bool = Field(
+ False,
+ description=(
+ "Show a trendline below the big number. "
+ "Requires 'temporal_column' to be set."
+ ),
+ )
+ subheader: str | None = Field(
+ None,
+ description="Subtitle text displayed below the big number",
+ max_length=500,
+ )
+ y_axis_format: str | None = Field(
+ None,
+ description=(
+ "Number format string for the metric value "
+ "(e.g., '$,.2f' for currency, ',.0f' for integers, "
+ "'.2%' for percentages)"
+ ),
+ max_length=50,
+ )
+ start_y_axis_at_zero: bool = Field(
+ True,
+ description="Anchor trendline y-axis at zero",
+ )
+ compare_lag: int | None = Field(
+ None,
+ description=(
+ "Number of time periods to compare against. "
+ "Displays a percentage change vs the prior period."
+ ),
+ ge=1,
+ )
+ filters: list[FilterConfig] | None = Field(
+ None,
+ description="Filters to apply",
+ )
+
+ @model_validator(mode="after")
+ def validate_trendline_fields(self) -> Self:
+ """Validate trendline requires temporal column."""
+ if self.show_trendline and not self.temporal_column:
+ raise ValueError(
+ "Big Number chart with show_trendline=True requires "
+ "'temporal_column'. Specify a date/time column for "
+ "the trendline x-axis."
+ )
+ if self.compare_lag and not self.show_trendline:
+ raise ValueError(
+ "compare_lag requires show_trendline=True. "
+ "Period comparison is only available for "
+ "trendline charts."
+ )
+ return self
+
+ @model_validator(mode="after")
+ def validate_metric_aggregate(self) -> Self:
+ """Ensure metric is a valid metric reference (aggregate or saved)."""
+ if not self.metric.is_metric:
+ raise ValueError(
+ "Big Number metric must be either a saved dataset metric "
+ "or include an aggregate function (e.g., SUM, COUNT, AVG). "
+ "Set 'saved_metric': true to use a saved metric, or add "
+ "'aggregate' to the metric specification."
+ )
+ return self
+
+
class TableChartConfig(UnknownFieldCheckMixin):
model_config = ConfigDict(extra="ignore", populate_by_name=True)
@@ -1015,12 +1122,14 @@ ChartConfig = Annotated[
| PieChartConfig
| PivotTableChartConfig
| MixedTimeseriesChartConfig
- | HandlebarsChartConfig,
+ | HandlebarsChartConfig
+ | BigNumberChartConfig,
Field(
discriminator="chart_type",
description=(
"Chart configuration - specify chart_type as 'xy', 'table', "
- "'pie', 'pivot_table', 'mixed_timeseries', or 'handlebars'"
+ "'pie', 'pivot_table', 'mixed_timeseries', 'handlebars', "
+ "or 'big_number'"
),
),
]
diff --git a/superset/mcp_service/chart/tool/generate_chart.py b/superset/mcp_service/chart/tool/generate_chart.py
index c3cc82cf7bb..095e74419b3 100644
--- a/superset/mcp_service/chart/tool/generate_chart.py
+++ b/superset/mcp_service/chart/tool/generate_chart.py
@@ -89,13 +89,23 @@ def _compile_chart(
query_filters = adhoc_filters_to_query_filters(
form_data.get("adhoc_filters", [])
)
+
+ # Big Number charts use singular "metric" instead of "metrics"
+ metrics = form_data.get("metrics", [])
+ if not metrics and form_data.get("metric"):
+ metrics = [form_data["metric"]]
+
+ # Big Number with trendline uses granularity_sqla as the time column
+ if not columns and form_data.get("granularity_sqla"):
+ columns = [form_data["granularity_sqla"]]
+
factory = QueryContextFactory()
query_context = factory.create(
datasource={"id": dataset_id, "type": "table"},
queries=[
{
"columns": columns,
- "metrics": form_data.get("metrics", []),
+ "metrics": metrics,
"orderby": form_data.get("orderby", []),
"row_limit": 2,
"filters": query_filters,
diff --git a/superset/mcp_service/chart/validation/schema_validator.py b/superset/mcp_service/chart/validation/schema_validator.py
index 07ecb2b912b..c82728b11bd 100644
--- a/superset/mcp_service/chart/validation/schema_validator.py
+++ b/superset/mcp_service/chart/validation/schema_validator.py
@@ -134,6 +134,7 @@ class SchemaValidator:
"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",
+ "Add 'chart_type': 'big_number' for big number display",
"Example: 'config': {'chart_type': 'xy', ...}",
],
error_code="MISSING_CHART_TYPE",
@@ -154,6 +155,7 @@ class SchemaValidator:
"pivot_table": SchemaValidator._pre_validate_pivot_table_config,
"mixed_timeseries": SchemaValidator._pre_validate_mixed_timeseries_config,
"handlebars": SchemaValidator._pre_validate_handlebars_config,
+ "big_number": SchemaValidator._pre_validate_big_number_config,
}
if not isinstance(chart_type, str) or chart_type not in chart_type_validators:
@@ -170,6 +172,7 @@ class SchemaValidator:
"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",
+ "Use 'chart_type': 'big_number' for big number display",
"Check spelling and ensure lowercase",
],
error_code="INVALID_CHART_TYPE",
@@ -362,6 +365,75 @@ class SchemaValidator:
return True, None
+ @staticmethod
+ def _pre_validate_big_number_config(
+ config: Dict[str, Any],
+ ) -> Tuple[bool, ChartGenerationError | None]:
+ """Pre-validate big number chart configuration."""
+ if "metric" not in config:
+ return False, ChartGenerationError(
+ error_type="missing_metric",
+ message="Big Number chart missing required field: metric",
+ details="Big Number charts require a 'metric' field "
+ "specifying the value to display",
+ suggestions=[
+ "Add 'metric' with name and aggregate: "
+ "{'name': 'revenue', 'aggregate': 'SUM'}",
+ "The aggregate function is required (SUM, COUNT, AVG, MIN, MAX)",
+ "Example: {'chart_type': 'big_number', "
+ "'metric': {'name': 'sales', 'aggregate': 'SUM'}}",
+ ],
+ error_code="MISSING_BIG_NUMBER_METRIC",
+ )
+
+ metric = config.get("metric", {})
+ if not isinstance(metric, dict):
+ return False, ChartGenerationError(
+ error_type="invalid_metric_type",
+ message="Big Number metric must be a dict with 'name' and 'aggregate'",
+ details="The 'metric' field must be an object, "
+ f"got {type(metric).__name__}",
+ suggestions=[
+ "Use a dict: {'name': 'col', 'aggregate': 'SUM'}",
+ "Valid aggregates: SUM, COUNT, AVG, MIN, MAX",
+ ],
+ error_code="INVALID_BIG_NUMBER_METRIC_TYPE",
+ )
+ if not metric.get("aggregate") and not metric.get("saved_metric"):
+ return False, ChartGenerationError(
+ error_type="missing_metric_aggregate",
+ message="Big Number metric must include an aggregate function "
+ "or reference a saved metric",
+ details="The metric must have an 'aggregate' field "
+ "or 'saved_metric': true",
+ suggestions=[
+ "Add 'aggregate' to your metric: "
+ "{'name': 'col', 'aggregate': 'SUM'}",
+ "Or use a saved metric: "
+ "{'name': 'total_sales', 'saved_metric': true}",
+ "Valid aggregates: SUM, COUNT, AVG, MIN, MAX",
+ ],
+ error_code="MISSING_BIG_NUMBER_AGGREGATE",
+ )
+
+ show_trendline = config.get("show_trendline", False)
+ temporal_column = config.get("temporal_column")
+ if show_trendline and not temporal_column:
+ return False, ChartGenerationError(
+ error_type="missing_temporal_column",
+ message="Trendline requires a temporal column",
+ details="When 'show_trendline' is True, a "
+ "'temporal_column' must be specified",
+ suggestions=[
+ "Add 'temporal_column': 'date_column_name'",
+ "Or set 'show_trendline': false for number only",
+ "Use get_dataset_info to find temporal columns",
+ ],
+ error_code="MISSING_TEMPORAL_COLUMN",
+ )
+
+ return True, None
+
@staticmethod
def _pre_validate_pivot_table_config(
config: Dict[str, Any],
@@ -526,6 +598,23 @@ class SchemaValidator:
],
error_code="HANDLEBARS_VALIDATION_ERROR",
)
+ elif chart_type == "big_number":
+ return ChartGenerationError(
+ error_type="big_number_validation_error",
+ message="Big Number chart configuration validation failed",
+ details="The Big Number chart configuration is "
+ "missing required fields or has invalid "
+ "structure",
+ suggestions=[
+ "Ensure 'metric' field has 'name' and 'aggregate'",
+ "Example: 'metric': {'name': 'revenue', "
+ "'aggregate': 'SUM'}",
+ "For trendline: add 'show_trendline': true "
+ "and 'temporal_column': 'date_col'",
+ "Without trendline: just provide the metric",
+ ],
+ error_code="BIG_NUMBER_VALIDATION_ERROR",
+ )
# Default enhanced error
error_details = []
diff --git a/tests/unit_tests/mcp_service/chart/test_big_number_chart.py b/tests/unit_tests/mcp_service/chart/test_big_number_chart.py
new file mode 100644
index 00000000000..59e142333bd
--- /dev/null
+++ b/tests/unit_tests/mcp_service/chart/test_big_number_chart.py
@@ -0,0 +1,524 @@
+# 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 Big Number chart type support in MCP service."""
+
+import pytest
+from pydantic import ValidationError
+
+from superset.mcp_service.chart.chart_utils import (
+ _resolve_viz_type,
+ analyze_chart_capabilities,
+ analyze_chart_semantics,
+ generate_chart_name,
+ map_big_number_config,
+ map_config_to_form_data,
+)
+from superset.mcp_service.chart.schemas import (
+ BigNumberChartConfig,
+ ColumnRef,
+ FilterConfig,
+)
+from superset.mcp_service.chart.validation.schema_validator import (
+ SchemaValidator,
+)
+
+
+class TestBigNumberChartConfig:
+ """Test BigNumberChartConfig Pydantic schema."""
+
+ def test_minimal_config(self) -> None:
+ config = BigNumberChartConfig(
+ chart_type="big_number",
+ metric=ColumnRef(name="revenue", aggregate="SUM"),
+ )
+ assert config.chart_type == "big_number"
+ assert config.metric.name == "revenue"
+ assert config.metric.aggregate == "SUM"
+ assert config.show_trendline is False
+
+ def test_with_trendline(self) -> None:
+ config = BigNumberChartConfig(
+ chart_type="big_number",
+ metric=ColumnRef(name="revenue", aggregate="SUM"),
+ temporal_column="ds",
+ show_trendline=True,
+ )
+ assert config.show_trendline is True
+ assert config.temporal_column == "ds"
+
+ def test_trendline_without_temporal_column_fails(self) -> None:
+ with pytest.raises(ValidationError, match="requires 'temporal_column'"):
+ BigNumberChartConfig(
+ chart_type="big_number",
+ metric=ColumnRef(name="revenue", aggregate="SUM"),
+ show_trendline=True,
+ )
+
+ def test_metric_without_aggregate_fails(self) -> None:
+ with pytest.raises(ValidationError, match="saved dataset metric"):
+ BigNumberChartConfig(
+ chart_type="big_number",
+ metric=ColumnRef(name="revenue"),
+ )
+
+ def test_saved_metric_accepted(self) -> None:
+ config = BigNumberChartConfig(
+ chart_type="big_number",
+ metric=ColumnRef(name="total_sales", saved_metric=True),
+ )
+ assert config.metric.saved_metric is True
+ assert config.metric.is_metric is True
+
+ def test_saved_metric_passes_pre_validation(self) -> None:
+ """Verify saved metrics pass through SchemaValidator pre-validation."""
+ data = {
+ "chart_type": "big_number",
+ "metric": {"name": "total_sales", "saved_metric": True},
+ }
+ is_valid, error = SchemaValidator._pre_validate_big_number_config(data)
+ assert is_valid is True
+ assert error is None
+
+ def test_with_subheader(self) -> None:
+ config = BigNumberChartConfig(
+ chart_type="big_number",
+ metric=ColumnRef(name="revenue", aggregate="SUM"),
+ subheader="Total revenue this quarter",
+ )
+ assert config.subheader == "Total revenue this quarter"
+
+ def test_with_y_axis_format(self) -> None:
+ config = BigNumberChartConfig(
+ chart_type="big_number",
+ metric=ColumnRef(name="revenue", aggregate="SUM"),
+ y_axis_format="$,.2f",
+ )
+ assert config.y_axis_format == "$,.2f"
+
+ def test_with_compare_lag(self) -> None:
+ config = BigNumberChartConfig(
+ chart_type="big_number",
+ metric=ColumnRef(name="revenue", aggregate="SUM"),
+ temporal_column="ds",
+ show_trendline=True,
+ compare_lag=1,
+ )
+ assert config.compare_lag == 1
+
+ def test_compare_lag_zero_fails(self) -> None:
+ with pytest.raises(ValidationError, match="greater than or equal"):
+ BigNumberChartConfig(
+ chart_type="big_number",
+ metric=ColumnRef(name="revenue", aggregate="SUM"),
+ compare_lag=0,
+ )
+
+ def test_compare_lag_requires_trendline(self) -> None:
+ with pytest.raises(
+ ValidationError, match="compare_lag requires show_trendline"
+ ):
+ BigNumberChartConfig(
+ chart_type="big_number",
+ metric=ColumnRef(name="revenue", aggregate="SUM"),
+ compare_lag=1,
+ )
+
+ def test_with_filters(self) -> None:
+ config = BigNumberChartConfig(
+ chart_type="big_number",
+ metric=ColumnRef(name="revenue", aggregate="SUM"),
+ filters=[
+ FilterConfig(column="region", op="=", value="US"),
+ ],
+ )
+ assert config.filters is not None
+ assert len(config.filters) == 1
+
+ def test_extra_fields_forbidden(self) -> None:
+ with pytest.raises(ValueError, match="Unknown field 'unknown_field'"):
+ BigNumberChartConfig(
+ chart_type="big_number",
+ metric=ColumnRef(name="revenue", aggregate="SUM"),
+ unknown_field="bad",
+ )
+
+ def test_with_time_grain(self) -> None:
+ config = BigNumberChartConfig(
+ chart_type="big_number",
+ metric=ColumnRef(name="revenue", aggregate="SUM"),
+ temporal_column="ds",
+ show_trendline=True,
+ time_grain="P1M",
+ )
+ assert config.time_grain == "P1M"
+
+ def test_with_custom_label(self) -> None:
+ config = BigNumberChartConfig(
+ chart_type="big_number",
+ metric=ColumnRef(name="revenue", aggregate="SUM", label="Total Sales"),
+ )
+ assert config.metric.label == "Total Sales"
+
+
+class TestMapBigNumberConfig:
+ """Test map_big_number_config function."""
+
+ def test_basic_total(self) -> None:
+ config = BigNumberChartConfig(
+ chart_type="big_number",
+ metric=ColumnRef(name="revenue", aggregate="SUM"),
+ )
+ form_data = map_big_number_config(config)
+ assert form_data["viz_type"] == "big_number_total"
+ assert form_data["metric"]["aggregate"] == "SUM"
+ assert form_data["metric"]["column"]["column_name"] == "revenue"
+
+ def test_with_trendline(self) -> None:
+ config = BigNumberChartConfig(
+ chart_type="big_number",
+ metric=ColumnRef(name="revenue", aggregate="SUM"),
+ temporal_column="order_date",
+ show_trendline=True,
+ )
+ form_data = map_big_number_config(config)
+ assert form_data["viz_type"] == "big_number"
+ assert form_data["show_trend_line"] is True
+ assert "x_axis" not in form_data
+ assert form_data["granularity_sqla"] == "order_date"
+ assert form_data["start_y_axis_at_zero"] is True
+
+ def test_with_time_grain(self) -> None:
+ config = BigNumberChartConfig(
+ chart_type="big_number",
+ metric=ColumnRef(name="revenue", aggregate="SUM"),
+ temporal_column="order_date",
+ show_trendline=True,
+ time_grain="P1M",
+ )
+ form_data = map_big_number_config(config)
+ assert form_data["time_grain_sqla"] == "P1M"
+
+ def test_with_subheader(self) -> None:
+ config = BigNumberChartConfig(
+ chart_type="big_number",
+ metric=ColumnRef(name="revenue", aggregate="SUM"),
+ subheader="Year to date",
+ )
+ form_data = map_big_number_config(config)
+ assert form_data["subheader"] == "Year to date"
+
+ def test_with_y_axis_format(self) -> None:
+ config = BigNumberChartConfig(
+ chart_type="big_number",
+ metric=ColumnRef(name="revenue", aggregate="SUM"),
+ y_axis_format="$,.2f",
+ )
+ form_data = map_big_number_config(config)
+ assert form_data["y_axis_format"] == "$,.2f"
+
+ def test_with_compare_lag(self) -> None:
+ config = BigNumberChartConfig(
+ chart_type="big_number",
+ metric=ColumnRef(name="revenue", aggregate="SUM"),
+ temporal_column="ds",
+ show_trendline=True,
+ compare_lag=7,
+ )
+ form_data = map_big_number_config(config)
+ assert form_data["compare_lag"] == 7
+
+ def test_with_filters(self) -> None:
+ config = BigNumberChartConfig(
+ chart_type="big_number",
+ metric=ColumnRef(name="revenue", aggregate="SUM"),
+ filters=[
+ FilterConfig(column="region", op="=", value="US"),
+ ],
+ )
+ form_data = map_big_number_config(config)
+ assert "adhoc_filters" in form_data
+ assert len(form_data["adhoc_filters"]) == 1
+ assert form_data["adhoc_filters"][0]["subject"] == "region"
+ assert form_data["adhoc_filters"][0]["operator"] == "=="
+ assert form_data["adhoc_filters"][0]["comparator"] == "US"
+
+ def test_no_trendline_fields_for_total(self) -> None:
+ config = BigNumberChartConfig(
+ chart_type="big_number",
+ metric=ColumnRef(name="revenue", aggregate="SUM"),
+ )
+ form_data = map_big_number_config(config)
+ assert "x_axis" not in form_data
+ assert "granularity_sqla" not in form_data
+ assert "time_grain_sqla" not in form_data
+ assert "start_y_axis_at_zero" not in form_data
+
+
+class TestMapConfigToFormDataBigNumber:
+ """Test map_config_to_form_data dispatch for big number."""
+
+ def test_dispatches_big_number(self) -> None:
+ config = BigNumberChartConfig(
+ chart_type="big_number",
+ metric=ColumnRef(name="revenue", aggregate="SUM"),
+ )
+ form_data = map_config_to_form_data(config)
+ assert form_data["viz_type"] == "big_number_total"
+
+ def test_dispatches_big_number_trendline(self) -> None:
+ config = BigNumberChartConfig(
+ chart_type="big_number",
+ metric=ColumnRef(name="revenue", aggregate="SUM"),
+ temporal_column="ds",
+ show_trendline=True,
+ )
+ form_data = map_config_to_form_data(config)
+ assert form_data["viz_type"] == "big_number"
+
+
+class TestGenerateChartNameBigNumber:
+ """Test generate_chart_name for big number configs."""
+
+ def test_basic_total_name(self) -> None:
+ config = BigNumberChartConfig(
+ chart_type="big_number",
+ metric=ColumnRef(name="revenue", aggregate="SUM"),
+ )
+ name = generate_chart_name(config)
+ assert "Big Number" in name
+ assert "SUM(revenue)" in name
+
+ def test_with_trendline_name(self) -> None:
+ config = BigNumberChartConfig(
+ chart_type="big_number",
+ metric=ColumnRef(name="revenue", aggregate="SUM"),
+ temporal_column="ds",
+ show_trendline=True,
+ )
+ name = generate_chart_name(config)
+ assert "Big Number" in name
+ assert "trendline" in name
+
+ def test_with_custom_label(self) -> None:
+ config = BigNumberChartConfig(
+ chart_type="big_number",
+ metric=ColumnRef(
+ name="revenue",
+ aggregate="SUM",
+ label="Total Sales",
+ ),
+ )
+ name = generate_chart_name(config)
+ assert "Total Sales" in name
+
+
+class TestResolveVizTypeBigNumber:
+ """Test _resolve_viz_type for big number configs."""
+
+ def test_big_number_total(self) -> None:
+ config = BigNumberChartConfig(
+ chart_type="big_number",
+ metric=ColumnRef(name="revenue", aggregate="SUM"),
+ )
+ assert _resolve_viz_type(config) == "big_number_total"
+
+ def test_big_number_with_trendline(self) -> None:
+ config = BigNumberChartConfig(
+ chart_type="big_number",
+ metric=ColumnRef(name="revenue", aggregate="SUM"),
+ temporal_column="ds",
+ show_trendline=True,
+ )
+ assert _resolve_viz_type(config) == "big_number"
+
+
+class TestAnalyzeChartCapabilitiesBigNumber:
+ """Test analyze_chart_capabilities for big number configs."""
+
+ def test_big_number_total_capabilities(self) -> None:
+ config = BigNumberChartConfig(
+ chart_type="big_number",
+ metric=ColumnRef(name="revenue", aggregate="SUM"),
+ )
+ result = analyze_chart_capabilities(None, config)
+ assert result.supports_export is True
+ assert result.supports_interaction is False
+ assert result.supports_drill_down is False
+
+ def test_big_number_trendline_capabilities(self) -> None:
+ config = BigNumberChartConfig(
+ chart_type="big_number",
+ metric=ColumnRef(name="revenue", aggregate="SUM"),
+ temporal_column="ds",
+ show_trendline=True,
+ )
+ result = analyze_chart_capabilities(None, config)
+ assert result.supports_export is True
+ assert result.supports_interaction is False
+ assert result.supports_drill_down is False
+
+
+class TestAnalyzeChartSemanticsBigNumber:
+ """Test analyze_chart_semantics for big number configs."""
+
+ def test_big_number_total_semantics(self) -> None:
+ config = BigNumberChartConfig(
+ chart_type="big_number",
+ metric=ColumnRef(name="revenue", aggregate="SUM"),
+ )
+ result = analyze_chart_semantics(None, config)
+ assert result is not None
+ assert "metric" in result.primary_insight.lower()
+ assert result.data_story != ""
+ assert len(result.recommended_actions) > 0
+
+ def test_big_number_trendline_semantics(self) -> None:
+ config = BigNumberChartConfig(
+ chart_type="big_number",
+ metric=ColumnRef(name="revenue", aggregate="SUM"),
+ temporal_column="ds",
+ show_trendline=True,
+ )
+ result = analyze_chart_semantics(None, config)
+ assert result is not None
+ assert "trend" in result.primary_insight.lower()
+ assert result.data_story != ""
+ assert len(result.recommended_actions) > 0
+
+
+class TestSchemaValidatorBigNumber:
+ """Test SchemaValidator for big number chart type."""
+
+ def test_valid_big_number_request(self) -> None:
+ data = {
+ "dataset_id": 1,
+ "config": {
+ "chart_type": "big_number",
+ "metric": {"name": "revenue", "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_valid_big_number_with_trendline(self) -> None:
+ data = {
+ "dataset_id": 1,
+ "config": {
+ "chart_type": "big_number",
+ "metric": {"name": "revenue", "aggregate": "SUM"},
+ "temporal_column": "ds",
+ "show_trendline": True,
+ },
+ }
+ is_valid, request, error = SchemaValidator.validate_request(data)
+ assert is_valid is True
+ assert request is not None
+
+ def test_missing_metric(self) -> None:
+ data = {
+ "dataset_id": 1,
+ "config": {
+ "chart_type": "big_number",
+ },
+ }
+ is_valid, request, error = SchemaValidator.validate_request(data)
+ assert is_valid is False
+ assert error is not None
+ assert error.error_code == "MISSING_BIG_NUMBER_METRIC"
+
+ def test_invalid_metric_type(self) -> None:
+ data = {
+ "dataset_id": 1,
+ "config": {
+ "chart_type": "big_number",
+ "metric": "not_a_dict",
+ },
+ }
+ is_valid, request, error = SchemaValidator.validate_request(data)
+ assert is_valid is False
+ assert error is not None
+ assert error.error_code == "INVALID_BIG_NUMBER_METRIC_TYPE"
+
+ def test_missing_metric_aggregate(self) -> None:
+ data = {
+ "dataset_id": 1,
+ "config": {
+ "chart_type": "big_number",
+ "metric": {"name": "revenue"},
+ },
+ }
+ is_valid, request, error = SchemaValidator.validate_request(data)
+ assert is_valid is False
+ assert error is not None
+ assert error.error_code == "MISSING_BIG_NUMBER_AGGREGATE"
+
+ def test_trendline_without_temporal(self) -> None:
+ data = {
+ "dataset_id": 1,
+ "config": {
+ "chart_type": "big_number",
+ "metric": {"name": "revenue", "aggregate": "SUM"},
+ "show_trendline": True,
+ },
+ }
+ is_valid, request, error = SchemaValidator.validate_request(data)
+ assert is_valid is False
+ assert error is not None
+ assert error.error_code == "MISSING_TEMPORAL_COLUMN"
+
+ def test_big_number_accepted_in_chart_type_check(self) -> None:
+ """Verify big_number passes the chart_type validation."""
+ is_valid, error = SchemaValidator._pre_validate(
+ {
+ "dataset_id": 1,
+ "config": {
+ "chart_type": "big_number",
+ "metric": {"name": "revenue", "aggregate": "SUM"},
+ },
+ }
+ )
+ assert is_valid is True
+ assert error is None
+
+ def test_invalid_chart_type_still_rejected(self) -> None:
+ """Ensure unknown chart types are still rejected."""
+ is_valid, error = SchemaValidator._pre_validate(
+ {
+ "dataset_id": 1,
+ "config": {"chart_type": "nonexistent_chart"},
+ }
+ )
+ assert is_valid is False
+ assert error is not None
+ assert error.error_code == "INVALID_CHART_TYPE"
+
+ def test_big_number_with_subheader_and_format(self) -> None:
+ data = {
+ "dataset_id": 1,
+ "config": {
+ "chart_type": "big_number",
+ "metric": {"name": "revenue", "aggregate": "SUM"},
+ "subheader": "Year to date",
+ "y_axis_format": "$,.2f",
+ },
+ }
+ is_valid, request, error = SchemaValidator.validate_request(data)
+ assert is_valid is True
+ assert request is not None