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/10/12 13:15:08 UTC
(superset) branch master updated: feat(mcp): add typed Bullet chart support (#43770)
This is an automated email from the ASF dual-hosted git repository.
aminghadersohi 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 2747094d4c2 feat(mcp): add typed Bullet chart support (#43770)
2747094d4c2 is described below
commit 2747094d4c2799214d15c5158274423151c904f7
Author: Amin Ghadersohi <am...@gmail.com>
AuthorDate: Mon Oct 12 23:15:00 2026 +1000
feat(mcp): add typed Bullet chart support (#43770)
Co-authored-by: Claude Opus 5 <no...@anthropic.com>
---
UPDATING.md | 2 +
docs/admin_docs/configuration/mcp-server.mdx | 67 +-
.../docs/using-superset/using-ai-with-superset.mdx | 5 +
.../test/Bullet/buildQuery.test.ts | 15 +
.../test/Bullet/transformProps.test.ts | 172 +-
.../src/layers/Geojson/Geojson.test.tsx | 22 +
superset/dataframe.py | 4 +
superset/mcp_service/CLAUDE.md | 5 +-
superset/mcp_service/app.py | 4 +-
superset/mcp_service/caching.py | 2 +-
superset/mcp_service/chart/chart_helpers.py | 63 +
superset/mcp_service/chart/chart_utils.py | 750 ++-
superset/mcp_service/chart/compile.py | 303 +-
superset/mcp_service/chart/plugin.py | 38 +-
superset/mcp_service/chart/plugins/__init__.py | 3 +
superset/mcp_service/chart/plugins/bullet.py | 596 +++
superset/mcp_service/chart/plugins/gantt.py | 1 +
superset/mcp_service/chart/plugins/geographic.py | 2 +
superset/mcp_service/chart/preview_utils.py | 1098 +++-
superset/mcp_service/chart/query_result.py | 218 +-
.../mcp_service/chart/resources/chart_configs.py | 45 +
superset/mcp_service/chart/schemas.py | 632 ++-
superset/mcp_service/chart/tool/generate_chart.py | 10 +-
superset/mcp_service/chart/tool/get_chart_data.py | 75 +-
.../mcp_service/chart/tool/get_chart_preview.py | 13 +
.../chart/tool/get_chart_type_schema.py | 25 +-
superset/mcp_service/chart/tool/update_chart.py | 4 +-
.../mcp_service/chart/tool/update_chart_preview.py | 1 +
.../chart/validation/dataset_validator.py | 36 +
superset/mcp_service/chart/validation/pipeline.py | 61 +-
tests/unit_tests/dataframe_test.py | 86 +
.../mcp_service/chart/test_bullet_chart.py | 5308 ++++++++++++++++++++
.../mcp_service/chart/test_bullet_tool_paths.py | 2337 +++++++++
.../chart/test_chart_plugin_contract.py | 85 +-
tests/unit_tests/mcp_service/chart/test_compile.py | 43 +
.../mcp_service/chart/test_gauge_chart.py | 98 +-
.../chart/test_native_chart_regressions.py | 351 ++
.../test_review_query_and_preview_regressions.py | 92 +
.../mcp_service/chart/test_sunburst_chart.py | 5 +
.../chart/tool/test_update_chart_preview.py | 18 +-
.../semantic_layer/tool/test_get_table.py | 48 +
41 files changed, 12637 insertions(+), 106 deletions(-)
diff --git a/UPDATING.md b/UPDATING.md
index febb5024aea..74666fbb845 100644
--- a/UPDATING.md
+++ b/UPDATING.md
@@ -60,6 +60,8 @@ assists people when migrating to a new version.
the response size guard does not raise these fixed limits. Oversized results,
including saved Table exports, return `InvalidQueryResult`; lower row limits,
filter, select fewer/narrower columns, or aggregate before exporting.
+ Bullet preview numeric format precision is limited to 20 digits; raw data
+ reads do not validate presentation formats.
- MCP `update_chart` requires a complete `config` when changing `dataset_id`
to a different dataset, for both preview and immediate-save requests. Re-sending
diff --git a/docs/admin_docs/configuration/mcp-server.mdx b/docs/admin_docs/configuration/mcp-server.mdx
index 4204187d187..ee9ef0db14f 100644
--- a/docs/admin_docs/configuration/mcp-server.mdx
+++ b/docs/admin_docs/configuration/mcp-server.mdx
@@ -1472,9 +1472,74 @@ MCP_DISABLED_TOOLS = {"extensions.myorg.myextension.some_tool"}
Specifying a tool name that does not exist logs a warning at startup and is otherwise ignored — it will not prevent the server from starting.
:::
+## Bullet chart compatibility
+
+The MCP Bullet plugin uses `chart_type: "bullet"` and the native ECharts
+`viz_type: "bullet"`. Its optional `dimensions` hierarchy maps to `groupby`.
+Omit `dimensions` (or use `null`) to create a single-metric Bullet without a
+breakdown. On updates, omission or `null` preserves the saved hierarchy; use
+`dimensions: []` to clear it explicitly. An `order_by` update can reference the
+saved dimensions without resending them; unknown targets are rejected after
+resolving the saved hierarchy. Replacing the dataset follows the shared rebind
+contract: saved query roles are not inherited, so resend the hierarchy.
+
+Inherited native SQL dimensions remain query expressions when updating metrics
+or `order_by`; reference their output labels to sort by them. Caller-supplied
+`dimensions` must still be physical columns, not SQL expressions.
+
+Dimension and metric output names are case-sensitive: quoted physical columns
+such as `Region` and `region` remain distinct. Reference lookup prefers exact
+names and uses case-insensitive matching only when there is a single candidate;
+ambiguous references require the exact spelling. Bullet metric strings use
+JavaScript numeric spellings: underscore separators and non-ASCII digits are
+rejected rather than interpreted as numbers. Bounded array-valued dimensions
+retain their raw values in data reads and exports; preview category labels use
+JavaScript string conversion (for example, `[1, 2]` displays as `1,2`). Native
+SQL metrics without a label use their SQL expression as the output label.
+Native metric objects in `orderby` or `order_by_cols` use the same output-label
+fallback, so an unlabeled SQL metric can also be used as its own sort target.
+
+Presentation-only updates preserve saved predicates, including legacy top-level
+`where`, `having`, and `filters`, and normalize the native `order_by_cols` alias.
+When both `orderby` and `order_by_cols` are saved, their sort entries are
+concatenated in form-data key order, including when `orderby` is empty.
+Explicit `filters: []` and `order_by: []` clear those inherited controls.
+
+Range, marker, and marker-line label lists may be shorter than their value lists.
+Labels that become empty after sanitization retain their slots so later labels
+remain aligned with their corresponding values.
+As in Explore, missing or empty range labels are not displayed, and missing or
+empty marker labels use the formatted numeric value. Extra labels have no value
+to annotate and are ignored. These rules also apply to saved-chart previews and
+updates. Omitted label controls preserve the saved state only when their
+corresponding value controls (`ranges`, `markers`, or `marker_lines`) are also
+omitted. Replacing a value control without its labels clears the saved labels;
+resend the labels to retain annotations with the replacement values.
+
+Saved native `ranges`, `markers`, and `marker_lines` controls ignore empty,
+non-numeric, and NaN tokens, matching Explore. If no numeric range remains, the
+preview uses the default band up to 110% of the largest measure. Unrelated
+updates preserve these saved controls. Newly authored typed lists must contain
+finite numbers; infinite native values and oversized controls remain errors.
+
+On same-dataset chart updates, `filters: []` clears both user filters and the
+generated dashboard-time binding. `temporal_column: null` clears only that
+generated binding, preserving user filters. Omitting those controls preserves
+the saved binding. Bullet Vega previews keep separate indexed rows even when
+dimension display labels are identical, and support both `SMART_NUMBER` and
+`SMART_NUMBER_SIGNED` number formats. Bullet preview numeric format
+precision is limited to 20 digits before formatting; raw data reads do not
+validate presentation formats.
+
+Native Bullet temporal filters retain the active filter's subject and range as a
+pair; `No filter` placeholders do not supply the subject of another active range.
+Typed configs support one such pair. Multiple active native temporal filters, or
+conflicts with explicit `temporal_column`/`time_range`, are rejected rather than
+silently dropping or moving a predicate.
+
## Disabling chart type plugins
-The `generate_chart` tool dispatches per chart type (`xy`, `table`, `pie`, `gauge`, `treemap_v2`, `pivot_table`, `interactive_pivot`, `mixed_timeseries`, `handlebars`, `big_number`, `histogram`, `box_plot`, `waterfall`) to a registered chart type plugin. Gauge requests use the public MCP discriminator `chart_type: "gauge"`; Superset stores the corresponding native Explore visualization as `viz_type: "gauge_chart"`. Two settings let operators enable or disable individual chart type plugin [...]
+The `generate_chart` tool dispatches per chart type (`xy`, `table`, `pie`, `gauge`, `treemap_v2`, `pivot_table`, `interactive_pivot`, `mixed_timeseries`, `handlebars`, `big_number`, `histogram`, `box_plot`, `waterfall`, `bullet`) to a registered chart type plugin. Gauge requests use the public MCP discriminator `chart_type: "gauge"`; Superset stores the corresponding native Explore visualization as `viz_type: "gauge_chart"`. Two settings let operators enable or disable individual chart t [...]
Saved Bubble and Histogram Vega-Lite previews with zero rows return an empty
specification instead of `NoDataError`, matching their unsaved previews.
diff --git a/docs/docs/using-superset/using-ai-with-superset.mdx b/docs/docs/using-superset/using-ai-with-superset.mdx
index 74af9c4a050..e651cc0c69e 100644
--- a/docs/docs/using-superset/using-ai-with-superset.mdx
+++ b/docs/docs/using-superset/using-ai-with-superset.mdx
@@ -90,6 +90,11 @@ built-in `pivot_table` chart type and is not offered when the host visualization
is unavailable.
:::
+For saved Bullet charts, updates that omit dimensions preserve Explore's
+hierarchy, including SQL expressions, along with native query metadata such as
+URL parameters. Replacing a metric also updates inherited expression-based
+ordering for that metric output. Raw chart data and CSV/Excel exports do not
+require the selected number formatter to be supported by MCP previews.
MCP Vega-Lite previews of grouped time-series charts retain each series from
post-processed chart data, including charts with multiple grouping dimensions.
Saved line, bar, area, and scatter previews honor the requested width and height
diff --git a/superset-frontend/plugins/plugin-chart-echarts/test/Bullet/buildQuery.test.ts b/superset-frontend/plugins/plugin-chart-echarts/test/Bullet/buildQuery.test.ts
index fa2c0c0a540..6861a9e2780 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/test/Bullet/buildQuery.test.ts
+++ b/superset-frontend/plugins/plugin-chart-echarts/test/Bullet/buildQuery.test.ts
@@ -46,3 +46,18 @@ test('passes the groupby through as query columns', () => {
expect(query.columns).toEqual(['state']);
expect(query.metrics).toEqual(['sum__num']);
});
+
+test('forwards explicit ordering and row limit to the query', () => {
+ const [query] = buildQuery({
+ datasource: '5__table',
+ viz_type: 'bullet',
+ metric: 'Revenue',
+ groupby: ['Region'],
+ orderby: [['Revenue', false]],
+ row_limit: 25,
+ }).queries;
+ expect(query.columns).toEqual(['Region']);
+ expect(query.metrics).toEqual(['Revenue']);
+ expect(query.orderby).toEqual([['Revenue', false]]);
+ expect(query.row_limit).toBe(25);
+});
diff --git a/superset-frontend/plugins/plugin-chart-echarts/test/Bullet/transformProps.test.ts b/superset-frontend/plugins/plugin-chart-echarts/test/Bullet/transformProps.test.ts
index 306db08e527..c8d68b2065f 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/test/Bullet/transformProps.test.ts
+++ b/superset-frontend/plugins/plugin-chart-echarts/test/Bullet/transformProps.test.ts
@@ -33,13 +33,16 @@ const formData: SqlaFormData = {
markerLineLabels: 'stretch',
};
-const chartProps = (overrides: Partial<SqlaFormData> = {}) =>
+const chartProps = (
+ overrides: Partial<SqlaFormData> = {},
+ data: Array<Record<string, number>> = [{ sum__num: 120 }],
+) =>
new ChartProps({
width: 800,
height: 200,
formData: { ...formData, ...overrides },
theme: supersetTheme,
- queriesData: [{ data: [{ sum__num: 120 }] }],
+ queriesData: [{ data }],
hooks: {},
}) as unknown as EchartsBulletChartProps;
@@ -149,6 +152,27 @@ test('marks a measure beyond every range as past the last label', () => {
);
});
+test('uses sorted inclusive range boundaries and omits unavailable labels', () => {
+ const tooltip = (measure: number, ranges: string, rangeLabels: string) => {
+ const { echartOptions } = transformProps(
+ chartProps({ ranges, rangeLabels }, [{ sum__num: measure }]),
+ );
+ const series = echartOptions.series as Array<{
+ tooltip: { formatter: () => string };
+ }>;
+ return series[0].tooltip.formatter();
+ };
+
+ expect(tooltip(100, '300,100,200', 'high,low,mid')).toContain('Range: low');
+ expect(tooltip(120, '300,100,200', 'high,low,mid')).toContain('Range: mid');
+ expect(tooltip(-5, '10,-10,0', 'positive,negative,zero')).toContain(
+ 'Range: zero',
+ );
+ expect(tooltip(50, '100,200', ',high')).not.toContain('Range:');
+ expect(tooltip(250, '100,200', 'low,')).not.toContain('Range:');
+ expect(tooltip(120, '100,200', '')).not.toContain('Range:');
+});
+
test('renders range labels inside the top-right corner of their bands', () => {
const { echartOptions } = transformProps(chartProps({ showLabels: true }));
const { series } = echartOptions as any;
@@ -222,6 +246,101 @@ test('handles an empty query result without crashing', () => {
expect(max).toBeGreaterThan(min);
});
+test('coerces grouped categories with JavaScript String semantics', () => {
+ const props = new ChartProps({
+ width: 800,
+ height: 400,
+ formData: { ...formData, groupby: ['state'] },
+ theme: supersetTheme,
+ queriesData: [
+ {
+ data: [
+ { state: null, sum__num: 1 },
+ { state: '', sum__num: 2 },
+ { state: true, sum__num: 3 },
+ { state: false, sum__num: 4 },
+ { state: 12.0, sum__num: 5 },
+ { state: 12.5, sum__num: 6 },
+ { state: -0, sum__num: 7 },
+ // Chart Data serializes date/datetime values as epoch-ms JSON
+ // numbers before ECharts applies String(value).
+ { state: JSON.parse('1788307200000'), sum__num: 8 },
+ { state: JSON.parse('1788318245000'), sum__num: 9 },
+ { state: JSON.parse('1699162200000'), sum__num: 10 },
+ { state: JSON.parse('1699165800000'), sum__num: 11 },
+ { state: '12345678-1234-5678-1234-567812345678', sum__num: 12 },
+ ],
+ },
+ ],
+ hooks: {},
+ }) as unknown as EchartsBulletChartProps;
+ const { echartOptions } = transformProps(props);
+ const { data } = (echartOptions as { yAxis: { data: string[] } }).yAxis;
+ expect(data).toEqual([
+ 'null',
+ '',
+ 'true',
+ 'false',
+ '12',
+ '12.5',
+ '0',
+ '1788307200000',
+ '1788318245000',
+ '1699162200000',
+ '1699165800000',
+ '12345678-1234-5678-1234-567812345678',
+ ]);
+});
+
+test('coerces JSON number boundaries through IEEE-754 before String', () => {
+ const wireNumbers = [
+ '9007199254740993',
+ '9007199254740995',
+ '1.0000000000000001',
+ '1.0000000000000002',
+ '1e20',
+ '1e21',
+ '1e-6',
+ '1e-7',
+ '1.7976931348623157e308',
+ '1.7976931348623159e308',
+ '-1e309',
+ '-0',
+ ].map(value => JSON.parse(value) as number);
+ const props = new ChartProps({
+ width: 800,
+ height: 400,
+ formData: { ...formData, groupby: ['state'] },
+ theme: supersetTheme,
+ queriesData: [
+ {
+ data: wireNumbers.map((state, index) => ({
+ state,
+ sum__num: index + 1,
+ })),
+ },
+ ],
+ hooks: {},
+ }) as unknown as EchartsBulletChartProps;
+
+ const { echartOptions } = transformProps(props);
+ const { data } = (echartOptions as { yAxis: { data: string[] } }).yAxis;
+ expect(data).toEqual([
+ '9007199254740992',
+ '9007199254740996',
+ '1',
+ '1.0000000000000002',
+ '100000000000000000000',
+ '1e+21',
+ '0.000001',
+ '1e-7',
+ '1.7976931348623157e+308',
+ 'Infinity',
+ '-Infinity',
+ '0',
+ ]);
+});
+
test('splits into one bullet row per group value with shared markers', () => {
const props = new ChartProps({
width: 800,
@@ -256,3 +375,52 @@ test('splits into one bullet row per group value with shared markers', () => {
// grouped bar tooltips name the row
expect(series[0].tooltip.formatter({ dataIndex: 1 })).toContain('NY');
});
+
+test('keeps exact-case dimensions and uses per-index fallbacks for short labels', () => {
+ const { echartOptions } = transformProps(
+ chartProps(
+ {
+ groupby: ['Region', 'region'],
+ ranges: '100,200,300',
+ rangeLabels: 'low',
+ markers: '150,160',
+ markerLabels: 'goal',
+ markerLines: '250,260',
+ markerLineLabels: 'stretch',
+ showLabels: true,
+ yAxisFormat: '.1f',
+ },
+ [{ Region: 1, region: 2, sum__num: 120 }],
+ ),
+ );
+ type Label = { show?: boolean; formatter: () => string };
+ const options = echartOptions as {
+ yAxis: { data: string[] };
+ series: Array<{
+ type: string;
+ symbol?: string;
+ label?: Label;
+ markArea?: { data: Array<[{ label: Label }, { xAxis: number }]> };
+ markLine?: { data: Array<{ label: Label }> };
+ data: Array<{ label?: Label }>;
+ }>;
+ };
+ expect(options.yAxis.data).toEqual(['1, 2']);
+ const bands = options.series[0].markArea?.data;
+ expect(bands?.map(band => band[0].label.show)).toEqual([false, false, true]);
+ expect(bands?.map(band => band[0].label.formatter())).toEqual([
+ '',
+ '',
+ 'low',
+ ]);
+ expect(
+ options.series
+ .filter(series => series.symbol === 'triangle')
+ .map(series => series.label?.formatter()),
+ ).toEqual(['goal', '160.0']);
+ expect(
+ options.series
+ .filter(series => series.type === 'line')
+ .map(series => series.markLine?.data[0].label.formatter()),
+ ).toEqual(['stretch', '260.0']);
+});
diff --git a/superset-frontend/plugins/preset-chart-deckgl/src/layers/Geojson/Geojson.test.tsx b/superset-frontend/plugins/preset-chart-deckgl/src/layers/Geojson/Geojson.test.tsx
index b930634e77e..48cb9a9a151 100644
--- a/superset-frontend/plugins/preset-chart-deckgl/src/layers/Geojson/Geojson.test.tsx
+++ b/superset-frontend/plugins/preset-chart-deckgl/src/layers/Geojson/Geojson.test.tsx
@@ -32,6 +32,7 @@ import DeckGLGeoJson, {
getPoints,
getLayer,
} from './Geojson';
+import buildQuery, { type DeckGeoJsonFormData } from './buildQuery';
import controlPanel from './controlPanel';
const mockDeckGLContainerProps: Array<Record<string, unknown>> = [];
@@ -59,6 +60,27 @@ jest.mock('react-map-gl/maplibre', () => ({
useControl: () => null,
}));
+test('buildQuery includes geometry, cross-filter, and tooltip fields', () => {
+ const formData: DeckGeoJsonFormData = {
+ datasource: '1__table',
+ viz_type: 'deck_geojson',
+ geojson: 'geom',
+ cross_filter_column: 'region',
+ tooltip_contents: ['name', 'region'],
+ };
+
+ const [query] = buildQuery(formData).queries;
+
+ expect(query.columns).toEqual(['geom', 'region', 'name']);
+ expect(query.metrics).toEqual([]);
+ expect(query.groupby).toEqual([]);
+ expect(query.filters).toContainEqual({
+ col: 'geom',
+ op: 'IS NOT NULL',
+ });
+ expect(query.is_timeseries).toBe(false);
+});
+
test('computeGeoJsonTextOptionsFromFormData computes text options based on form data', () => {
const formData: SqlaFormData = {
label_property_name: 'name',
diff --git a/superset/dataframe.py b/superset/dataframe.py
index 16dfe35abdd..82bd6e779a5 100644
--- a/superset/dataframe.py
+++ b/superset/dataframe.py
@@ -139,6 +139,10 @@ def df_to_records(
# converting it to float can overflow or lose precision.
if type(value) in _NUMPY_NATIVE_TYPES:
value = value.item()
+ elif type(value) is np.timedelta64:
+ value = pd.Timedelta(value)
+ elif type(value) is np.datetime64:
+ value = pd.Timestamp(value)
if _is_trusted_missing_or_nonfinite(value):
value = None
else:
diff --git a/superset/mcp_service/CLAUDE.md b/superset/mcp_service/CLAUDE.md
index fba77ae9ce5..78d24061388 100644
--- a/superset/mcp_service/CLAUDE.md
+++ b/superset/mcp_service/CLAUDE.md
@@ -410,8 +410,9 @@ Used by: `get_chart_info`, `get_chart_preview`, `get_chart_data`, `generate_char
When creating, saving, or previewing charts, run schema validation (Tier 1)
and optionally a compile check (Tier 2) before persisting or caching.
-``validate_and_compile`` glues both together; tools with tight SLAs
-(``generate_explore_link``, ``update_chart_preview``) opt out of Tier 2.
+``validate_and_compile`` glues both together. Explicitly latency-sensitive
+link-only tools such as ``generate_explore_link`` may opt out of Tier 2;
+mutation and cached-preview update tools run Tier 2 before persistence.
```python
from superset.mcp_service.chart.compile import validate_and_compile
diff --git a/superset/mcp_service/app.py b/superset/mcp_service/app.py
index 5021be2cdbc..b1cbf6151b3 100644
--- a/superset/mcp_service/app.py
+++ b/superset/mcp_service/app.py
@@ -444,6 +444,8 @@ Chart Types You Can CREATE with generate_chart/generate_explore_link:
- chart_type="bubble_v2": Bubble scatter plotting three metrics at once
(entity dimension + x, y and size metrics required; optional series
dimension colors the bubbles by group)
+- chart_type="bullet": Bullet Chart for comparing one numeric measure per row
+ against qualitative ranges, point markers, and marker lines
Time grain for temporal x-axis (time_grain parameter):
- PT1H (hourly), P1D (daily), P1W (weekly), P1M (monthly), P1Y (yearly)
@@ -453,7 +455,7 @@ Each chart returned by list_charts / get_chart_info includes a
chart_type_display_name field with a human-readable name when available.
This field is populated for chart types known to the MCP registry
(xy, pie, sunburst, table, pivot_table, big_number, mixed_timeseries, handlebars,
-histogram, box_plot, waterfall, gantt, bubble_v2, and interactive_pivot).
+histogram, box_plot, waterfall, gantt, bullet, bubble_v2, and interactive_pivot).
Availability gates creation and schema discovery, not display names for
existing charts.
For all other viz_types (Funnel, Gauge, Heatmap, etc.) it will be null —
diff --git a/superset/mcp_service/caching.py b/superset/mcp_service/caching.py
index 164cf8d3357..94587a76bb8 100644
--- a/superset/mcp_service/caching.py
+++ b/superset/mcp_service/caching.py
@@ -31,7 +31,7 @@ logger = logging.getLogger(__name__)
# namespace whenever a cached response from an older release is not valid under
# the active contract. This keeps rolling upgrades from serving incompatible
# entries through newly upgraded processes without trying to rewrite cached data.
-MCP_RESPONSE_CACHE_NAMESPACE = "response-contract-v2:"
+MCP_RESPONSE_CACHE_NAMESPACE = "response-contract-v3:"
# Tools whose responses must never be served from the response cache,
# regardless of MCP_CACHE_CONFIG["excluded_tools"]. The catalog is a live,
diff --git a/superset/mcp_service/chart/chart_helpers.py b/superset/mcp_service/chart/chart_helpers.py
index c3707175839..1e5771dedd8 100644
--- a/superset/mcp_service/chart/chart_helpers.py
+++ b/superset/mcp_service/chart/chart_helpers.py
@@ -1429,3 +1429,66 @@ def build_applied_dashboard_filters(
)
return applied
+
+
+def _parse_orderby(values: Any) -> list[list[Any]]:
+ """Parse bounded native ``order_by_cols`` without coercing malformed input."""
+ if values is not None and not isinstance(values, list):
+ raise ValueError("order_by_cols must be a list")
+ if isinstance(values, list) and len(values) > 100:
+ raise ValueError("order_by_cols must contain at most 100 entries")
+ from superset.utils import json as utils_json
+
+ result: list[list[Any]] = []
+ for index, value in enumerate(values or []):
+ if isinstance(value, str):
+ if len(value) > 1000:
+ raise ValueError(f"order_by_cols[{index}] is too long")
+ try:
+ value = utils_json.loads(value)
+ except (TypeError, ValueError) as ex:
+ raise ValueError(f"order_by_cols[{index}] is not valid JSON") from ex
+ if (
+ isinstance(value, (list, tuple))
+ and len(value) == 2
+ and isinstance(value[0], str)
+ and bool(value[0])
+ and isinstance(value[1], bool)
+ ):
+ result.append(list(value))
+ else:
+ raise ValueError(
+ f"order_by_cols[{index}] must be [column, ascending_boolean]"
+ )
+ return result
+
+
+def _column_label(column: Any) -> str | None:
+ """Return the frontend ``getColumnLabel`` value for a query column."""
+ if isinstance(column, str):
+ return column
+ if not isinstance(column, dict):
+ return None
+ return (
+ column.get("label") or column.get("sqlExpression") or column.get("column_name")
+ )
+
+
+def _metric_label(metric: Any) -> str | None:
+ """Return the frontend ``getMetricLabel`` value for a query metric."""
+ if isinstance(metric, str):
+ return metric
+ if not isinstance(metric, dict):
+ return None
+ if label := metric.get("label"):
+ return label
+ if metric.get("expressionType") == "SIMPLE":
+ column = metric.get("column") or {}
+ name = (
+ column.get("columnName") or column.get("column_name")
+ if isinstance(column, dict)
+ else None
+ )
+ if name and metric.get("aggregate"):
+ return f"{metric['aggregate']}({name})"
+ return metric.get("sqlExpression")
diff --git a/superset/mcp_service/chart/chart_utils.py b/superset/mcp_service/chart/chart_utils.py
index cf546dc27d3..f8fe13ce789 100644
--- a/superset/mcp_service/chart/chart_utils.py
+++ b/superset/mcp_service/chart/chart_utils.py
@@ -24,6 +24,7 @@ generation that can be used by both generate_chart and generate_explore_link too
import hashlib
import logging
+import math
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from typing import Any, cast, Dict, TYPE_CHECKING
@@ -39,9 +40,11 @@ from superset.constants import NO_TIME_RANGE
from superset.daos.exceptions import DatasourceNotFound, DatasourceValueIsIncorrect
from superset.exceptions import SupersetSecurityException
from superset.mcp_service.chart.schemas import (
+ _get_known_fields,
BigNumberChartConfig,
BoxPlotChartConfig,
BubbleChartConfig,
+ BulletChartConfig,
ChartCapabilities,
ChartConfig,
ChartSemantics,
@@ -56,6 +59,7 @@ from superset.mcp_service.chart.schemas import (
MixedTimeseriesChartConfig,
PieChartConfig,
PivotTableChartConfig,
+ resolve_bullet_order_target,
SortByConfig,
SunburstChartConfig,
TableChartConfig,
@@ -468,9 +472,37 @@ def map_config_to_form_data(
raise ValueError(" ".join(parts))
_bind_dashboard_time_range_filter(form_data, config, dataset_id)
+ if plugin.binds_time_range_to_temporal_filter:
+ _apply_time_range_to_bound_filter(form_data, config)
return form_data
+def _apply_time_range_to_bound_filter(
+ form_data: Dict[str, Any], config: ChartConfig
+) -> None:
+ """Set an explicit ``time_range`` on the generated dashboard time binding.
+
+ Charts whose frontend reads time only through adhoc filters (for example
+ Bullet) would otherwise ignore an explicit range once the binding falls
+ back to the dataset's main temporal column.
+ """
+ if "time_range" not in getattr(config, "model_fields_set", set()):
+ return
+ subject = form_data.get(MCP_DASHBOARD_TIME_FILTER_SUBJECT)
+ if not isinstance(subject, str) or not subject:
+ return
+ comparator = getattr(config, "time_range", None) or NO_TIME_RANGE
+ for filter_ in form_data.get("adhoc_filters") or []:
+ if (
+ isinstance(filter_, dict)
+ and filter_.get("operator") == FilterOperator.TEMPORAL_RANGE.value
+ and filter_.get("subject") == subject
+ and filter_.get("comparator") == NO_TIME_RANGE
+ ):
+ filter_["comparator"] = comparator
+ break
+
+
def _add_adhoc_filters(
form_data: Dict[str, Any], filters: Sequence[FilterConfig] | None
) -> None:
@@ -3538,7 +3570,676 @@ def _as_column_list(value: Any) -> list[Any]:
return [value]
-def analyze_chart_capabilities(viz_type: str | None, config: Any) -> ChartCapabilities:
+_NATIVE_TEMPORAL_ROLE_FIELDS: dict[str, frozenset[str]] = {
+ # Typed ``x`` is persisted as native x_axis/granularity_sqla for XY and
+ # Mixed Timeseries. Waterfall exposes the typed field as ``x_axis``.
+ "x_axis": frozenset({"x", "x_axis"}),
+ "granularity_sqla": frozenset({"x", "x_axis", "temporal_column"}),
+ # Chart plugins may designate a chart-specific query role as the implicit
+ # dashboard-time subject.
+ "start_time": frozenset({"start_time"}),
+}
+
+
+def _bullet_token_list(values: Sequence[str | int | float]) -> str:
+ """Serialize typed Bullet controls to the frontend's comma-separated form."""
+ tokens: list[str] = []
+ for value in values:
+ if isinstance(value, float):
+ token = repr(value)
+ # ``100`` parses back to the same binary float as ``100.0`` and
+ # preserves the frontend's established compact integer spelling.
+ if token.endswith(".0") and not (
+ value == 0.0 and math.copysign(1.0, value) < 0
+ ):
+ token = token[:-2]
+ tokens.append(token)
+ else:
+ tokens.append(str(value))
+ return ",".join(tokens)
+
+
+def map_bullet_config(config: BulletChartConfig) -> Dict[str, Any]: # noqa: C901
+ """Map typed Bullet config to ``Bullet/buildQuery`` and transformProps.
+
+ The frontend buildQuery replaces the generic query fields with exactly one
+ metric and the groupby hierarchy. Presentation controls stay in native
+ snake_case form_data; the chart plugin camelizes them for transformProps.
+ """
+ if (
+ config.dimensions is None
+ and config._inherited_groupby is None
+ and config.order_by
+ ):
+ # An update resolves its saved hierarchy before mapping. Without one,
+ # creation must validate sort targets against an empty hierarchy.
+ BulletChartConfig.model_validate(
+ {**config.model_dump(exclude_unset=True), "dimensions": []}
+ )
+ metric = create_metric_object(config.metric)
+ form_data: Dict[str, Any] = {
+ "viz_type": "bullet",
+ "metric": metric,
+ }
+
+ # Optional semantic/query fields are emitted only when explicitly supplied.
+ # This lets update_chart and update_chart_preview preserve native saved state,
+ # while an explicit empty value still clears it through the generic merge path.
+ # The row limit always carries the schema default so a new chart is bounded;
+ # update merging restores the saved limit when the caller omits it.
+ if "dimensions" in config.model_fields_set:
+ form_data["groupby"] = [dimension.name for dimension in config.dimensions or []]
+ form_data["row_limit"] = config.row_limit
+ if "time_range" in config.model_fields_set:
+ form_data["time_range"] = config.time_range
+
+ if config.order_by:
+ dimensions = config.order_dimensions
+ orderby: list[list[Any]] = []
+ for order in config.order_by:
+ role, index = resolve_bullet_order_target(
+ order.column, dimensions, config.metric
+ )
+ if role == "metric":
+ order_target: Any = metric
+ else:
+ if index is None: # Defensive: resolver pairs dimensions with indexes.
+ raise ValueError("Bullet dimension order target has no index")
+ dimension = dimensions[index]
+ order_target = (
+ dimension.name if isinstance(dimension, ColumnRef) else dimension
+ )
+ orderby.append([order_target, order.ascending])
+ form_data["orderby"] = orderby
+ elif "order_by" in config.model_fields_set:
+ form_data["orderby"] = []
+
+ presentation_fields: dict[str, tuple[str, Any]] = {
+ "ranges": ("ranges", _bullet_token_list(config.ranges)),
+ "range_labels": (
+ "range_labels",
+ _bullet_token_list(config.range_labels),
+ ),
+ "markers": ("markers", _bullet_token_list(config.markers)),
+ "marker_labels": (
+ "marker_labels",
+ _bullet_token_list(config.marker_labels),
+ ),
+ "marker_lines": (
+ "marker_lines",
+ _bullet_token_list(config.marker_lines),
+ ),
+ "marker_line_labels": (
+ "marker_line_labels",
+ _bullet_token_list(config.marker_line_labels),
+ ),
+ "y_axis_format": ("y_axis_format", config.y_axis_format),
+ "show_labels": ("show_labels", config.show_labels),
+ "show_legend": ("show_legend", config.show_legend),
+ }
+ for field_name, (form_key, value) in presentation_fields.items():
+ if field_name in config.model_fields_set:
+ form_data[form_key] = value
+
+ _add_adhoc_filters(form_data, config.filters)
+ if config.filters == [] and "filters" in config.model_fields_set:
+ form_data["adhoc_filters"] = []
+ if config.time_range and config.temporal_column:
+ _ensure_temporal_adhoc_filter(form_data, config.temporal_column)
+ for filter_ in form_data.get("adhoc_filters", []):
+ if (
+ isinstance(filter_, dict)
+ and filter_.get("operator") == FilterOperator.TEMPORAL_RANGE.value
+ and filter_.get("subject") == config.temporal_column
+ and filter_.get("comparator") == NO_TIME_RANGE
+ ):
+ filter_["comparator"] = config.time_range
+ return form_data
+
+
+def _normalize_native_filter_aliases(form_data: Mapping[str, Any]) -> Dict[str, Any]:
+ """Fold legacy WHERE, HAVING, and filter predicates into adhoc controls."""
+ from superset.utils.core import form_data_to_adhoc, simple_filter_to_adhoc
+
+ normalized = dict(form_data)
+ legacy_filters = [
+ form_data_to_adhoc(normalized, clause)
+ for clause in ("having", "where")
+ if normalized.get(clause)
+ ]
+ legacy_filters.extend(
+ simple_filter_to_adhoc(filter_, "where")
+ for filter_ in normalized.get("filters") or []
+ if filter_ is not None
+ )
+ if legacy_filters:
+ normalized["adhoc_filters"] = [
+ *legacy_filters,
+ *(normalized.get("adhoc_filters") or []),
+ ]
+ for key in ("where", "having", "filters"):
+ normalized.pop(key, None)
+ return normalized
+
+
+def _normalize_bullet_query_aliases(form_data: Mapping[str, Any]) -> Dict[str, Any]:
+ """Fold inherited native predicates and ordering into canonical controls."""
+ from superset.mcp_service.chart.chart_helpers import _parse_orderby
+
+ normalized = _normalize_native_filter_aliases(form_data)
+ if "groupby" in normalized and not isinstance(normalized["groupby"], list):
+ # Bullet/buildQuery and transformProps read ensureIsArray(groupby).
+ normalized["groupby"] = bullet_groupby_list(normalized["groupby"])
+ if "order_by_cols" in normalized:
+ # Native extractQueryFields concatenates both aliases in key order.
+ ordering: list[Any] = []
+ for key, value in normalized.items():
+ if key == "order_by_cols":
+ ordering.extend(_parse_orderby(value))
+ elif key == "orderby":
+ ordering.extend(value or [])
+ normalized["orderby"] = ordering
+ normalized.pop("order_by_cols", None)
+ return normalized
+
+
+def merge_bullet_form_data(
+ existing_form_data: Mapping[str, Any], new_form_data: Dict[str, Any]
+) -> None:
+ """Preserve omitted native Bullet controls across update tool paths.
+
+ Query roles and every UI control have an explicit typed representation.
+ Mappers emit optional fields only when the caller supplied them, so copying
+ the bounded native keys below preserves omitted state while explicit empty,
+ false, null, and zero-like values remain authoritative.
+ """
+ if (
+ existing_form_data.get("viz_type") != "bullet"
+ or new_form_data.get("viz_type") != "bullet"
+ ):
+ return
+ existing_form_data = _normalize_bullet_query_aliases(existing_form_data)
+ preserved_keys = {
+ "groupby",
+ "adhoc_filters",
+ "time_range",
+ "row_limit",
+ "orderby",
+ "ranges",
+ "range_labels",
+ "markers",
+ "marker_labels",
+ "marker_lines",
+ "marker_line_labels",
+ "y_axis_format",
+ "show_labels",
+ "show_legend",
+ "url_params",
+ # Native query context (dashboard/native filter predicates and time
+ # overrides) that buildQueryObject applies on top of the controls.
+ "extra_form_data",
+ "extra_filters",
+ MCP_DASHBOARD_TIME_FILTER_SUBJECT,
+ }
+
+ # Threshold and label arrays are one frontend control pair. If callers
+ # replace the values without replacing their labels, clear the stale labels
+ # instead of accidentally reassigning them by position.
+ dependent_controls = {
+ "ranges": "range_labels",
+ "markers": "marker_labels",
+ "marker_lines": "marker_line_labels",
+ }
+ for values_key, labels_key in dependent_controls.items():
+ if values_key in new_form_data and labels_key not in new_form_data:
+ new_form_data[labels_key] = ""
+
+ preserve_orderby = (
+ "orderby" not in new_form_data and "orderby" in existing_form_data
+ )
+ for key in preserved_keys:
+ if (
+ key == MCP_DASHBOARD_TIME_FILTER_SUBJECT
+ and "adhoc_filters" in new_form_data
+ ):
+ # The marker describes a mapper-generated temporal filter. Do not
+ # retain stale provenance when an explicit filter update removed it.
+ continue
+ if key in existing_form_data and key not in new_form_data:
+ new_form_data[key] = existing_form_data[key]
+ if preserve_orderby:
+ new_form_data["orderby"] = _orderby_for_final_output_roles(
+ existing_form_data, new_form_data
+ )
+
+
+def _bullet_output_labels(
+ form_data: Mapping[str, Any],
+) -> tuple[set[str], dict[str, Any]]:
+ """Return a Bullet state's dimension output labels and metric outputs."""
+ from superset.mcp_service.chart.chart_helpers import _column_label, _metric_label
+
+ dimensions = {
+ label
+ for column in bullet_groupby_list(form_data.get("groupby"))
+ if (label := _column_label(column)) is not None
+ }
+ metrics = form_data.get("metrics") or []
+ if not isinstance(metrics, (list, tuple)):
+ metrics = [metrics]
+ metric_outputs = {
+ label: metric
+ for metric in [form_data.get("metric"), *metrics]
+ if (label := _metric_label(metric)) is not None
+ }
+ return dimensions, metric_outputs
+
+
+def bullet_groupby_list(groupby: Any) -> list[Any]:
+ """Normalize a saved Bullet hierarchy like the frontend ``ensureIsArray``."""
+ if groupby is None:
+ return []
+ return list(groupby) if isinstance(groupby, (list, tuple)) else [groupby]
+
+
+def _orderby_for_final_output_roles(
+ existing_form_data: Mapping[str, Any], new_form_data: Mapping[str, Any]
+) -> Any:
+ """Drop sorts on removed output roles and rebind inherited metric expressions.
+
+ Native ordering may also rank by a saved metric or column that is not a
+ displayed output (``get_sqla_query`` resolves it independently). Those
+ sorters never named a Bullet role, so a role change does not remove them.
+ """
+ from superset.mcp_service.chart.chart_helpers import _column_label, _metric_label
+
+ saved = existing_form_data.get("orderby")
+ if not isinstance(saved, list):
+ return saved
+ outputs, metric_outputs = _bullet_output_labels(new_form_data)
+ outputs.update(metric_outputs)
+ previous_dimensions, previous_metrics = _bullet_output_labels(existing_form_data)
+ previous_outputs = previous_dimensions | set(previous_metrics)
+ retained = []
+ for entry in saved:
+ if isinstance(entry, (list, tuple)) and entry:
+ target = entry[0]
+ label = (
+ _metric_label(target)
+ or _column_label(target)
+ or target.get("metric_name")
+ if isinstance(target, Mapping)
+ else target
+ )
+ if (
+ isinstance(label, str)
+ and label not in outputs
+ and label in previous_outputs
+ ):
+ continue
+ if (
+ isinstance(target, Mapping)
+ and isinstance(label, str)
+ and label in metric_outputs
+ ):
+ # Label equality identifies an output role, not expression
+ # equality: execute the final metric, never the saved expression.
+ entry = [metric_outputs[label], *entry[1:]]
+ retained.append(entry)
+ return retained
+
+
+def _filter_identity(filter_: Any) -> tuple[Any, ...] | None:
+ """Return the native identity used when one filter replaces another."""
+ if not isinstance(filter_, Mapping):
+ return None
+ return (
+ filter_.get("clause"),
+ filter_.get("expressionType"),
+ filter_.get("subject"),
+ filter_.get("operator"),
+ )
+
+
+def _temporal_binding_filter(filters: list[Any], subject: Any) -> dict[str, Any] | None:
+ """Find the unique filter owned by a recorded MCP temporal marker."""
+ if subject is None:
+ return None
+ if not isinstance(subject, str) or not subject:
+ raise ValueError(
+ "MCP temporal binding provenance subject must be a non-empty string"
+ )
+ matches = [
+ filter_
+ for filter_ in filters
+ if isinstance(filter_, dict)
+ and filter_.get("subject") == subject
+ and filter_.get("operator") == FilterOperator.TEMPORAL_RANGE.value
+ ]
+ if len(matches) != 1:
+ raise ValueError(
+ "MCP temporal binding provenance must match exactly one "
+ f"TEMPORAL_RANGE filter for subject {subject!r}; found {len(matches)}"
+ )
+ return matches[0]
+
+
+def _append_or_replace_filter(filters: list[Any], filter_: Any) -> None:
+ """Append a filter, replacing the same native role when identifiable."""
+ identity = _filter_identity(filter_)
+ if identity is None:
+ if filter_ not in filters:
+ filters.append(filter_)
+ return
+ filters[:] = [item for item in filters if _filter_identity(item) != identity]
+ filters.append(filter_)
+
+
+def _native_temporal_subject_changed(
+ existing_form_data: Mapping[str, Any],
+ new_form_data: Mapping[str, Any],
+ explicit_fields: set[str],
+) -> bool:
+ """Return whether an authoritative native temporal role was replaced.
+
+ Mapping a partial update can propose a dataset fallback binding even when
+ the caller only changed filters. That proposal is not authoritative. A
+ changed x/granularity/chart-specific role is authoritative only when its
+ corresponding typed field was actually supplied.
+ """
+ for native_key, typed_fields in _NATIVE_TEMPORAL_ROLE_FIELDS.items():
+ if explicit_fields.isdisjoint(typed_fields):
+ continue
+ existing_value = existing_form_data.get(native_key)
+ incoming_value = new_form_data.get(native_key)
+ if existing_value != incoming_value:
+ return True
+ return False
+
+
+def _native_temporal_binding(
+ form_data: Mapping[str, Any], filters: list[Any]
+) -> tuple[str | None, dict[str, Any] | None]:
+ """Resolve one binding for a trusted native temporal role, if present."""
+ for native_key in _NATIVE_TEMPORAL_ROLE_FIELDS:
+ subject = form_data.get(native_key)
+ if not isinstance(subject, str) or not subject:
+ continue
+ matches = [
+ filter_
+ for filter_ in filters
+ if isinstance(filter_, dict)
+ and filter_.get("subject") == subject
+ and filter_.get("operator") == FilterOperator.TEMPORAL_RANGE.value
+ ]
+ if len(matches) > 1:
+ raise ValueError(
+ "An authoritative native temporal subject must match at most one "
+ f"TEMPORAL_RANGE filter for subject {subject!r}; found "
+ f"{len(matches)}"
+ )
+ if matches:
+ return subject, matches[0]
+ return None, None
+
+
+def merge_update_form_data( # noqa: C901
+ existing_form_data: Mapping[str, Any],
+ new_form_data: Dict[str, Any],
+ config: ChartConfig,
+) -> None:
+ """Apply the shared omission/provenance contract for chart updates.
+
+ Mapper-generated neutral temporal bindings are infrastructure, not evidence
+ that the caller supplied ``filters`` or changed a saved time-range binding.
+ This helper is used by immediate saves, preview-first saved updates, and
+ cached-preview updates so omission, clear, replacement, and temporal
+ overrides have identical behavior.
+
+ State never crosses a visualization boundary: a viz-type change starts from
+ the mapper's output, so the previous chart's predicates are not restored.
+ """
+ # A missing saved viz_type is a boundary too, matching merge_chart_form_data.
+ if existing_form_data.get("viz_type") != new_form_data.get("viz_type"):
+ return
+ existing_form_data = _normalize_native_filter_aliases(existing_form_data)
+ # The initial overlay may carry legacy keys from saved form data. Filter
+ # omission/replacement below owns the complete canonical predicate sequence.
+ for key in ("where", "having", "filters"):
+ new_form_data.pop(key, None)
+ existing_filters = list(existing_form_data.get("adhoc_filters") or [])
+ incoming_filters = list(new_form_data.get("adhoc_filters") or [])
+ existing_subject = existing_form_data.get(MCP_DASHBOARD_TIME_FILTER_SUBJECT)
+ incoming_subject = new_form_data.get(MCP_DASHBOARD_TIME_FILTER_SUBJECT)
+ existing_binding = _temporal_binding_filter(existing_filters, existing_subject)
+ incoming_binding = _temporal_binding_filter(incoming_filters, incoming_subject)
+
+ explicit_fields = set(getattr(config, "model_fields_set", set()))
+ filters_explicit = "filters" in explicit_fields
+ range_explicit = "time_range" in explicit_fields
+ subject_explicit = "temporal_column" in explicit_fields
+ native_subject_changed = _native_temporal_subject_changed(
+ existing_form_data, new_form_data, explicit_fields
+ )
+ subject_authoritative = subject_explicit or native_subject_changed
+ if incoming_binding is None:
+ native_subject, native_binding = _native_temporal_binding(
+ new_form_data, incoming_filters
+ )
+ if native_binding is not None:
+ incoming_subject = native_subject
+ incoming_binding = native_binding
+ incoming_user_filters = [
+ filter_ for filter_ in incoming_filters if filter_ is not incoming_binding
+ ]
+ temporal_explicit = range_explicit or subject_authoritative
+ if (
+ existing_binding is None
+ and incoming_binding is not None
+ and isinstance(incoming_subject, str)
+ and "filters" not in explicit_fields
+ and temporal_explicit
+ ):
+ # A saved temporal filter that Explore wrote has no MCP provenance
+ # marker. When it is the only native filter for the incoming subject,
+ # the update replaces it in place instead of appending a duplicate.
+ native_matches = [
+ filter_
+ for filter_ in existing_filters
+ if isinstance(filter_, dict)
+ and filter_.get("subject") == incoming_subject
+ and filter_.get("operator") == FilterOperator.TEMPORAL_RANGE.value
+ ]
+ if len(native_matches) == 1:
+ existing_binding = native_matches[0]
+ existing_subject = incoming_subject
+ if (
+ existing_binding is None
+ and existing_subject is None
+ and range_explicit
+ and not subject_authoritative
+ and "filters" not in explicit_fields
+ ):
+ # A range-only update on an unmarked chart adopts its saved temporal
+ # filter even when Explore bound it to a column other than the mapper's
+ # proposed subject. Zero or several native ranges stay ambiguous, so the
+ # proposed binding is added instead of guessing which one to rewrite.
+ unmarked_matches = [
+ filter_
+ for filter_ in existing_filters
+ if isinstance(filter_, dict)
+ and filter_.get("operator") == FilterOperator.TEMPORAL_RANGE.value
+ and filter_.get("expressionType") == "SIMPLE"
+ and filter_.get("clause") == "WHERE"
+ and isinstance(filter_.get("subject"), str)
+ ]
+ if len(unmarked_matches) == 1:
+ existing_binding = unmarked_matches[0]
+ existing_subject = existing_binding["subject"]
+
+ chosen_binding: dict[str, Any] | None = None
+ chosen_subject: Any = None
+ if not filters_explicit:
+ # Omission is byte-faithful: keep the native sequence in its exact order,
+ # including SQL/HAVING objects and a provenance-owned binding at any index.
+ merged_filters = list(existing_filters)
+ chosen_binding = existing_binding
+ chosen_subject = existing_subject
+ if temporal_explicit:
+ if subject_authoritative:
+ chosen_binding = incoming_binding
+ chosen_subject = incoming_subject
+ elif existing_binding is not None:
+ # A range-only update belongs to the saved subject, even when
+ # mapping the partial config proposed the dataset main_dttm.
+ chosen_binding = dict(existing_binding)
+ chosen_subject = existing_subject
+ else:
+ chosen_binding = incoming_binding
+ chosen_subject = incoming_subject
+
+ if chosen_binding is not None:
+ chosen_binding = dict(chosen_binding)
+ if range_explicit:
+ chosen_binding["comparator"] = (
+ getattr(config, "time_range", None) or NO_TIME_RANGE
+ )
+ elif existing_binding is not None:
+ # Subject-only replacement preserves the saved active or
+ # neutral range instead of resetting it to No filter.
+ chosen_binding["comparator"] = existing_binding.get(
+ "comparator", NO_TIME_RANGE
+ )
+ if existing_binding is not None:
+ binding_index = next(
+ index
+ for index, filter_ in enumerate(merged_filters)
+ if filter_ is existing_binding
+ )
+ if chosen_binding is None:
+ merged_filters.pop(binding_index)
+ else:
+ # A temporal override changes infrastructure in place instead
+ # of moving it past surrounding native filters.
+ merged_filters[binding_index] = chosen_binding
+ elif chosen_binding is not None:
+ merged_filters.append(chosen_binding)
+ else:
+ # An explicit filter array replaces the saved native sequence. The mapper
+ # deliberately emits [] for an explicit clear; otherwise retain its
+ # generated temporal binding after the replacement filters.
+ merged_filters = list(incoming_user_filters)
+ if incoming_user_filters or temporal_explicit:
+ if subject_authoritative:
+ chosen_binding = incoming_binding
+ chosen_subject = incoming_subject
+ elif range_explicit and existing_binding is not None:
+ chosen_binding = dict(existing_binding)
+ chosen_subject = existing_subject
+ elif range_explicit:
+ # With no saved binding to rewrite, an explicit range uses the
+ # mapper's generated binding rather than being dropped.
+ chosen_binding = incoming_binding
+ chosen_subject = incoming_subject
+ else:
+ # A filter-only replacement keeps the saved provenance binding.
+ # The mapper's incoming binding may merely be a dataset fallback
+ # and must not reset the saved subject or active range.
+ chosen_binding = existing_binding
+ chosen_subject = existing_subject
+ if chosen_binding is not None:
+ chosen_binding = dict(chosen_binding)
+ if range_explicit:
+ chosen_binding["comparator"] = (
+ getattr(config, "time_range", None) or NO_TIME_RANGE
+ )
+ elif subject_authoritative and existing_binding is not None:
+ chosen_binding["comparator"] = existing_binding.get(
+ "comparator", NO_TIME_RANGE
+ )
+ if chosen_binding is not None:
+ _append_or_replace_filter(merged_filters, chosen_binding)
+
+ # Materialize exactly when saved state had the key or the caller made the
+ # controls authoritative. An omitted update must not turn a missing native
+ # filter key into [] merely because its mapper proposed a neutral binding.
+ if filters_explicit or "adhoc_filters" in existing_form_data or temporal_explicit:
+ new_form_data["adhoc_filters"] = merged_filters
+ else:
+ new_form_data.pop("adhoc_filters", None)
+ if chosen_binding is not None and isinstance(chosen_subject, str):
+ new_form_data[MCP_DASHBOARD_TIME_FILTER_SUBJECT] = chosen_subject
+ else:
+ new_form_data.pop(MCP_DASHBOARD_TIME_FILTER_SUBJECT, None)
+
+
+# The shared preservation registry may carry envelope, presentation, and time
+# keys over from another visualization. Bullet does not model them, so they are
+# opaque persisted state rather than misspelled typed fields.
+_BULLET_UNMODELED_PRESERVED_KEYS = (
+ FORM_DATA_UPDATE_PRESERVE_KEYS["envelope"]
+ | FORM_DATA_UPDATE_PRESERVE_KEYS["presentation"]
+ | FORM_DATA_UPDATE_PRESERVE_KEYS["time"]
+) - _get_known_fields(BulletChartConfig)
+
+
+def _strip_inherited_bullet_roles(
+ validation_data: dict[str, Any], update_config: ChartConfig | None
+) -> list[Any] | None:
+ """Drop omitted native query roles; return the inherited hierarchy, if any."""
+ if update_config is not None and not isinstance(update_config, BulletChartConfig):
+ return None
+ inherited_groupby: list[Any] | None = None
+ if update_config is None or update_config.dimensions is None:
+ inherited_groupby = bullet_groupby_list(validation_data.pop("groupby", None))
+ if update_config is None or "filters" not in update_config.model_fields_set:
+ validation_data.pop("adhoc_filters", None)
+ validation_data.pop(MCP_DASHBOARD_TIME_FILTER_SUBJECT, None)
+ if update_config is None or "order_by" not in update_config.model_fields_set:
+ # An inherited sort may rank by an independent saved metric; the
+ # native query contract validates it, not the authoring schema.
+ for order_key in ("orderby", "order_by_cols"):
+ validation_data.pop(order_key, None)
+ return inherited_groupby
+
+
+def validate_merged_bullet_form_data(
+ form_data: Mapping[str, Any],
+ update_config: ChartConfig | None = None,
+) -> BulletChartConfig | None:
+ """Validate final Bullet controls without reinterpreting inherited query roles.
+
+ Saved Explore state may contain SQL dimensions and SQL WHERE/SIMPLE HAVING
+ filters beyond the typed authoring surface. Omitted roles are validated by
+ the native query contract and compilation, not as newly authored physical
+ columns or SIMPLE WHERE filters. Only this validation copy excludes them
+ and native query metadata; compiled and persisted form data stays intact.
+ Explicit replacements, including ``[]``, retain strict typed validation.
+ """
+ if form_data.get("viz_type") != "bullet":
+ return None
+ validation_data = dict(form_data)
+ for native_query_key in ("url_params", "extra_form_data", "extra_filters"):
+ validation_data.pop(native_query_key, None)
+ for preserved_key in _BULLET_UNMODELED_PRESERVED_KEYS:
+ validation_data.pop(preserved_key, None)
+ if not validation_data.get(MCP_DASHBOARD_TIME_FILTER_SUBJECT):
+ # A null marker records an explicit subject clear with no binding to
+ # validate; it is not an authored physical column.
+ validation_data.pop(MCP_DASHBOARD_TIME_FILTER_SUBJECT, None)
+ inherited_groupby = _strip_inherited_bullet_roles(validation_data, update_config)
+ validated = BulletChartConfig.model_validate(validation_data)
+ if inherited_groupby is not None:
+ # The final query keeps the saved hierarchy, so the metric output label
+ # must not collide with any inherited dimension output.
+ validated._inherited_groupby = inherited_groupby
+ validated.validate_roles_and_outputs()
+ return validated
+
+
+def analyze_chart_capabilities( # noqa: C901
+ viz_type: str | None, config: Any
+) -> ChartCapabilities:
"""Analyze chart capabilities based on type and configuration."""
if not viz_type:
viz_type = _resolve_viz_type(config)
@@ -3556,7 +4257,7 @@ def analyze_chart_capabilities(viz_type: str | None, config: Any) -> ChartCapabi
"sunburst_v2",
]
- supports_interaction = viz_type in interactive_types
+ supports_interaction = viz_type == "bullet" or viz_type in interactive_types
supports_drill_down = viz_type in [
"table",
"pivot_table_v2",
@@ -3570,26 +4271,31 @@ def analyze_chart_capabilities(viz_type: str | None, config: Any) -> ChartCapabi
# Determine optimal formats
optimal_formats = ["url"] # Always include static image
- if supports_interaction:
- from superset.mcp_service.chart.preview_utils import (
- plugin_unsupported_preview,
- )
- from superset.mcp_service.chart.registry import plugin_for_viz_type
-
- optimal_formats.append("interactive")
- # Only advertise Vega-Lite where a spec can actually be produced. A
- # plugin whose output cannot be expressed as a Vega-Lite spec (e.g. map
- # geometry) opts out, so the capability list never promises a failing
- # preview.
- owner = plugin_for_viz_type(viz_type)
- if (
- owner is None or owner.supports_vega_lite_preview
- ) and plugin_unsupported_preview(viz_type, "vega_lite") is None:
- optimal_formats.append("vega_lite")
- optimal_formats.extend(["ascii", "table"])
+ if viz_type == "bullet":
+ # These are the formats implemented by both saved and transient Bullet
+ # preview paths. Table explicitly rejects Bullet's layered semantics.
+ optimal_formats.extend(["vega_lite", "ascii"])
+ else:
+ if supports_interaction:
+ from superset.mcp_service.chart.preview_utils import (
+ plugin_unsupported_preview,
+ )
+ from superset.mcp_service.chart.registry import plugin_for_viz_type
+
+ optimal_formats.append("interactive")
+ owner = plugin_for_viz_type(viz_type)
+ if (
+ owner is None or owner.supports_vega_lite_preview
+ ) and plugin_unsupported_preview(viz_type, "vega_lite") is None:
+ optimal_formats.append("vega_lite")
+ optimal_formats.extend(["ascii", "table"])
# Classify data types
data_types = []
+ if viz_type == "bullet":
+ data_types.append("metric")
+ if getattr(config, "dimensions", None):
+ data_types.append("categorical")
if hasattr(config, "x") and config.x:
data_types.append("categorical" if not config.x.is_metric else "metric")
if hasattr(config, "y") and config.y:
@@ -3605,7 +4311,11 @@ def analyze_chart_capabilities(viz_type: str | None, config: Any) -> ChartCapabi
supports_drill_down=supports_drill_down,
supports_export=True, # All charts can be exported
optimal_formats=optimal_formats,
- data_types=list(set(data_types)),
+ data_types=(
+ list(dict.fromkeys(data_types))
+ if viz_type == "bullet"
+ else list(set(data_types))
+ ),
)
diff --git a/superset/mcp_service/chart/compile.py b/superset/mcp_service/chart/compile.py
index 6fb87a6dc5d..feb96f94e90 100644
--- a/superset/mcp_service/chart/compile.py
+++ b/superset/mcp_service/chart/compile.py
@@ -46,12 +46,14 @@ from superset.mcp_service.chart.query_result import (
first_query_data,
normalize_chart_query_result,
query_result_failure,
+ temporal_json_numbers,
)
from superset.mcp_service.chart.schemas import ChartError
from superset.mcp_service.chart.validation.dataset_validator import (
AmbiguousDatasetReferenceError,
build_dataset_context_from_orm,
DatasetValidator,
+ resolve_dataset_column,
resolve_dataset_reference,
)
from superset.mcp_service.common.error_schemas import (
@@ -149,7 +151,10 @@ def _compile_chart( # noqa: C901
command.validate()
result = command.run()
- if query_failure := query_result_failure(result):
+ if query_failure := query_result_failure(
+ result,
+ temporal_json_numbers=temporal_json_numbers(form_data.get("viz_type")),
+ ):
error_str = query_failure.error
return CompileResult(
success=False,
@@ -457,7 +462,286 @@ def _build_compile_error(message: str) -> ChartGenerationError:
)
-def validate_and_compile(
+def _native_validation_error(role: str, reference: str) -> ChartGenerationError:
+ """Build a fail-closed error for an incompatible native chart reference."""
+ return ChartGenerationError(
+ error_type="invalid_native_chart_reference",
+ message=f"Native chart {role} {reference!r} is incompatible with the dataset",
+ details=(
+ "The rebound form data must retain its exact query roles on the target "
+ "dataset; no column or saved-metric reference may be guessed or dropped."
+ ),
+ suggestions=[
+ "Choose a target dataset with a compatible schema",
+ "Provide a complete typed chart config using target-dataset fields",
+ ],
+ error_code="CHART_VALIDATION_FAILED",
+ )
+
+
+def _native_column_name(value: Any) -> str | None:
+ """Extract a physical QueryFormColumn reference, or None for SQL columns."""
+ if isinstance(value, str):
+ return value
+ if not isinstance(value, dict):
+ return None
+ if value.get("expressionType") == "SQL":
+ reference = value.get("sqlExpression")
+ if value.get("isColumnReference") is True and isinstance(reference, str):
+ return reference or None
+ return None
+ name = value.get("column_name") or value.get("columnName")
+ return name if isinstance(name, str) and name else None
+
+
+def _native_column_label(value: Any) -> str | None:
+ """Return the frontend label for a native column without custom hooks."""
+ if isinstance(value, str):
+ return value
+ if not isinstance(value, dict):
+ return None
+ for key in ("label", "sqlExpression", "column_name", "columnName"):
+ candidate = value.get(key)
+ if isinstance(candidate, str) and candidate:
+ return candidate
+ return None
+
+
+def _native_metric_ref(value: Any) -> tuple[str, str] | None:
+ """Return ``(saved_metric|column, name)`` for a native query metric."""
+ if isinstance(value, str):
+ return "saved_metric", value
+ if not isinstance(value, dict):
+ return None
+ if value.get("expressionType") == "SQL":
+ return None
+ if value.get("expressionType") != "SIMPLE":
+ # Match QueryObject's guarded legacy saved-metric normalization.
+ if not ({"sqlExpression", "aggregate", "column"} & value.keys()):
+ label = value.get("label")
+ if isinstance(label, str) and label:
+ return "saved_metric", label
+ return None
+ column = value.get("column")
+ name = (
+ column.get("column_name") or column.get("columnName")
+ if isinstance(column, dict)
+ else None
+ )
+ return ("column", name) if isinstance(name, str) and name else None
+
+
+def _native_reference_error( # noqa: C901
+ form_data: Dict[str, Any],
+ dataset_context: DatasetContext,
+ dataset_id: int,
+ *,
+ strict_all_form_refs: bool,
+) -> ChartGenerationError | None:
+ """Validate the canonical native QueryObjects against a rebound dataset."""
+ from superset.mcp_service.chart.chart_helpers import (
+ build_query_dicts_from_form_data,
+ )
+ from superset.mcp_service.chart.response_preflight import (
+ bounded_exception_message,
+ )
+
+ try:
+ queries = build_query_dicts_from_form_data(
+ deepcopy(form_data), dataset_id, "table"
+ )
+ except (KeyError, TypeError, ValueError) as ex:
+ return _native_validation_error("query contract", bounded_exception_message(ex))
+
+ saved_metrics = [item["name"] for item in dataset_context.available_metrics]
+
+ def column_error(value: Any, role: str) -> ChartGenerationError | None:
+ name = _native_column_name(value)
+ if name is None:
+ if isinstance(value, dict) and value.get("expressionType") == "SQL":
+ return None
+ return _native_validation_error(role, repr(value)[:200])
+ try:
+ if resolve_dataset_column(name, dataset_context) is not None:
+ return None
+ except ValueError:
+ pass
+ return _native_validation_error(role, name)
+
+ def metric_error(value: Any, role: str) -> ChartGenerationError | None:
+ """Validate one raw or generated metric reference against the target."""
+ ref = _native_metric_ref(value)
+ if ref is None:
+ if isinstance(value, dict) and value.get("expressionType") == "SQL":
+ return None
+ return _native_validation_error(role, repr(value)[:200])
+ kind, name = ref
+ if kind == "saved_metric":
+ # Native lookup selects an exact name unambiguously; only a
+ # case-folded reference has to be unique.
+ matches = (
+ [name]
+ if name in saved_metrics
+ else [
+ item for item in saved_metrics if item.casefold() == name.casefold()
+ ]
+ )
+ if len(set(matches)) != 1:
+ saved_role = f"{role.removesuffix(' metric')} saved metric"
+ return _native_validation_error(saved_role, name)
+ return None
+ return column_error(name, f"{role} column")
+
+ for filter_ in form_data.get("adhoc_filters") or []:
+ if not isinstance(filter_, dict) or filter_.get("expressionType") != "SIMPLE":
+ continue
+ if not strict_all_form_refs and _is_inert_adhoc_filter(filter_):
+ continue
+ subject = filter_.get("subject")
+ clause = str(filter_.get("clause") or "WHERE").upper()
+ if clause == "HAVING" and isinstance(subject, str):
+ metric_matches = (
+ [subject]
+ if subject in saved_metrics
+ else [
+ name
+ for name in saved_metrics
+ if name.casefold() == subject.casefold()
+ ]
+ )
+ if len(metric_matches) == 1:
+ continue
+ if subject is not None and (
+ error := column_error(subject, "form-data filter column")
+ ):
+ return error
+ if filter_.get("operator") == "TEMPORAL_RANGE" and isinstance(subject, str):
+ try:
+ temporal = resolve_dataset_column(subject, dataset_context)
+ except ValueError:
+ temporal = None
+ if temporal is not None and not temporal.get("is_temporal", False):
+ return _native_validation_error("temporal filter column", subject)
+
+ # temporal_columns_lookup describes the entire datasource, not selected
+ # roles. The physical form/query column checks validate selected references.
+
+ for query_index, query in enumerate(queries, 1):
+ metric_labels: set[str] = set()
+ for column in query.get("columns") or []:
+ if error := column_error(column, f"query {query_index} column"):
+ return error
+ for column in query.get("series_columns") or []:
+ if error := column_error(column, f"query {query_index} series column"):
+ return error
+ for column in query.get("groupby") or []:
+ if error := column_error(column, f"query {query_index} groupby column"):
+ return error
+ selected_column_labels = {
+ label
+ for column in query.get("columns") or []
+ if (label := _native_column_label(column)) is not None
+ }
+ adhoc_column_labels = {
+ label
+ for column in query.get("columns") or []
+ if isinstance(column, dict)
+ and isinstance(label := column.get("label"), str)
+ and label
+ }
+ for level in query.get("grouping_sets") or []:
+ for column in level:
+ # Grouping sets reference selected logical outputs, including
+ # Custom SQL labels. Their source columns were checked above.
+ if isinstance(column, str) and column in selected_column_labels:
+ continue
+ if error := column_error(
+ column, f"query {query_index} grouping-set column"
+ ):
+ return error
+
+ metrics = query.get("metrics") or []
+ for metric in metrics:
+ if label := _metric_label_for_validation(metric):
+ metric_labels.add(label)
+ if error := metric_error(metric, f"query {query_index} metric"):
+ return error
+
+ granularity = query.get("granularity")
+ if granularity:
+ if error := column_error(granularity, "temporal column"):
+ return error
+ try:
+ temporal = resolve_dataset_column(granularity, dataset_context)
+ except ValueError:
+ temporal = None
+ if (
+ (query.get("extras") or {}).get("time_grain_sqla")
+ and temporal is not None
+ and not temporal.get("is_temporal", False)
+ ):
+ return _native_validation_error("temporal column", granularity)
+
+ for filter_ in query.get("filters") or []:
+ if not isinstance(filter_, dict):
+ return _native_validation_error("filter", repr(filter_)[:200])
+ column = filter_.get("col")
+ if isinstance(column, str) and column in metric_labels:
+ continue
+ if isinstance(column, str) and any(
+ name.casefold() == column.casefold() for name in saved_metrics
+ ):
+ continue
+ if column is not None and (
+ error := column_error(column, f"query {query_index} filter column")
+ ):
+ return error
+
+ for order in query.get("orderby") or []:
+ if not isinstance(order, (list, tuple)) or len(order) != 2:
+ return _native_validation_error("ordering", repr(order)[:200])
+ target = order[0]
+ target_label = _metric_label_for_validation(target)
+ if target in metrics or (target_label and target_label in metric_labels):
+ continue
+ if isinstance(target, str) and target in metric_labels:
+ continue
+ # get_sqla_query resolves a string sort against labelled adhoc
+ # (Custom SQL) query columns before physical columns.
+ if isinstance(target, str) and target in adhoc_column_labels:
+ continue
+ # Native ordering can use a metric that is not displayed. Resolve
+ # saved names with the same exact/case-folded rules as other metrics,
+ # and validate adhoc metric columns rather than their output labels.
+ if (
+ isinstance(target, str)
+ and any(name.casefold() == target.casefold() for name in saved_metrics)
+ ) or (isinstance(target, dict) and _native_metric_ref(target) is not None):
+ if error := metric_error(
+ target, f"query {query_index} ordering metric"
+ ):
+ return error
+ elif error := column_error(target, f"query {query_index} ordering column"):
+ return error
+ return None
+
+
+def _metric_label_for_validation(metric: Any) -> str | None:
+ """Resolve a native metric output label without executing custom code."""
+ if isinstance(metric, str):
+ return metric
+ if not isinstance(metric, dict):
+ return None
+ if isinstance(metric.get("label"), str) and metric["label"]:
+ return metric["label"]
+ ref = _native_metric_ref(metric)
+ if ref and ref[0] == "column" and isinstance(metric.get("aggregate"), str):
+ return f"{metric['aggregate']}({ref[1]})"
+ expression = metric.get("sqlExpression")
+ return expression if isinstance(expression, str) and expression else None
+
+
+def validate_and_compile( # noqa: C901
config: Any,
form_data: Dict[str, Any],
dataset: Any,
@@ -534,6 +818,21 @@ def validate_and_compile(
tier="validation",
error_obj=temporal_state_error,
)
+ if state_plugin is not None and state_plugin.validates_native_references:
+ native_error = _native_reference_error(
+ form_data,
+ dataset_context,
+ dataset.id,
+ strict_all_form_refs=config is None,
+ )
+ if native_error is not None:
+ return CompileResult(
+ success=False,
+ error=native_error.details or native_error.message,
+ error_code="CHART_VALIDATION_FAILED",
+ tier="validation",
+ error_obj=native_error,
+ )
if not run_compile_check:
return CompileResult(success=True)
diff --git a/superset/mcp_service/chart/plugin.py b/superset/mcp_service/chart/plugin.py
index da3bb75ae52..c0c4902df28 100755
--- a/superset/mcp_service/chart/plugin.py
+++ b/superset/mcp_service/chart/plugin.py
@@ -300,6 +300,9 @@ class ChartTypePlugin(Protocol):
#: Whether an empty result is a valid, renderable preview.
allows_empty_result: ClassVar[bool]
+ #: Whether get_chart_data returns a successful response for zero rows.
+ allows_empty_data_result: ClassVar[bool]
+
#: Whether saved-chart Vega-Lite previews take the requested width,
#: height and chart description instead of the spec's own frame.
resizes_saved_preview: ClassVar[bool]
@@ -326,6 +329,20 @@ class ChartTypePlugin(Protocol):
invalid_result_message: ClassVar[str]
invalid_result_suggestions: ClassVar[tuple[str, ...]]
+ #: Whether chart-data consumers convert temporal and duration result
+ #: values to the JSON numbers and text the frontend ``transformProps``
+ #: receives, instead of the generic scalar serialization.
+ temporal_json_numbers: ClassVar[bool]
+
+ #: Whether an explicit ``time_range`` sets the comparator of the generated
+ #: dashboard temporal filter instead of being ignored by the binding.
+ binds_time_range_to_temporal_filter: ClassVar[bool]
+
+ #: Whether the compile check's validation tier also resolves every column
+ #: and saved-metric reference of the canonical native QueryObjects (saved
+ #: native roles the typed config does not model) against the dataset.
+ validates_native_references: ClassVar[bool]
+
def resolve_query_fields(
self, form_data: Mapping[str, Any], viz_type: str
) -> tuple[list[Any], list[Any]] | None:
@@ -468,15 +485,24 @@ class ChartTypePlugin(Protocol):
form_data: Mapping[str, Any],
dataset_id: int | str | None,
dataset_context: Callable[[], Any] | None = None,
+ update_config: Any = None,
) -> Any | None:
"""Validate the final merged update state.
- Return the config the merged state implies (replacing the request
+ ``update_config`` is the request config (None for a dataset-only
+ update), so a plugin can tell explicitly supplied controls from saved
+ ones. Return the config the merged state implies (replacing the request
config for compile and persistence), or None to keep the request
config. Raise ``ValueError`` when the merged state is invalid.
"""
...
+ def sanitize_data_rows(
+ self, data: list[Any], form_data: Mapping[str, Any]
+ ) -> tuple[list[Any], ChartError | None]:
+ """Return the rows get_chart_data (JSON, CSV, XLSX) exposes, or an error."""
+ ...
+
class BaseChartPlugin:
"""
@@ -504,6 +530,7 @@ class BaseChartPlugin:
unbound_form_data_is_rebind: ClassVar[bool] = False
normalize_data_results: ClassVar[bool] = False
allows_empty_result: ClassVar[bool] = False
+ allows_empty_data_result: ClassVar[bool] = False
resizes_saved_preview: ClassVar[bool] = False
supports_column_append: ClassVar[bool] = False
supports_vega_lite_preview: ClassVar[bool] = True
@@ -511,6 +538,9 @@ class BaseChartPlugin:
null_data_is_empty: ClassVar[bool] = True
invalid_result_error_code: ClassVar[str] = "INVALID_CHART_RESULT"
invalid_result_message: ClassVar[str] = "Chart query returned invalid values"
+ temporal_json_numbers: ClassVar[bool] = False
+ binds_time_range_to_temporal_filter: ClassVar[bool] = False
+ validates_native_references: ClassVar[bool] = False
invalid_result_suggestions: ClassVar[tuple[str, ...]] = (
"Use a numeric-producing metric",
"Check the metric alias and SQL expression",
@@ -678,9 +708,15 @@ class BaseChartPlugin:
form_data: Mapping[str, Any],
dataset_id: int | str | None,
dataset_context: Callable[[], Any] | None = None,
+ update_config: Any = None,
) -> Any | None:
return None
+ def sanitize_data_rows(
+ self, data: list[Any], form_data: Mapping[str, Any]
+ ) -> tuple[list[Any], ChartError | None]:
+ return data, None
+
@staticmethod
def _with_context(what: str, context: str | None) -> str:
"""Combine a 'what' label and optional context with an en-dash."""
diff --git a/superset/mcp_service/chart/plugins/__init__.py b/superset/mcp_service/chart/plugins/__init__.py
index efc5423eeac..2dd8c0cdfd4 100644
--- a/superset/mcp_service/chart/plugins/__init__.py
+++ b/superset/mcp_service/chart/plugins/__init__.py
@@ -30,6 +30,7 @@ To add a new chart type:
from superset.mcp_service.chart.plugins.big_number import BigNumberChartPlugin
from superset.mcp_service.chart.plugins.box_plot import BoxPlotChartPlugin
from superset.mcp_service.chart.plugins.bubble import BubbleChartPlugin
+from superset.mcp_service.chart.plugins.bullet import BulletChartPlugin
from superset.mcp_service.chart.plugins.gantt import GanttChartPlugin
from superset.mcp_service.chart.plugins.gauge import GaugeChartPlugin
from superset.mcp_service.chart.plugins.geographic import (
@@ -56,6 +57,7 @@ from superset.mcp_service.chart.registry import register
# Register all built-in chart type plugins
register(XYChartPlugin())
+register(BulletChartPlugin())
register(TableChartPlugin())
register(PieChartPlugin())
register(SunburstChartPlugin())
@@ -76,6 +78,7 @@ __all__ = [
"BigNumberChartPlugin",
"BoxPlotChartPlugin",
"BubbleChartPlugin",
+ "BulletChartPlugin",
"GanttChartPlugin",
"GaugeChartPlugin",
"CountryMapChartPlugin",
diff --git a/superset/mcp_service/chart/plugins/bullet.py b/superset/mcp_service/chart/plugins/bullet.py
new file mode 100644
index 00000000000..c7d92eff28b
--- /dev/null
+++ b/superset/mcp_service/chart/plugins/bullet.py
@@ -0,0 +1,596 @@
+# 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.
+
+"""ECharts Bullet chart type plugin."""
+
+from __future__ import annotations
+
+from collections.abc import Callable, Mapping
+from copy import deepcopy
+from typing import Any, ClassVar
+
+from superset.mcp_service.chart.chart_utils import (
+ _summarize_filters,
+ bullet_groupby_list,
+ map_bullet_config,
+)
+from superset.mcp_service.chart.plugin import BaseChartPlugin
+from superset.mcp_service.chart.schemas import (
+ BulletChartConfig,
+ ChartError,
+ ColumnRef,
+ MCP_DASHBOARD_TIME_FILTER_SUBJECT,
+ resolve_bullet_order_target,
+ TablePreview,
+ VegaLitePreview,
+)
+from superset.mcp_service.chart.validation.dataset_validator import (
+ AmbiguousDatasetReferenceError,
+ DatasetValidator,
+ is_numeric_column,
+ resolve_dataset_column,
+)
+from superset.mcp_service.common.error_schemas import (
+ ChartGenerationError,
+ DatasetContext,
+)
+
+
+def _canonical_reference(
+ name: str,
+ candidates: list[str],
+ role: str,
+) -> str:
+ """Resolve exact/casefold matches without silently choosing ambiguity."""
+ if name in candidates:
+ return name
+ matches = [
+ candidate for candidate in candidates if candidate.casefold() == name.casefold()
+ ]
+ if len(matches) > 1:
+ # AmbiguousDatasetReferenceError subclasses ValueError, so existing
+ # ValueError handlers keep working, while the validation pipeline
+ # re-raises it instead of downgrading it to a warning and proceeding
+ # with the unresolved reference.
+ raise AmbiguousDatasetReferenceError(name, matches, f"Bullet {role}")
+ return matches[0] if matches else name
+
+
+def _render_model_error(rows: Any, form_data: Mapping[str, Any]) -> ChartError | None:
+ """Validate Bullet result rows without requiring a backend preview formatter."""
+ from superset.mcp_service.chart.preview_utils import (
+ BulletOutputError,
+ resolve_bullet_render_model,
+ )
+ from superset.mcp_service.chart.response_preflight import (
+ bounded_exception_message,
+ )
+
+ try:
+ # Every consumer passes through here; preview renderers re-resolve with
+ # the label bound, so raw-data fetches keep the shared result limits.
+ resolve_bullet_render_model(
+ rows, dict(form_data), validate_format=False, bound_labels=False
+ )
+ except BulletOutputError as ex:
+ return ChartError(error=bounded_exception_message(ex), error_type=ex.error_type)
+ return None
+
+
+def _unsupported_table_preview() -> ChartError:
+ return ChartError(
+ error=(
+ "Table previews cannot represent Bullet ranges, markers, "
+ "labels, and legend semantics"
+ ),
+ error_type="UnsupportedFormat",
+ )
+
+
+class BulletChartPlugin(BaseChartPlugin):
+ """Plugin matching ``plugin-chart-echarts/src/Bullet``."""
+
+ chart_type = "bullet"
+ display_name = "Bullet Chart"
+ native_viz_types: ClassVar[Mapping[str, str]] = {
+ "bullet": "Bullet Chart",
+ }
+ # Bullet/transformProps.ts converts temporal values with Number().
+ temporal_json_numbers = True
+ # The frontend renders an empty result (a zero measure when ungrouped).
+ allows_empty_result = True
+ allows_empty_data_result = True
+ # A null ``data`` array is a malformed producer result, not an empty chart.
+ null_data_is_empty = False
+ # Updates merge filter provenance plus Bullet's bounded native controls;
+ # no other saved control is carried into the typed Bullet state.
+ owns_update_merge = True
+ binds_time_range_to_temporal_filter = True
+ validates_native_references = True
+ invalid_result_error_code = "MALFORMED_BULLET_OUTPUT"
+ invalid_result_message = (
+ "Bullet query output does not contain a usable sizing measure."
+ )
+
+ def pre_validate(self, config: dict[str, Any]) -> ChartGenerationError | None:
+ if "metric" in config:
+ return None
+ return ChartGenerationError(
+ error_type="missing_bullet_fields",
+ message="Bullet chart missing required field: metric",
+ details=(
+ "A Bullet chart measures one numeric aggregate or saved/SQL metric; "
+ "optional dimensions split it into one row per group."
+ ),
+ suggestions=[
+ "Add metric: {'name': 'revenue', 'aggregate': 'SUM'}",
+ "For a saved metric use {'name': 'revenue', 'saved_metric': true}",
+ "Add dimensions: [{'name': 'region'}] for grouped bullet rows",
+ ],
+ error_code="MISSING_BULLET_FIELDS",
+ )
+
+ def extract_column_refs(self, config: Any) -> list[ColumnRef]:
+ if not isinstance(config, BulletChartConfig):
+ return []
+ refs = [config.metric, *(config.dimensions or [])]
+ refs.extend(ColumnRef(name=filter_.column) for filter_ in config.filters or [])
+ # order_by is constrained to role outputs by the schema, so those names
+ # are already represented by metric/dimension refs and must not be
+ # reinterpreted as physical columns.
+ return refs
+
+ def to_form_data(
+ self, config: Any, dataset_id: int | str | None = None
+ ) -> dict[str, Any]:
+ if not isinstance(config, BulletChartConfig):
+ raise TypeError("BulletChartPlugin requires BulletChartConfig")
+ return map_bullet_config(config)
+
+ def post_map_validate( # noqa: C901
+ self,
+ config: Any,
+ form_data: dict[str, Any],
+ dataset_id: int | str | None = None,
+ ) -> ChartGenerationError | None:
+ """Require an unambiguous numeric metric output for Number(...)."""
+ if not isinstance(config, BulletChartConfig) or dataset_id is None:
+ return None
+ dataset_context = DatasetValidator._get_dataset_context(dataset_id)
+ if dataset_context is None:
+ return None
+
+ columns = [column["name"] for column in dataset_context.available_columns]
+ metrics = [metric["name"] for metric in dataset_context.available_metrics]
+ requested: list[tuple[str, list[str], str]] = []
+ if config.metric.name and not config.metric.sql_expression:
+ requested.append(
+ (
+ config.metric.name,
+ metrics if config.metric.saved_metric else columns,
+ "saved metric" if config.metric.saved_metric else "metric column",
+ )
+ )
+ requested.extend(
+ (dimension.name or "", columns, "dimension")
+ for dimension in config.dimensions or []
+ if dimension.name
+ )
+ requested.extend(
+ (filter_.column, columns, "filter column")
+ for filter_ in config.filters or []
+ )
+ if config.temporal_column:
+ requested.append((config.temporal_column, columns, "temporal column"))
+
+ for name, candidates, role in requested:
+ if (
+ name not in candidates
+ and sum(
+ candidate.casefold() == name.casefold() for candidate in candidates
+ )
+ > 1
+ ):
+ return ChartGenerationError(
+ error_type="ambiguous_bullet_reference",
+ message=(
+ f"Bullet {role} {name!r} is ambiguous in dataset metadata"
+ ),
+ details=(
+ "Multiple dataset fields differ only by case. The query and "
+ "frontend require an exact canonical field name."
+ ),
+ suggestions=[
+ "Use get_dataset_info and copy the exact-case field name"
+ ],
+ error_code="AMBIGUOUS_BULLET_REFERENCE",
+ )
+
+ metric = config.metric
+ if metric.saved_metric or metric.sql_expression:
+ # Saved/SQL metric result types are determined by their expressions;
+ # Tier-2 compile validation remains authoritative.
+ return None
+ if (metric.aggregate or "SUM") in {"COUNT", "COUNT_DISTINCT"}:
+ return None
+ try:
+ column = resolve_dataset_column(metric.name or "", dataset_context)
+ except ValueError as ex:
+ return ChartGenerationError(
+ error_type="ambiguous_bullet_reference",
+ message=(
+ f"Bullet metric column {metric.name!r} is ambiguous in "
+ "dataset metadata"
+ ),
+ details=str(ex),
+ suggestions=["Use get_dataset_info and copy the exact-case field name"],
+ error_code="AMBIGUOUS_BULLET_REFERENCE",
+ )
+ if column is None or is_numeric_column(column):
+ return None
+ return ChartGenerationError(
+ error_type="non_numeric_bullet_metric",
+ message=(
+ f"Bullet metric {metric.name!r} must produce a number; dataset "
+ f"type is {column.get('type', 'UNKNOWN')}."
+ ),
+ details=(
+ "Bullet/transformProps.ts converts the metric result with Number(). "
+ "A non-numeric MIN/MAX or default SUM would render an invalid bar."
+ ),
+ suggestions=[
+ "Use COUNT or COUNT_DISTINCT for a text column",
+ "Choose a numeric dataset column",
+ "Use a saved or SQL metric that returns a numeric value",
+ ],
+ error_code="NON_NUMERIC_BULLET_METRIC",
+ )
+
+ def normalize_column_refs(
+ self, config: Any, dataset_context: DatasetContext
+ ) -> Any:
+ if not isinstance(config, BulletChartConfig):
+ return config
+ explicit_fields = set(config.model_fields_set)
+ config_dict = config.model_dump(exclude_unset=True)
+ columns = [column["name"] for column in dataset_context.available_columns]
+ metrics = [metric["name"] for metric in dataset_context.available_metrics]
+
+ metric = config_dict["metric"]
+ if not metric.get("sql_expression"):
+ metric["name"] = _canonical_reference(
+ metric["name"],
+ metrics if metric.get("saved_metric") else columns,
+ "saved metric" if metric.get("saved_metric") else "metric column",
+ )
+ for dimension in config_dict.get("dimensions") or []:
+ dimension["name"] = _canonical_reference(
+ dimension["name"], columns, "dimension"
+ )
+ if temporal := config_dict.get("temporal_column"):
+ config_dict["temporal_column"] = _canonical_reference(
+ temporal, columns, "temporal column"
+ )
+ for filter_ in config_dict.get("filters") or []:
+ filter_["column"] = _canonical_reference(
+ filter_["column"], columns, "filter column"
+ )
+
+ # Sort targets may use ergonomic role names or labels. Canonicalize
+ # physical-name targets and leave explicit display labels untouched.
+ # A merged validation copy omits native dimensions; its native sorts
+ # belong to query-contract validation, not physical-column resolution.
+ orders = (
+ config_dict.get("order_by") or []
+ if config.dimensions is not None or config._inherited_groupby is not None
+ else []
+ )
+ for order in orders:
+ role, index = resolve_bullet_order_target(
+ order["column"], config.order_dimensions, config.metric
+ )
+ if role == "dimension" and index is not None:
+ if config.dimensions is not None:
+ order["column"] = config_dict["dimensions"][index]["name"]
+ elif not metric.get("sql_expression") and not metric.get("label"):
+ order["column"] = metric["name"]
+
+ normalized = BulletChartConfig.model_validate(config_dict)
+ normalized._inherited_groupby = deepcopy(config._inherited_groupby)
+ normalized.validate_roles_and_outputs()
+ normalized.model_fields_set.clear()
+ normalized.model_fields_set.update(explicit_fields)
+ return normalized
+
+ def generate_name(self, config: Any, dataset_name: str | None = None) -> str:
+ metric = config.metric.label or config.metric.name or "Metric"
+ what = f"{metric} bullet"
+ if config.dimensions:
+ what += " by " + ", ".join(
+ dimension.label or dimension.name or "dimension"
+ for dimension in config.dimensions
+ )
+ return self._with_context(what, _summarize_filters(config.filters))
+
+ def resolve_viz_type(self, config: Any) -> str:
+ return "bullet"
+
+ def schema_error_hint(self) -> ChartGenerationError | None:
+ return ChartGenerationError(
+ error_type="bullet_validation_error",
+ message="Bullet chart configuration validation failed",
+ details=(
+ "Bullet requires one numeric metric and optional unique physical "
+ "dimensions. Threshold/marker labels are optional; missing marker "
+ "labels fall back to formatted values."
+ ),
+ suggestions=[
+ "Use metric with aggregate, saved_metric, or sql_expression + label",
+ "Use dimensions (alias: groupby) for row hierarchy",
+ "Use ranges, markers, and marker_lines for comparison targets",
+ ],
+ error_code="BULLET_VALIDATION_ERROR",
+ )
+
+ def build_query_dicts(
+ self,
+ form_data: dict[str, Any],
+ *,
+ viz_type: str,
+ engine: str,
+ row_limit: int | None,
+ order_desc: bool | None,
+ ) -> list[dict[str, Any]] | None:
+ """Mirror ``Bullet/buildQuery``: one metric over the groupby hierarchy.
+
+ The frontend keeps only explicitly saved ordering; it never applies
+ the legacy top-N metric ordering of unadapted visualizations.
+ """
+ from superset.common.form_data_query_context import (
+ build_query_objects_from_form_data,
+ )
+
+ queries = build_query_objects_from_form_data(
+ form_data,
+ viz_type=viz_type,
+ row_limit=row_limit,
+ order_desc=order_desc,
+ filters_prepared=True,
+ )
+ query = queries[0]
+ metric = form_data.get("metric")
+ query["columns"] = bullet_groupby_list(form_data.get("groupby"))
+ query["metrics"] = [metric] if metric else []
+ if not form_data.get("orderby") and not form_data.get("order_by_cols"):
+ query["orderby"] = []
+ return queries
+
+ def normalize_query_result(self, result: Any, form_data: Mapping[str, Any]) -> Any:
+ """Reject results that cannot size a Bullet chart without guessing."""
+ from superset.mcp_service.chart.query_result import first_query_data
+
+ rows, failure = first_query_data(
+ result, none_as_empty=False, temporal_json_numbers=True
+ )
+ if failure is not None:
+ return failure
+ assert rows is not None
+ if (error := _render_model_error(rows, form_data)) is not None:
+ return error
+ return result
+
+ def sanitize_data_rows(
+ self, data: list[Any], form_data: Mapping[str, Any]
+ ) -> tuple[list[Any], ChartError | None]:
+ """Expose validated rows independently of backend preview formatter support."""
+ from superset.mcp_service.chart.preview_utils import (
+ _safe_enum_backing,
+ BulletOutputError,
+ resolve_bullet_render_model,
+ )
+ from superset.mcp_service.chart.response_preflight import (
+ bounded_exception_message,
+ )
+
+ try:
+ model = resolve_bullet_render_model(
+ data, dict(form_data), validate_format=False, bound_labels=False
+ )
+ except BulletOutputError as ex:
+ return [], ChartError(
+ error=bounded_exception_message(ex), error_type=ex.error_type
+ )
+ if not data:
+ # The strict model's zero-valued ungrouped row is a render-only
+ # frontend fallback, not source query data, so an empty query
+ # exposes no rows to get-data and exports.
+ return [], None
+ # The strict model retains exact result keys while replacing unselected
+ # values with None and normalizing the dimensions. Its float measure is
+ # render-only: exported rows keep the validated source metric so exact
+ # BIGINT/Decimal values are not rounded to binary64.
+ # An ungrouped model keeps only the rendered first row; every later
+ # source row was validated by the same resolver and stays exported.
+ model_rows = list(model.rows)
+ if not model.dimensions:
+ try:
+ for source in data[len(model_rows) :]:
+ model_rows.extend(
+ resolve_bullet_render_model(
+ [source],
+ dict(form_data),
+ validate_format=False,
+ bound_labels=False,
+ ).rows
+ )
+ except BulletOutputError as ex:
+ return [], ChartError(
+ error=bounded_exception_message(ex), error_type=ex.error_type
+ )
+ rows: list[Any] = []
+ for source, row in zip(data, model_rows, strict=True):
+ exposed = dict(row)
+ if model.metric_field in source:
+ exposed[model.metric_field] = _safe_enum_backing(
+ source[model.metric_field]
+ )
+ rows.append(exposed)
+ return rows, None
+
+ def ascii_preview(
+ self,
+ data: list[Any],
+ form_data: dict[str, Any],
+ width: int,
+ height: int = 20,
+ ) -> str | ChartError | None:
+ from superset.mcp_service.chart.preview_utils import (
+ _generate_ascii_bullet_chart,
+ BulletOutputError,
+ )
+ from superset.mcp_service.chart.response_preflight import (
+ bounded_exception_message,
+ )
+
+ try:
+ return _generate_ascii_bullet_chart(data, form_data)
+ except BulletOutputError as ex:
+ return ChartError(
+ error=bounded_exception_message(ex), error_type=ex.error_type
+ )
+
+ def table_preview(
+ self, data: list[Any], form_data: dict[str, Any]
+ ) -> TablePreview | ChartError | None:
+ return _unsupported_table_preview()
+
+ def unsupported_preview(self, preview_format: str) -> ChartError | None:
+ if preview_format == "table":
+ return _unsupported_table_preview()
+ return None
+
+ def vega_lite_preview(
+ self, data: list[Any], form_data: dict[str, Any]
+ ) -> VegaLitePreview | ChartError | None:
+ from superset.mcp_service.chart.preview_utils import (
+ _generate_bullet_vega_lite_preview,
+ BulletOutputError,
+ )
+ from superset.mcp_service.chart.response_preflight import (
+ bounded_exception_message,
+ )
+
+ try:
+ return _generate_bullet_vega_lite_preview(data, form_data)
+ except BulletOutputError as ex:
+ return ChartError(
+ error=bounded_exception_message(ex), error_type=ex.error_type
+ )
+
+ def resolve_update_config(
+ self,
+ config: Any,
+ existing_form_data: dict[str, Any],
+ *,
+ dataset_rebind: bool,
+ ) -> Any:
+ """Resolve sort targets against the saved hierarchy before mapping."""
+ if not isinstance(config, BulletChartConfig) or config.dimensions is not None:
+ return config
+ if not config.order_by:
+ return config
+ dimensions = (
+ bullet_groupby_list(existing_form_data.get("groupby"))
+ if not dataset_rebind and existing_form_data.get("viz_type") == "bullet"
+ else []
+ )
+ resolved = config.model_copy(deep=True)
+ resolved._inherited_groupby = deepcopy(dimensions)
+ return resolved.validate_roles_and_outputs()
+
+ def merge_update_form_data(
+ self,
+ existing_form_data: dict[str, Any],
+ new_form_data: dict[str, Any],
+ config: Any,
+ *,
+ dataset_rebind: bool,
+ ) -> dict[str, Any] | None:
+ """Merge filter provenance and preserve omitted native Bullet controls."""
+ if not isinstance(config, BulletChartConfig) or dataset_rebind:
+ return None
+ from superset.mcp_service.chart.chart_utils import (
+ _normalize_bullet_query_aliases,
+ merge_bullet_form_data,
+ merge_update_form_data,
+ )
+
+ saved_form_data = existing_form_data
+ if "orderby" in new_form_data:
+ # An explicit ordering replaces every saved ordering alias, so a
+ # stale or malformed saved alias is never parsed or inherited.
+ existing_form_data = {
+ key: value
+ for key, value in existing_form_data.items()
+ if key not in {"orderby", "order_by_cols"}
+ }
+ if existing_form_data.get("viz_type") == "bullet":
+ existing_form_data = _normalize_bullet_query_aliases(existing_form_data)
+ merged = dict(new_form_data)
+ if "row_limit" not in config.model_fields_set:
+ # The mapper always emits the schema default; an omitted limit
+ # keeps the saved value instead.
+ merged.pop("row_limit", None)
+ merge_update_form_data(existing_form_data, merged, config)
+ merge_bullet_form_data(existing_form_data, merged)
+ if "row_limit" not in merged and "row_limit" in new_form_data:
+ merged["row_limit"] = new_form_data["row_limit"]
+ # Like the shared overlay, unmodeled native presentation controls
+ # survive a same-viz update; query roles of other visualizations and
+ # legacy predicate aliases (already folded into adhoc filters) do not.
+ # The time-binding provenance marker follows the merged filters, so it
+ # is never restored once the merge decided the filter sequence.
+ excluded = {"where", "having", "filters"}
+ if "adhoc_filters" in merged:
+ excluded.add(MCP_DASHBOARD_TIME_FILTER_SUBJECT)
+ for key, value in saved_form_data.items():
+ if (
+ key not in merged
+ and key not in self.query_role_keys
+ and key not in excluded
+ ):
+ merged[key] = value
+ return merged
+
+ def validate_merged_form_data(
+ self,
+ form_data: Mapping[str, Any],
+ dataset_id: int | str | None,
+ dataset_context: Callable[[], Any] | None = None,
+ update_config: Any = None,
+ ) -> Any | None:
+ """Validate the merged state; canonicalize it when dataset metadata exists."""
+ from superset.mcp_service.chart.chart_utils import (
+ validate_merged_bullet_form_data,
+ )
+
+ merged = validate_merged_bullet_form_data(form_data, update_config)
+ if merged is None or dataset_context is None or dataset_id is None:
+ return merged
+ return DatasetValidator.normalize_column_names(
+ merged, dataset_id, dataset_context=dataset_context()
+ )
diff --git a/superset/mcp_service/chart/plugins/gantt.py b/superset/mcp_service/chart/plugins/gantt.py
index 544994705ca..34ba0d1fc36 100644
--- a/superset/mcp_service/chart/plugins/gantt.py
+++ b/superset/mcp_service/chart/plugins/gantt.py
@@ -309,6 +309,7 @@ class GanttChartPlugin(BaseChartPlugin):
form_data: Mapping[str, Any],
dataset_id: int | str | None,
dataset_context: Callable[[], Any] | None = None,
+ update_config: Any = None,
) -> Any | None:
from superset.mcp_service.chart.chart_utils import validate_gantt_form_data
diff --git a/superset/mcp_service/chart/plugins/geographic.py b/superset/mcp_service/chart/plugins/geographic.py
index 217b40a26f9..b4f7485f8d1 100644
--- a/superset/mcp_service/chart/plugins/geographic.py
+++ b/superset/mcp_service/chart/plugins/geographic.py
@@ -590,6 +590,7 @@ class WorldMapChartPlugin(GeographicChartPlugin):
form_data: Mapping[str, Any],
dataset_id: int | str | None,
dataset_context: Any = None,
+ update_config: Any = None,
) -> Any | None:
"""Reject merged color and bubble metrics that share a label.
@@ -689,6 +690,7 @@ class DeckScatterChartPlugin(GeographicChartPlugin):
form_data: Mapping[str, Any],
dataset_id: int | str | None,
dataset_context: Any = None,
+ update_config: Any = None,
) -> Any | None:
"""Reject effective roles that conflict with native spatial features.
diff --git a/superset/mcp_service/chart/preview_utils.py b/superset/mcp_service/chart/preview_utils.py
index 5d3db543bc8..0f73e990464 100644
--- a/superset/mcp_service/chart/preview_utils.py
+++ b/superset/mcp_service/chart/preview_utils.py
@@ -24,14 +24,19 @@ from form data without requiring a saved chart object.
import logging
import math
+import re
import unicodedata
from copy import deepcopy
+from dataclasses import dataclass
from datetime import date, datetime, time, timezone
-from decimal import Decimal
+from decimal import Decimal, localcontext
+from enum import Enum
from numbers import Real
from typing import Any, Dict, List
from uuid import UUID
+import numpy as np
+
from superset.mcp_service.chart.chart_helpers import canonicalize_operation_form_data
from superset.mcp_service.chart.query_result import (
first_query_data,
@@ -50,6 +55,7 @@ from superset.mcp_service.chart.sunburst import (
normalize_and_validate_sunburst_result_data,
resolve_sunburst_result_roles,
)
+from superset.utils import json
from superset.utils.core import get_column_name
logger = logging.getLogger(__name__)
@@ -286,11 +292,18 @@ def _generate_ascii_preview_from_data(
renderer = _GENERIC_ASCII_RENDERERS.get(viz_type, _generate_safe_ascii_table)
content = renderer(data)
- content = "\n".join(
- _truncate_display_line(line, width) for line in content.splitlines()[:height]
- )
return ASCIIPreview(
- ascii_content=content, width=width, height=height, supports_color=False
+ ascii_content=fit_ascii_frame(content, width, height),
+ width=width,
+ height=height,
+ supports_color=False,
+ )
+
+
+def fit_ascii_frame(content: str, width: int, height: int) -> str:
+ """Clip rendered ASCII text to the reported ``width`` x ``height`` frame."""
+ return "\n".join(
+ _truncate_display_line(line, width) for line in content.splitlines()[:height]
)
@@ -1828,7 +1841,1080 @@ def generate_xy_vega_lite_preview(
spec["transform"] = [
{"fold": series_columns, "as": [series_field, value_field]}
]
- encoding["y"] = {"field": value_field, "type": "quantitative"}
+ # Vega-Lite stacks bar and area marks colored by a nominal field
+ # by default; follow the chart's saved ``stack`` control instead.
+ encoding["y"] = {
+ "field": value_field,
+ "type": "quantitative",
+ "stack": _xy_vega_lite_stack(form_data.get("stack")),
+ }
encoding["color"] = {"field": series_field, "type": "nominal"}
+ if (
+ spec.get("mark") == "bar"
+ and encoding["y"]["stack"] is None
+ and "x" in encoding
+ ):
+ # Unstacked bars sit side by side like Explore's grouped bars.
+ # Offset channels need a discrete band x scale, so temporal
+ # values stay date-formatted through a millisecond time unit so
+ # sub-second timestamps keep distinct bands as in Explore.
+ x_encoding = encoding["x"]
+ if x_encoding.get("type") == "temporal":
+ x_encoding["timeUnit"] = (
+ "yearmonthdatehoursminutessecondsmilliseconds"
+ )
+ x_encoding["type"] = "ordinal"
+ encoding["xOffset"] = {"field": series_field, "type": "nominal"}
return preview
+
+
+def _xy_vega_lite_stack(stack: Any) -> str | None:
+ """Map the ECharts timeseries ``stack`` control to a Vega-Lite stack."""
+ if not stack:
+ return None
+ return "normalize" if stack == "Expand" else "zero"
+
+
+def _truncate_utf8(value: str, max_bytes: int) -> str:
+ """Return bounded, replacement-decoded UTF-8 text.
+
+ Encoding even the non-truncated path is intentional: Python strings may
+ contain unpaired surrogates, while MCP/JSON responses must always be valid
+ UTF-8. Slicing by characters before encoding also prevents an
+ attacker-sized string from being encoded in full.
+ """
+ if max_bytes <= 0:
+ return ""
+ candidate = value[:max_bytes]
+ encoded = candidate.encode("utf-8", errors="replace")
+ if len(encoded) <= max_bytes and len(candidate) == len(value):
+ return encoded.decode("utf-8", errors="replace")
+ suffix = "... [truncated]"
+ suffix_bytes = suffix.encode()
+ if max_bytes <= len(suffix_bytes):
+ return suffix_bytes[:max_bytes].decode("ascii")
+ content_limit = max(0, max_bytes - len(suffix_bytes))
+ content = encoded[:content_limit].decode("utf-8", errors="ignore")
+ return content + suffix
+
+
+_MAX_BULLET_FIELDS = 256
+
+
+_MAX_BULLET_FIELD_BYTES = 1000
+
+
+_MAX_BULLET_TEXT_BYTES = 2000
+
+
+# ECMAScript WhiteSpace and LineTerminator characters used by trim/Number.
+_JAVASCRIPT_WHITESPACE = (
+ "\t\n\v\f\r \u00a0\u1680\u2000\u2001\u2002\u2003\u2004"
+ "\u2005\u2006\u2007\u2008\u2009\u200a\u2028\u2029\u202f\u205f\u3000\ufeff"
+)
+
+
+_MAX_BULLET_TOKENS = 256
+
+
+# A comma-joined presentation control holds up to _MAX_BULLET_TOKENS tokens of
+# _MAX_BULLET_TEXT_BYTES each, so the whole-control bound must cover that many
+# tokens plus separators rather than reuse the per-token bound.
+_MAX_BULLET_CONTROL_CHARS = _MAX_BULLET_TOKENS * (_MAX_BULLET_TEXT_BYTES + 1)
+
+
+_ENUM_SCALAR_TYPES = (str, int, float, bool, Decimal)
+
+
+class BulletOutputError(ValueError):
+ """A Bullet query result cannot be rendered without guessing its roles."""
+
+ def __init__(self, message: str, error_type: str = "MalformedBulletOutput") -> None:
+ super().__init__(message)
+ self.error_type = error_type
+
+
+@dataclass(frozen=True)
+class BulletRenderModel:
+ """Strict, frontend-aligned data and presentation roles for one preview."""
+
+ rows: list[dict[str, Any]]
+ metric_field: str
+ dimensions: list[str]
+ measures: list[float]
+ ranges: list[float]
+ range_labels: list[str]
+ markers: list[float]
+ marker_labels: list[str]
+ marker_lines: list[float]
+ marker_line_labels: list[str]
+ y_axis_format: str
+ show_labels: bool
+ show_legend: bool
+
+
+def _form_metric_label(metric: Any) -> str | None:
+ """Return the result-column label for a native QueryFormMetric."""
+ if type(metric) is str:
+ return metric
+ if type(metric) is not dict:
+ return None
+ if label := dict.get(metric, "label"):
+ return label if type(label) is str else None
+ if dict.get(metric, "expressionType") == "SQL":
+ expression = dict.get(metric, "sqlExpression")
+ return expression if type(expression) is str and expression else None
+ column = dict.get(metric, "column")
+ column_name = dict.get(column, "column_name") if type(column) is dict else column
+ aggregate = dict.get(metric, "aggregate")
+ if type(column_name) is str and type(aggregate) is str:
+ return f"{aggregate}({column_name})"
+ return None
+
+
+def _form_column_label(column: Any) -> str | None:
+ """Return the result-column label for a native QueryFormColumn."""
+ if type(column) is str:
+ return column
+ if type(column) is not dict:
+ return None
+ # getColumnLabel: an adhoc column without a label is keyed by its SQL.
+ for key in ("label", "sqlExpression", "column_name"):
+ if type(value := dict.get(column, key)) is str and value:
+ return value
+ return None
+
+
+def _require_result_field(label: str | None, row: dict[str, Any], role: str) -> str:
+ """Resolve a role without falling back to an unrelated result field."""
+ if not label:
+ raise BulletOutputError(f"Bullet {role} has no declared result alias")
+ if label in dict.keys(row):
+ return label
+ matches = sorted(
+ field
+ for field in dict.keys(row)
+ if type(field) is str and field.casefold() == label.casefold()
+ )
+ if len(matches) == 1:
+ return matches[0]
+ if matches:
+ raise BulletOutputError(
+ f"Bullet {role} alias {label!r} is ambiguous; candidates: "
+ f"{', '.join(matches)}"
+ )
+ raise BulletOutputError(
+ f"Bullet {role} alias {label!r} is missing from query output"
+ )
+
+
+def _safe_enum_backing(value: Any) -> Any:
+ """Extract Enum's stored value without public descriptors/conversions."""
+ value_type = type(value)
+ try:
+ mro = type.__getattribute__(value_type, "__mro__")
+ except (AttributeError, TypeError): # pragma: no cover - normal types have MRO
+ return value
+ if type(mro) is not tuple or not any(base is Enum for base in mro):
+ return value
+ try:
+ backing = object.__getattribute__(value, "_value_")
+ except Exception as ex:
+ raise BulletOutputError("Bullet output contains an unreadable enum") from ex
+ if not any(type(backing) is allowed for allowed in _ENUM_SCALAR_TYPES):
+ raise BulletOutputError("Bullet output contains an unsupported enum value")
+ return backing
+
+
+def _decimal_javascript_string(value: Decimal) -> str:
+ """Render an exact binary64 spelling with JavaScript Number thresholds."""
+ sign, digits_tuple, exponent = Decimal.as_tuple(value)
+ if type(exponent) is not int: # finite Decimals always have an integer exponent
+ raise BulletOutputError("Bullet dimension contains a non-finite Decimal")
+ if not any(digits_tuple):
+ return "0"
+
+ digits = "".join(str(digit) for digit in digits_tuple)
+ adjusted = len(digits) + exponent - 1
+ prefix = "-" if sign else ""
+ if -6 <= adjusted < 21:
+ point = len(digits) + exponent
+ if point <= 0:
+ text = f"0.{('0' * -point)}{digits}"
+ elif point >= len(digits):
+ text = digits + ("0" * (point - len(digits)))
+ else:
+ text = f"{digits[:point]}.{digits[point:]}"
+ if "." in text:
+ text = text.rstrip("0").rstrip(".")
+ return prefix + text
+
+ fraction = digits[1:].rstrip("0")
+ coefficient = digits[0] + (f".{fraction}" if fraction else "")
+ exponent_text = f"+{adjusted}" if adjusted >= 0 else str(adjusted)
+ return f"{prefix}{coefficient}e{exponent_text}"
+
+
+def _javascript_number_string(value: int | float | Decimal) -> str:
+ """Apply JSON-number -> IEEE-754 Number -> JavaScript String semantics.
+
+ Exact result scalars can retain precision that the frontend cannot: JSON
+ parsing first rounds a numeric token to binary64, and ``String`` then emits
+ the shortest round-tripping decimal with fixed notation for exponents in
+ [-6, 20]. Converting exact builtin scalars to an exact builtin float keeps
+ the path hook-free. Python and JavaScript use the same shortest
+ round-tripping binary64 digits; ``_decimal_javascript_string`` only adjusts
+ the notation thresholds and exponent spelling.
+
+ A finite integer or Decimal outside binary64's range becomes an infinity
+ after JSON parsing, matching JavaScript. Non-finite source values are
+ rejected by the trusted scalar normalizer before this helper is called.
+ """
+ value_type = type(value)
+ if value_type not in {int, float, Decimal}:
+ raise BulletOutputError("Bullet dimension contains an unsupported number")
+ if value_type is float and not math.isfinite(value):
+ raise BulletOutputError("Bullet dimension contains a non-finite number")
+ if isinstance(value, Decimal) and not Decimal.is_finite(value):
+ raise BulletOutputError("Bullet dimension contains a non-finite Decimal")
+ try:
+ number = float(value)
+ except OverflowError:
+ number = -math.inf if value < 0 else math.inf
+
+ if math.isinf(number):
+ return "-Infinity" if number < 0 else "Infinity"
+ if number == 0:
+ # String(-0) is "0" even though JSON.parse preserves negative zero.
+ return "0"
+ return _decimal_javascript_string(Decimal(float.__repr__(number)))
+
+
+def _bullet_category_value( # noqa: C901
+ value: Any, dimension: str, row_index: int, *, bound_labels: bool = True
+) -> tuple[Any, str]:
+ """Return a JSON-safe value and bounded frontend ``String(value)`` text.
+
+ The trusted scalar normalizer is type-exact and does not dispatch through
+ application hooks. Vega data retains the normalized Chart Data wire value
+ (including epoch-ms temporal numbers); only the derived category key and
+ ASCII label use the JavaScript-compatible text.
+
+ With ``bound_labels=False`` (raw row export) no label text is derived, so
+ string values keep the shared result-cell limit instead of the label bound.
+ """
+ from superset.mcp_service.chart.query_result import (
+ _bounded_utf8_length,
+ _chart_data_duration_text,
+ _chart_data_temporal_number,
+ _is_chart_data_duration_scalar,
+ _is_chart_data_temporal_scalar,
+ _normalize_scalar,
+ MAX_RESULT_STRING_LENGTH,
+ )
+
+ text_limit = _MAX_BULLET_TEXT_BYTES if bound_labels else MAX_RESULT_STRING_LENGTH
+
+ normalized: Any
+ reason: str | None
+ if type(value) in {list, tuple, dict}:
+ data, failure = first_query_data(
+ {"queries": [{"data": [{"value": value}]}]}, temporal_json_numbers=True
+ )
+ if failure is not None or data is None:
+ raise BulletOutputError(
+ f"Bullet dimension {dimension!r} row {row_index} "
+ "has an invalid or unbounded container value"
+ )
+ normalized = data[0]["value"]
+ reason = None
+ elif _is_chart_data_temporal_scalar(value := _safe_enum_backing(value)):
+ normalized, reason = _chart_data_temporal_number(value)
+ elif _is_chart_data_duration_scalar(value):
+ normalized, reason = _chart_data_duration_text(value)
+ else:
+ normalized, reason = _normalize_scalar(value)
+ if (
+ reason is None
+ and normalized is None
+ and value is not None
+ and type(value) in {float, np.float16, np.float32, np.float64}
+ and math.isinf(float(value))
+ ):
+ # NaN keeps the shared null normalization, but an infinite source
+ # value has no faithful category text and is rejected.
+ reason = "contains a non-finite number"
+ if (
+ reason is None
+ and type(normalized) is str
+ and _bounded_utf8_length(normalized, text_limit) is None
+ ):
+ reason = "exceeds the size limit"
+ if reason is not None:
+ if reason == "an unsupported or subclassed value":
+ reason = "has an unsupported value type"
+ elif not reason.startswith(("contains ", "exceeds ", "has ")):
+ reason = f"contains {reason}"
+ raise BulletOutputError(
+ f"Bullet dimension {dimension!r} row {row_index} {reason}"
+ )
+ if not bound_labels:
+ return normalized, ""
+
+ value_type = type(normalized)
+ if value_type in {list, dict}:
+ text = _bullet_container_category_text(normalized, dimension, row_index)
+ elif normalized is None:
+ text = "null"
+ elif value_type is str:
+ text = normalized
+ elif value_type is bool:
+ text = "true" if normalized else "false"
+ elif value_type is int or value_type is float or value_type is Decimal:
+ text = _javascript_number_string(normalized)
+ else:
+ raise BulletOutputError(
+ f"Bullet dimension {dimension!r} row {row_index} has an "
+ "unsupported value type"
+ )
+
+ if _bounded_utf8_length(text, _MAX_BULLET_TEXT_BYTES) is None:
+ raise BulletOutputError(
+ f"Bullet dimension {dimension!r} row {row_index} exceeds the size limit"
+ )
+ return normalized, text
+
+
+def _bullet_container_category_text(value: Any, dimension: str, row_index: int) -> str:
+ """Stringify validated containers like JavaScript with a bounded text budget."""
+ from superset.mcp_service.chart.query_result import _bounded_utf8_length
+
+ if type(value) is dict:
+ return "[object Object]"
+ parts: list[str] = []
+ size = 0
+ for index, item in enumerate(value):
+ if type(item) in {list, dict}:
+ text = _bullet_container_category_text(item, dimension, row_index)
+ else:
+ text = (
+ ""
+ if item is None
+ else _bullet_category_value(item, dimension, row_index)[1]
+ )
+ text_size = _bounded_utf8_length(text, _MAX_BULLET_TEXT_BYTES)
+ size += (text_size if text_size is not None else _MAX_BULLET_TEXT_BYTES + 1) + (
+ index > 0
+ )
+ if size > _MAX_BULLET_TEXT_BYTES:
+ raise BulletOutputError(
+ f"Bullet dimension {dimension!r} row {row_index} exceeds the size limit"
+ )
+ parts.append(text)
+ return ",".join(parts)
+
+
+def _javascript_numeric_string(value: str) -> float:
+ """Parse a nonempty trimmed string using JavaScript Number's grammar."""
+ if re.fullmatch(r"0[xX][0-9a-fA-F]+|0[bB][01]+|0[oO][0-7]+", value):
+ return float(int(value, 0))
+ if re.fullmatch(
+ r"[+-]?(?:Infinity|(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+)(?:[eE][+-]?[0-9]+)?)",
+ value,
+ ):
+ return float(value)
+ raise ValueError("Invalid JavaScript number spelling")
+
+
+def _bullet_number(value: Any, row_index: int, metric_field: str) -> float:
+ """Apply the frontend's useful ``Number(value ?? 0)`` numeric subset."""
+ value = _safe_enum_backing(value)
+ if value is None:
+ number = 0.0
+ elif type(value) is bool:
+ raise BulletOutputError(
+ f"Bullet metric {metric_field!r} row {row_index} returned a boolean"
+ )
+ elif type(value) is int or type(value) is float or type(value) is Decimal:
+ try:
+ number = float(value)
+ except (TypeError, ValueError, OverflowError) as ex:
+ raise BulletOutputError(
+ f"Bullet metric {metric_field!r} row {row_index} is not numeric"
+ ) from ex
+ elif type(value) is str:
+ if len(value) > _MAX_BULLET_TEXT_BYTES:
+ raise BulletOutputError(
+ f"Bullet metric {metric_field!r} row {row_index} is not numeric"
+ )
+ stripped = value.strip(_JAVASCRIPT_WHITESPACE)
+ if not stripped:
+ raise BulletOutputError(
+ f"Bullet metric {metric_field!r} row {row_index} is not numeric"
+ )
+ try:
+ number = _javascript_numeric_string(stripped)
+ except (ValueError, OverflowError) as ex:
+ raise BulletOutputError(
+ f"Bullet metric {metric_field!r} row {row_index} returned "
+ f"non-numeric text"
+ ) from ex
+ else:
+ raise BulletOutputError(
+ f"Bullet metric {metric_field!r} row {row_index} is not numeric"
+ )
+ if not math.isfinite(number):
+ raise BulletOutputError(
+ f"Bullet metric {metric_field!r} row {row_index} is NaN or infinite"
+ )
+ return number
+
+
+def _bullet_string_tokens(value: Any) -> list[str]:
+ """Parse labels exactly like the frontend's comma tokenizer."""
+ value = _safe_enum_backing(value)
+ if value is None:
+ return []
+ if type(value) is not str or len(value) > _MAX_BULLET_CONTROL_CHARS:
+ raise BulletOutputError("Bullet labels must be a bounded comma-separated list")
+ if not value.strip():
+ return []
+ tokens = value.split(",")
+ if len(tokens) > _MAX_BULLET_TOKENS:
+ raise BulletOutputError("Bullet labels exceed the item limit")
+ return [_truncate_utf8(token.strip(), _MAX_BULLET_TEXT_BYTES) for token in tokens]
+
+
+def _unique_bullet_derived_field(
+ rows: list[dict[str, Any]], base: str, reserved: tuple[str, ...] = ()
+) -> str:
+ """Return one internal key absent from result rows and prior derived keys."""
+ occupied = {key for row in rows for key in dict.keys(row)}
+ occupied.update(reserved)
+ candidate = base
+ suffix = 0
+ while candidate in occupied:
+ suffix += 1
+ candidate = f"{base}_{suffix}"
+ return candidate
+
+
+def _unique_bullet_category_field(rows: list[dict[str, Any]]) -> str:
+ """Return an internal category key absent from every query-result row."""
+ return _unique_bullet_derived_field(rows, "__mcp_bullet_category")
+
+
+def _validate_bullet_format(format_: Any, values: list[float]) -> str:
+ """Reject a presentation format the backend cannot reproduce."""
+ format_ = _safe_enum_backing(format_)
+ if format_ is None or format_ == "":
+ format_ = "SMART_NUMBER"
+ if type(format_) is not str or len(format_) > 50:
+ raise BulletOutputError(
+ "Bullet number format is unsupported by previews",
+ error_type="UnsupportedFormat",
+ )
+ from superset.utils.number_format import D3_FORMAT_RE
+
+ # Specifier length does not bound its requested output precision. Check the
+ # parsed precision before the formatter can allocate or round any value.
+ match = D3_FORMAT_RE.match(format_)
+ if match and match.group(8) is not None and int(match.group(8)) > 20:
+ raise BulletOutputError(
+ "Bullet number format precision must not exceed 20",
+ error_type="UnsupportedFormat",
+ )
+ try:
+ for value in values:
+ _format_bullet_number(format_, value)
+ except (TypeError, ValueError, OverflowError) as ex:
+ raise BulletOutputError(
+ f"Bullet number format {format_!r} is unsupported by previews",
+ error_type="UnsupportedFormat",
+ ) from ex
+ return format_
+
+
+def _format_bullet_number(format_: str, value: float) -> str:
+ """Format finite Bullet values, including the full binary-float range."""
+ from superset.utils.number_format import format_numeric
+
+ try:
+ # Binary64 has at most 309 integer digits. Leave room for the bounded
+ # fractional precision, percent scaling, and a rounding carry without
+ # changing the caller's Decimal context.
+ with localcontext() as context:
+ context.prec = max(context.prec, 334)
+ return format_numeric(format_, value)
+ except OverflowError:
+ # SMART_NUMBER's significant-digit rounding can overflow a finite float
+ # near DBL_MAX. Scientific repr remains deterministic and informative.
+ if format_ in {"SMART_NUMBER", "SMART_NUMBER_SIGNED"} and math.isfinite(value):
+ prefix = "+" if format_ == "SMART_NUMBER_SIGNED" and value > 0 else ""
+ return prefix + repr(value)
+ raise
+
+
+def _containing_bullet_range_label(
+ measure: float, ranges: list[float], labels: list[str]
+) -> str | None:
+ """Match the frontend's labelled containing-range tooltip selection."""
+ ascending = sorted(
+ (
+ (value, labels[index] if index < len(labels) else "")
+ for index, value in enumerate(ranges)
+ ),
+ key=lambda entry: entry[0],
+ )
+ for threshold, label in ascending:
+ if measure <= threshold:
+ return label or None
+ if ascending and ascending[-1][1]:
+ return f"> {ascending[-1][1]}"
+ return None
+
+
+def resolve_bullet_render_model( # noqa: C901
+ data: List[Dict[str, Any]],
+ form_data: Dict[str, Any],
+ *,
+ validate_format: bool = True,
+ bound_labels: bool = True,
+) -> BulletRenderModel:
+ """Resolve Bullet rows; check preview formatter support only when rendering.
+
+ ``bound_labels=False`` serves raw-data consumers (get_chart_data and its
+ exports): dimension values keep the shared result limits, and the preview
+ label bound applies only where category text is rendered.
+ """
+ if type(data) is not list:
+ raise BulletOutputError("Bullet query output must be an array of objects")
+ for row_index in range(list.__len__(data)):
+ row = list.__getitem__(data, row_index)
+ if type(row) is not dict:
+ raise BulletOutputError("Bullet query output must be an array of objects")
+ if dict.__len__(row) > _MAX_BULLET_FIELDS:
+ raise BulletOutputError("Bullet query row exceeds the field limit")
+ for key in dict.keys(row):
+ if type(key) is not str:
+ raise BulletOutputError("Bullet query row keys must be strings")
+ if len(key) > _MAX_BULLET_FIELD_BYTES:
+ raise BulletOutputError("Bullet query row key exceeds the size limit")
+
+ if type(form_data) is not dict:
+ raise BulletOutputError("Bullet form data must be an object")
+
+ metric_label = _form_metric_label(dict.get(form_data, "metric"))
+ if not metric_label:
+ raise BulletOutputError("Bullet metric has no declared result alias")
+ raw_groupby = dict.get(form_data, "groupby")
+ # Bullet/transformProps.ts reads ensureIsArray(groupby): a saved scalar
+ # column (or adhoc column object) is a one-level hierarchy.
+ if raw_groupby is None:
+ raw_groupby = []
+ elif type(raw_groupby) is str or type(raw_groupby) is dict:
+ raw_groupby = [raw_groupby]
+ if type(raw_groupby) is not list:
+ raise BulletOutputError("Bullet dimensions must be an array")
+ dimension_labels = [
+ _form_column_label(list.__getitem__(raw_groupby, index))
+ for index in range(list.__len__(raw_groupby))
+ ]
+ if any(not label for label in dimension_labels):
+ raise BulletOutputError("Bullet dimension has no declared result alias")
+
+ if data:
+ first_row = list.__getitem__(data, 0)
+ metric_field = _require_result_field(metric_label, first_row, "metric")
+ dimensions = [
+ _require_result_field(label, first_row, "dimension")
+ for label in dimension_labels
+ ]
+ else:
+ # The frontend accepts empty results. Ungrouped charts retain one
+ # zero-valued measure; grouped charts retain the declared roles but no
+ # categories or rows are fabricated.
+ metric_field = metric_label
+ dimensions = [label for label in dimension_labels if label is not None]
+
+ measures: list[float] = []
+ copied_rows: list[dict[str, Any]] = []
+ for index in range(list.__len__(data)):
+ row = list.__getitem__(data, index)
+ row_metric_field = _require_result_field(
+ metric_label, row, f"metric row {index}"
+ )
+ measure = _bullet_number(
+ dict.__getitem__(row, row_metric_field), index, metric_field
+ )
+ # Reserve every exact output key so the internal Vega category alias
+ # cannot collide with an unselected result field. Unselected values are
+ # deliberately replaced with None rather than converted or serialized.
+ copied: dict[str, Any] = dict.fromkeys(dict.keys(row))
+ copied[metric_field] = measure
+ for label, dimension in zip(dimension_labels, dimensions, strict=True):
+ row_dimension = _require_result_field(label, row, f"dimension row {index}")
+ dimension_value, _ = _bullet_category_value(
+ dict.__getitem__(row, row_dimension),
+ dimension,
+ index,
+ bound_labels=bound_labels,
+ )
+ copied[dimension] = dimension_value
+ copied_rows.append(copied)
+ measures.append(measure)
+
+ # The frontend validates/coerces the whole result array but renders only
+ # the first row for an ungrouped aggregate.
+ if not dimensions:
+ copied_rows = copied_rows[:1]
+ measures = measures[:1]
+ if not copied_rows:
+ copied_rows = [{metric_field: 0.0}]
+ measures = [0.0]
+
+ ranges = _bullet_numeric_control_tokens(dict.get(form_data, "ranges"), "ranges")
+ if not ranges:
+ # Match Bullet/transformProps.ts: the largest measure drives one
+ # qualitative band whose upper threshold is 110% of that measure.
+ ranges = [0.0, max(measures, default=0.0) * 1.1]
+ markers = _bullet_numeric_control_tokens(dict.get(form_data, "markers"), "markers")
+ marker_lines = _bullet_numeric_control_tokens(
+ dict.get(form_data, "marker_lines"), "marker lines"
+ )
+ all_numbers = [*measures, *ranges, *markers, *marker_lines]
+ if any(not math.isfinite(value) for value in all_numbers):
+ raise BulletOutputError("Bullet presentation values must be finite")
+
+ show_labels = dict.get(form_data, "show_labels", False)
+ show_legend = dict.get(form_data, "show_legend", False)
+ if type(show_labels) is not bool or type(show_legend) is not bool:
+ raise BulletOutputError("Bullet label and legend controls must be booleans")
+
+ range_labels = _bullet_string_tokens(dict.get(form_data, "range_labels"))
+ marker_labels = _bullet_string_tokens(dict.get(form_data, "marker_labels"))
+ marker_line_labels = _bullet_string_tokens(
+ dict.get(form_data, "marker_line_labels")
+ )
+ return BulletRenderModel(
+ rows=copied_rows,
+ metric_field=metric_field,
+ dimensions=dimensions,
+ measures=measures,
+ ranges=ranges,
+ range_labels=range_labels,
+ markers=markers,
+ marker_labels=marker_labels,
+ marker_lines=marker_lines,
+ marker_line_labels=marker_line_labels,
+ y_axis_format=(
+ _validate_bullet_format(
+ dict.get(form_data, "y_axis_format", "SMART_NUMBER"), all_numbers
+ )
+ if validate_format
+ else "SMART_NUMBER"
+ ),
+ show_labels=show_labels,
+ show_legend=show_legend,
+ )
+
+
+def _generate_ascii_bullet_chart(
+ data: List[Dict[str, Any]], form_data: Dict[str, Any]
+) -> str:
+ """Generate a horizontal Bullet preview from the shared strict model."""
+ model = resolve_bullet_render_model(data, form_data)
+ if model.dimensions and not model.rows:
+ return "No data available for grouped Bullet chart"
+ extent = (
+ max(
+ [abs(value) for value in [*model.measures, *model.ranges, *model.markers]],
+ default=1,
+ )
+ or 1
+ )
+ lines = [f"ASCII Bullet Chart — {model.metric_field}", "=" * 60]
+ for row_index, (row, value) in enumerate(
+ zip(model.rows[:10], model.measures[:10], strict=True)
+ ):
+ category = ", ".join(
+ _bullet_category_value(dict.get(row, field), field, row_index)[1]
+ for field in model.dimensions
+ )
+ category = category or "Measure"
+ width = round(abs(value) / extent * 32)
+ bar = "█" * width
+ formatted = _format_bullet_number(model.y_axis_format, value)
+ lines.append(f"{category[:20]:>20} |{bar:<32} {formatted}")
+
+ def labeled(values: list[float], labels: list[str], prefix: str) -> list[str]:
+ return [
+ f"{labels[index] if index < len(labels) and labels[index] else prefix}: "
+ f"{_format_bullet_number(model.y_axis_format, value)}"
+ for index, value in enumerate(values)
+ ]
+
+ # The frontend always draws range bands, markers, and marker lines;
+ # show_labels/show_legend only toggle their text. A text preview has no
+ # geometry, so the comparison targets are always listed.
+ lines.append("Key:")
+ lines.extend(
+ f" range {item}" for item in labeled(model.ranges, model.range_labels, "Range")
+ )
+ lines.extend(
+ f" marker {item}"
+ for item in labeled(model.markers, model.marker_labels, "Marker")
+ )
+ lines.extend(
+ f" line {item}"
+ for item in labeled(model.marker_lines, model.marker_line_labels, "Marker line")
+ )
+ return "\n".join(lines)
+
+
+def _bullet_numeric_control_tokens(value: Any, role: str) -> list[float]: # noqa: C901
+ """Drop non-numeric native tokens like Explore, retaining safety bounds."""
+ value = _safe_enum_backing(value)
+ if value is None or (type(value) is str and value == ""):
+ return []
+ if type(value) is str:
+ if len(value) > _MAX_BULLET_CONTROL_CHARS:
+ raise BulletOutputError(f"Bullet {role} exceeds the size limit")
+ tokens: list[Any] = value.split(",")
+ elif type(value) is list:
+ tokens = [
+ list.__getitem__(value, index) for index in range(list.__len__(value))
+ ]
+ else:
+ raise BulletOutputError(f"Bullet {role} must be a comma-separated list")
+ if len(tokens) > _MAX_BULLET_TOKENS:
+ raise BulletOutputError(f"Bullet {role} exceeds the item limit")
+
+ numbers: list[float] = []
+ for index, token in enumerate(tokens):
+ token = _safe_enum_backing(token)
+ if type(token) is str:
+ token = token.strip(_JAVASCRIPT_WHITESPACE)
+ if type(token) is str and token == "":
+ continue
+ if type(token) is bool or not (
+ type(token) is str
+ or type(token) is int
+ or type(token) is float
+ or type(token) is Decimal
+ ):
+ raise BulletOutputError(f"Bullet {role}[{index}] is not numeric")
+ if type(token) is str and len(token) > _MAX_BULLET_TEXT_BYTES:
+ raise BulletOutputError(f"Bullet {role}[{index}] is not numeric")
+ try:
+ number = (
+ _javascript_numeric_string(token)
+ if type(token) is str
+ else float(token)
+ )
+ except ValueError:
+ # Native controls tolerate stray text and incomplete input.
+ continue
+ except (TypeError, OverflowError) as ex:
+ raise BulletOutputError(f"Bullet {role}[{index}] is not numeric") from ex
+ if math.isnan(number):
+ continue
+ if not math.isfinite(number):
+ raise BulletOutputError(f"Bullet {role}[{index}] is NaN or infinite")
+ numbers.append(number)
+ return numbers
+
+
+def _generate_bullet_vega_lite_preview( # noqa: C901
+ data: List[Dict[str, Any]], form_data: Dict[str, Any]
+) -> VegaLitePreview:
+ """Build a horizontal layered preview from the shared strict model."""
+ model = resolve_bullet_render_model(data, form_data)
+
+ metric_reference = "".join(
+ "\\" + char if char in ".[]\\" else char for char in model.metric_field
+ )
+ category_field = _unique_bullet_category_field(model.rows)
+ row_field = _unique_bullet_derived_field(
+ model.rows, "__mcp_bullet_row", (category_field,)
+ )
+ containing_range_labels = (
+ [
+ _containing_bullet_range_label(measure, model.ranges, model.range_labels)
+ for measure in model.measures
+ ]
+ if model.range_labels
+ else [None] * len(model.rows)
+ )
+ range_tooltip_field = (
+ _unique_bullet_derived_field(
+ model.rows, "__mcp_bullet_range", (category_field, row_field)
+ )
+ if any(label is not None for label in containing_range_labels)
+ else None
+ )
+ values = []
+ for row_index, row in enumerate(model.rows):
+ copied = dict.copy(row)
+ copied[row_field] = row_index
+ copied[category_field] = (
+ ", ".join(
+ _bullet_category_value(dict.get(row, field), field, row_index)[1]
+ for field in model.dimensions
+ )
+ if model.dimensions
+ else ""
+ )
+ if (
+ range_tooltip_field is not None
+ and containing_range_labels[row_index] is not None
+ ):
+ copied[range_tooltip_field] = containing_range_labels[row_index]
+ values.append(copied)
+
+ # Explore uses indexed rows even when their display labels are identical.
+ category_labels = [row[category_field] for row in values]
+ vega_format = {
+ "SMART_NUMBER": "~s",
+ "SMART_NUMBER_SIGNED": "+~s",
+ }.get(model.y_axis_format, model.y_axis_format)
+ y_encoding = {
+ "field": row_field,
+ "type": "nominal",
+ "title": ", ".join(model.dimensions) if model.dimensions else None,
+ "sort": None,
+ "axis": {"labelExpr": f"{json.dumps(category_labels)}[datum.value]"},
+ }
+ tooltip = [
+ {
+ "field": category_field,
+ "type": "nominal",
+ "title": ", ".join(model.dimensions) if model.dimensions else None,
+ },
+ {
+ "field": metric_reference,
+ "type": "quantitative",
+ "title": model.metric_field,
+ "format": vega_format,
+ },
+ ]
+ if range_tooltip_field is not None:
+ tooltip.append(
+ {"field": range_tooltip_field, "type": "nominal", "title": "Range"}
+ )
+ axis_min = min(
+ 0.0,
+ *model.measures,
+ *model.ranges,
+ *model.markers,
+ *model.marker_lines,
+ )
+ axis_max = max(
+ [*model.measures, *model.ranges, *model.markers, *model.marker_lines]
+ )
+ if axis_min == axis_max:
+ axis_max = axis_min + (abs(axis_min) or 1)
+
+ def label_at(labels: list[str], index: int, value: float, prefix: str) -> str:
+ if index < len(labels) and labels[index]:
+ return labels[index]
+ return (
+ ""
+ if prefix == "Range"
+ else _format_bullet_number(model.y_axis_format, value)
+ )
+
+ def legend_color(name: str) -> dict[str, Any]:
+ return {
+ "datum": name,
+ "type": "nominal",
+ "legend": {"title": None} if model.show_legend else None,
+ }
+
+ label_field = _unique_bullet_derived_field(values, "__mcp_bullet_threshold_label")
+ value_field = _unique_bullet_derived_field(
+ values, "__mcp_bullet_threshold_value", (label_field,)
+ )
+
+ def threshold_details(
+ label: str, value: float, label_title: str, value_title: str
+ ) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
+ """Materialize constants as fields for valid Vega tooltip arrays."""
+ return (
+ [
+ {"calculate": json.dumps(label), "as": label_field},
+ {"calculate": json.dumps(value), "as": value_field},
+ ],
+ [
+ {"field": label_field, "type": "nominal", "title": label_title},
+ {
+ "field": value_field,
+ "type": "quantitative",
+ "title": value_title,
+ "format": vega_format,
+ },
+ ],
+ )
+
+ layers: list[dict[str, Any]] = []
+ range_entries = sorted(
+ [
+ (
+ threshold,
+ label_at(model.range_labels, index, threshold, "Range"),
+ )
+ for index, threshold in enumerate(model.ranges)
+ ],
+ key=lambda entry: entry[0],
+ reverse=True,
+ )
+ for index, (threshold, label) in enumerate(range_entries):
+ transforms, threshold_tooltip = threshold_details(
+ label, threshold, "Range", "Threshold"
+ )
+ layers.append(
+ {
+ "transform": transforms,
+ "mark": {
+ "type": "rect",
+ "opacity": max(0.08, 0.28 - index * 0.04),
+ },
+ "encoding": {
+ "x": {"datum": axis_min, "type": "quantitative"},
+ "x2": {"datum": threshold},
+ "y": y_encoding,
+ "color": legend_color(
+ (f"{label}: " if label else "")
+ + f"≤ {_format_bullet_number(model.y_axis_format, threshold)}"
+ ),
+ "tooltip": threshold_tooltip,
+ },
+ }
+ )
+ if model.show_labels and label:
+ layers.append(
+ {
+ "mark": {"type": "text", "align": "right", "dx": -3},
+ "encoding": {
+ "x": {"datum": threshold, "type": "quantitative"},
+ "y": y_encoding,
+ "text": {"value": label},
+ },
+ }
+ )
+ layers.append(
+ {
+ "mark": {"type": "bar", "tooltip": True, "size": 16},
+ "encoding": {
+ "x": {
+ "field": metric_reference,
+ "type": "quantitative",
+ "title": model.metric_field,
+ "scale": {"domain": [axis_min, axis_max]},
+ "axis": {"format": vega_format},
+ },
+ "y": y_encoding,
+ "tooltip": tooltip,
+ "color": legend_color(model.metric_field),
+ },
+ }
+ )
+ for index, marker in enumerate(model.markers):
+ label = label_at(model.marker_labels, index, marker, "Marker")
+ transforms, threshold_tooltip = threshold_details(
+ label, marker, "Marker", "Value"
+ )
+ layers.append(
+ {
+ "transform": transforms,
+ "mark": {
+ "type": "point",
+ "shape": "triangle-up",
+ "filled": True,
+ "size": 80,
+ },
+ "encoding": {
+ "x": {"datum": marker, "type": "quantitative"},
+ "y": y_encoding,
+ "color": legend_color(
+ f"{label}: {_format_bullet_number(model.y_axis_format, marker)}"
+ ),
+ "tooltip": threshold_tooltip,
+ },
+ }
+ )
+ if model.show_labels:
+ layers.append(
+ {
+ "mark": {"type": "text", "dy": 14},
+ "encoding": {
+ "x": {"datum": marker, "type": "quantitative"},
+ "y": y_encoding,
+ "text": {"value": label},
+ },
+ }
+ )
+ for index, marker_line in enumerate(model.marker_lines):
+ label = label_at(model.marker_line_labels, index, marker_line, "Marker line")
+ transforms, threshold_tooltip = threshold_details(
+ label, marker_line, "Marker line", "Value"
+ )
+ layers.append(
+ {
+ "transform": transforms,
+ "mark": {"type": "rule", "strokeWidth": 2},
+ "encoding": {
+ "x": {"datum": marker_line, "type": "quantitative"},
+ "color": legend_color(
+ f"{label}: "
+ f"{_format_bullet_number(model.y_axis_format, marker_line)}"
+ ),
+ "tooltip": threshold_tooltip,
+ },
+ }
+ )
+ # ECharts markLine renders its label regardless of show_labels. Keep
+ # Vega faithful by always materializing one label per reference line.
+ layers.append(
+ {
+ "transform": [{"aggregate": []}],
+ "mark": {"type": "text", "align": "right", "dx": -3, "dy": -8},
+ "encoding": {
+ "x": {"datum": marker_line, "type": "quantitative"},
+ "y": {"value": 8},
+ "text": {"value": label},
+ },
+ }
+ )
+
+ return VegaLitePreview(
+ type="vega_lite",
+ specification={
+ "$schema": "https://vega.github.io/schema/vega-lite/v5.json",
+ **(
+ {"description": "No data available for grouped Bullet chart"}
+ if model.dimensions and not model.rows
+ else {}
+ ),
+ "data": {"values": values},
+ "layer": layers,
+ "resolve": {"scale": {"color": "independent"}},
+ "usermeta": {
+ "bullet": {
+ "metric": model.metric_field,
+ "dimensions": model.dimensions,
+ "ranges": model.ranges,
+ "range_labels": model.range_labels,
+ "markers": model.markers,
+ "marker_labels": model.marker_labels,
+ "marker_lines": model.marker_lines,
+ "marker_line_labels": model.marker_line_labels,
+ "y_axis_format": model.y_axis_format,
+ "show_labels": model.show_labels,
+ "show_legend": model.show_legend,
+ }
+ },
+ },
+ supports_streaming=False,
+ )
diff --git a/superset/mcp_service/chart/query_result.py b/superset/mcp_service/chart/query_result.py
index 1bddc37ea65..975b976350e 100644
--- a/superset/mcp_service/chart/query_result.py
+++ b/superset/mcp_service/chart/query_result.py
@@ -49,6 +49,7 @@ from superset.utils.core import (
ExtraFiltersTimeColumnType,
GenericDataType,
)
+from superset.utils.dates import datetime_to_epoch, EPOCH
FAILED_QUERY_STATUSES = frozenset(
{"error", "failed", "stopped", "timed_out", "cancelled", "canceled"}
@@ -174,6 +175,10 @@ class _ResultBudget:
json_bytes: int = 0
metadata_items: int = 0
metadata_bytes: int = 0
+ # Charts whose frontend ``transformProps`` reads temporal values with
+ # ``Number()`` (for example Bullet) receive the Chart Data API's epoch-ms
+ # numbers and duration text instead of ISO-8601 strings.
+ temporal_json_numbers: bool = False
@dataclass(frozen=True)
@@ -782,6 +787,19 @@ def _timestamp_offset_without_hooks(value: pd.Timestamp) -> timezone | None:
def _canonical_datetime(value: datetime) -> tuple[str | None, str | None]:
"""Serialize an exact datetime through trusted timezone state only."""
+ canonical_value, reason = _canonical_datetime_value(value)
+ if canonical_value is None:
+ return None, reason
+ try:
+ return datetime.isoformat(canonical_value), None
+ except (OverflowError, TypeError, ValueError):
+ return None, "an invalid datetime"
+
+
+def _canonical_datetime_value(
+ value: datetime,
+) -> tuple[datetime | None, str | None]:
+ """Rebuild an exact datetime on a trusted timezone without its hooks."""
tzinfo = value.tzinfo
canonical_value = value
if tzinfo is not None and not any(
@@ -806,10 +824,7 @@ def _canonical_datetime(value: datetime) -> tuple[str | None, str | None]:
tzinfo=canonical_tz,
fold=value.fold,
)
- try:
- return datetime.isoformat(canonical_value), None
- except (OverflowError, TypeError, ValueError):
- return None, "an invalid datetime"
+ return canonical_value, None
def _canonical_time(value: time) -> tuple[str | None, str | None]:
@@ -856,6 +871,19 @@ def _canonical_time(value: time) -> tuple[str | None, str | None]:
def _canonical_timestamp(value: pd.Timestamp) -> tuple[str | None, str | None]:
"""Preserve a trusted timestamp's instant, offset, nanoseconds, and fold."""
+ canonical_value, reason = _canonical_timestamp_value(value)
+ if canonical_value is None:
+ return None, reason
+ try:
+ return pd.Timestamp.isoformat(canonical_value), None
+ except (KeyError, OverflowError, TypeError, ValueError):
+ return None, "an invalid timestamp"
+
+
+def _canonical_timestamp_value(
+ value: pd.Timestamp,
+) -> tuple[pd.Timestamp | None, str | None]:
+ """Rebuild an exact timestamp on a trusted timezone without its hooks."""
try:
tzinfo = value.tzinfo
if tzinfo is not None and not any(
@@ -876,11 +904,152 @@ def _canonical_timestamp(value: pd.Timestamp) -> tuple[str | None, str | None]:
value = pd.Timestamp(raw_value, unit=value.unit, tz="UTC").tz_convert(
canonical_tz
)
- return pd.Timestamp.isoformat(value), None
+ return value, None
except (KeyError, OverflowError, TypeError, ValueError):
return None, "an invalid timestamp"
+def _is_chart_data_temporal_scalar(value: Any) -> bool:
+ """Return whether an exact scalar has Chart Data temporal wire semantics."""
+ return type(value) in {
+ date,
+ datetime,
+ pd.Timestamp,
+ np.datetime64,
+ _PANDAS_NAT_TYPE,
+ }
+
+
+def _is_chart_data_duration_scalar(value: Any) -> bool:
+ """Return whether an exact scalar has Chart Data duration semantics."""
+ return type(value) in {timedelta, pd.Timedelta, np.timedelta64}
+
+
+def _chart_data_builtin_timedelta_text(value: timedelta) -> str:
+ """Reproduce ``format_timedelta`` without comparison or string hooks."""
+ total_microseconds = (
+ value.days * 86_400 + value.seconds
+ ) * 1_000_000 + value.microseconds
+ sign = "-" if total_microseconds < 0 else ""
+ remaining = abs(total_microseconds)
+ days, remaining = divmod(remaining, 86_400 * 1_000_000)
+ hours, remaining = divmod(remaining, 3_600 * 1_000_000)
+ minutes, remaining = divmod(remaining, 60 * 1_000_000)
+ seconds, microseconds = divmod(remaining, 1_000_000)
+ day_text = f"{days} {'day' if days == 1 else 'days'}, " if days else ""
+ fraction = f".{microseconds:06d}" if microseconds else ""
+ return f"{sign}{day_text}{hours}:{minutes:02d}:{seconds:02d}{fraction}"
+
+
+def _chart_data_pandas_timedelta_text(value: pd.Timedelta) -> str:
+ """Reproduce Chart Data ``format_timedelta`` output from exact fields."""
+ total_nanoseconds = (
+ (
+ object.__getattribute__(value, "days") * 86_400
+ + object.__getattribute__(value, "seconds")
+ )
+ * 1_000_000
+ + object.__getattribute__(value, "microseconds")
+ ) * 1_000 + object.__getattribute__(value, "nanoseconds")
+ sign = "-" if total_nanoseconds < 0 else ""
+ remaining = abs(total_nanoseconds)
+ days, remaining = divmod(remaining, 86_400 * 1_000_000_000)
+ hours, remaining = divmod(remaining, 3_600 * 1_000_000_000)
+ minutes, remaining = divmod(remaining, 60 * 1_000_000_000)
+ seconds, nanoseconds = divmod(remaining, 1_000_000_000)
+ if nanoseconds % 1_000:
+ fraction = f".{nanoseconds:09d}"
+ elif nanoseconds:
+ fraction = f".{nanoseconds // 1_000:06d}"
+ else:
+ fraction = ""
+ return f"{sign}{days} days {hours:02d}:{minutes:02d}:{seconds:02d}{fraction}"
+
+
+def _chart_data_duration_text(value: Any) -> tuple[str | None, str | None]:
+ """Project an exact duration through Chart Data's public JSON spelling.
+
+ Builtin and pandas durations are ``timedelta`` instances consumed by
+ ``format_timedelta``. Exact NumPy durations model the real DataFrame
+ producer boundary: unambiguous units are promoted to a pandas Timedelta,
+ NaT becomes JSON null, and ambiguous or overflowing units fail closed.
+ """
+ value_type = type(value)
+ if value_type is timedelta:
+ text = _chart_data_builtin_timedelta_text(value)
+ elif value_type is pd.Timedelta:
+ text = _chart_data_pandas_timedelta_text(value)
+ elif value_type is np.timedelta64:
+ if np.isnat(value):
+ return None, None
+ try:
+ pandas_value = pd.Timedelta(value)
+ except (OverflowError, TypeError, ValueError):
+ return None, "contains an invalid NumPy duration"
+ text = _chart_data_pandas_timedelta_text(pandas_value)
+ else:
+ return None, "contains an unsupported duration value"
+ if _bounded_utf8_length(text, MAX_RESULT_STRING_LENGTH) is None:
+ return None, "contains an oversized duration"
+ return text, None
+
+
+def _chart_data_temporal_number( # noqa: C901
+ value: Any,
+) -> tuple[float | None, str | None]:
+ """Project an exact date/datetime through Chart Data's epoch-ms wire form.
+
+ The public Chart Data API uses ``json_int_dttm_ser`` before the browser
+ parses the payload. Trusted canonicalizers first validate or replace
+ timezone implementations without arbitrary hooks, then the production
+ ``datetime_to_epoch`` helper preserves its exact float behavior.
+ """
+ value_type = type(value)
+ if value_type is _PANDAS_NAT_TYPE:
+ # json_int_dttm_ser produces NaN and the Chart Data response's
+ # ``ignore_nan=True`` projects it to JSON null.
+ return None, None
+ if value_type is np.datetime64:
+ if np.isnat(value):
+ return None, None
+ try:
+ value = pd.Timestamp(value)
+ except (OverflowError, TypeError, ValueError):
+ return None, "contains an invalid NumPy datetime"
+ value_type = type(value)
+ if value_type is pd.Timestamp:
+ canonical_timestamp, reason = _canonical_timestamp_value(value)
+ if reason is not None or canonical_timestamp is None:
+ return None, reason or "contains an invalid pandas timestamp"
+ try:
+ return datetime_to_epoch(canonical_timestamp), None
+ except (OverflowError, TypeError, ValueError):
+ return None, "contains an invalid pandas timestamp"
+ if value_type is datetime:
+ canonical_datetime, reason = _canonical_datetime_value(value)
+ if reason is not None or canonical_datetime is None:
+ return None, reason or "contains an invalid datetime"
+ try:
+ return datetime_to_epoch(canonical_datetime), None
+ except (OverflowError, TypeError, ValueError):
+ return None, "contains an invalid datetime"
+ if value_type is date:
+ return (value - EPOCH.date()).total_seconds() * 1000, None
+ return None, "contains an unsupported temporal value"
+
+
+def _normalize_chart_data_scalar(
+ value: Any, *, temporal_json_numbers: bool
+) -> tuple[Any, str | None]:
+ """Normalize a row scalar, optionally with Chart Data temporal numbers."""
+ if temporal_json_numbers:
+ if _is_chart_data_temporal_scalar(value):
+ return _chart_data_temporal_number(value)
+ if _is_chart_data_duration_scalar(value):
+ return _chart_data_duration_text(value)
+ return _normalize_scalar(value)
+
+
def _canonical_binary(
value: bytes | bytearray | memoryview,
) -> tuple[str | None, str | None]:
@@ -1176,7 +1345,10 @@ def _normalize_value( # noqa: C901
number = float(item)
if not math.isfinite(number) and not math.isnan(number):
return None, "a non-finite metadata number"
- normalized, reason = _normalize_scalar(item)
+ normalized, reason = _normalize_chart_data_scalar(
+ item,
+ temporal_json_numbers=budget.temporal_json_numbers and not metadata,
+ )
if reason is not None:
return None, reason
max_string_bytes = (
@@ -1430,7 +1602,10 @@ def _validate_query_metadata(query: dict[str, Any], index: int) -> ChartError |
def validate_query_result_envelope( # noqa: C901
- result: Any, *, none_as_empty: bool = False
+ result: Any,
+ *,
+ none_as_empty: bool = False,
+ temporal_json_numbers: bool = False,
) -> ChartError | None:
"""Canonicalize and strictly validate one real command result in place.
@@ -1443,7 +1618,7 @@ def validate_query_result_envelope( # noqa: C901
if type(result) is not dict:
return _invalid_result("a malformed result envelope")
- budget = _ResultBudget()
+ budget = _ResultBudget(temporal_json_numbers=temporal_json_numbers)
if reason := _charge_value(budget, metadata=True):
return _invalid_result(reason)
top_level_keys = list(dict.keys(result))
@@ -1640,7 +1815,9 @@ def _normalize_index_names(
return None
-def query_result_failure(result: Any) -> ChartError | None:
+def query_result_failure(
+ result: Any, *, temporal_json_numbers: bool = False
+) -> ChartError | None:
"""Return a structured failure embedded in a ChartDataCommand payload.
ChartDataCommand can return an HTTP-successful envelope whose top level or
@@ -1653,11 +1830,16 @@ def query_result_failure(result: Any) -> ChartError | None:
reported as failure-free, because every caller reads data from at least one
query.
"""
- return validate_query_result_envelope(result)
+ return validate_query_result_envelope(
+ result, temporal_json_numbers=temporal_json_numbers
+ )
def first_query_data(
- result: Any, *, none_as_empty: bool = False
+ result: Any,
+ *,
+ none_as_empty: bool = False,
+ temporal_json_numbers: bool = False,
) -> tuple[list[Any] | None, ChartError | None]:
"""Validate the full result and return its first canonical data array.
@@ -1665,7 +1847,11 @@ def first_query_data(
this with the strict default so hierarchy input can never silently become an
empty visualization.
"""
- if failure := validate_query_result_envelope(result, none_as_empty=none_as_empty):
+ if failure := validate_query_result_envelope(
+ result,
+ none_as_empty=none_as_empty,
+ temporal_json_numbers=temporal_json_numbers,
+ ):
return None, failure
queries = dict.__getitem__(result, "queries")
first_query = list.__getitem__(queries, 0)
@@ -1948,6 +2134,14 @@ def normalize_chart_query_result(result: Any, form_data: Mapping[str, Any]) -> A
return plugin.normalize_query_result(result, form_data)
+def temporal_json_numbers(viz_type: str | None) -> bool:
+ """Return whether the owning plugin reads temporals as Chart Data numbers."""
+ from superset.mcp_service.chart.registry import plugin_for_viz_type
+
+ plugin = plugin_for_viz_type(viz_type)
+ return plugin.temporal_json_numbers if plugin is not None else False
+
+
def null_data_is_empty(viz_type: str | None) -> bool:
"""Return whether the owning plugin reads a null ``data`` array as empty."""
from superset.mcp_service.chart.registry import plugin_for_viz_type
diff --git a/superset/mcp_service/chart/resources/chart_configs.py b/superset/mcp_service/chart/resources/chart_configs.py
index 603fe47d922..215775bcc5d 100644
--- a/superset/mcp_service/chart/resources/chart_configs.py
+++ b/superset/mcp_service/chart/resources/chart_configs.py
@@ -167,6 +167,44 @@ def get_chart_configs_resource() -> str:
},
}
+ # Bullet chart examples
+ bullet_chart_configs = {
+ "attainment_against_targets": {
+ "description": (
+ "Revenue attainment against qualitative thresholds and a plan marker"
+ ),
+ "config": {
+ "chart_type": "bullet",
+ "metric": {
+ "name": "revenue",
+ "aggregate": "SUM",
+ "label": "Revenue",
+ },
+ "ranges": [100000, 250000, 500000],
+ "range_labels": ["Minimum", "Target", "Stretch"],
+ "markers": [300000],
+ "marker_labels": ["Plan"],
+ "show_labels": True,
+ "y_axis_format": "$,.0f",
+ },
+ "use_cases": ["Goal attainment", "KPI target comparison"],
+ },
+ "regional_attainment": {
+ "description": "One bullet row per region using a saved metric",
+ "config": {
+ "chart_type": "bullet",
+ "metric": {"name": "attainment", "saved_metric": True},
+ "dimensions": [{"name": "region"}],
+ "marker_lines": [1],
+ "marker_line_labels": ["Goal"],
+ "show_legend": True,
+ "y_axis_format": ".0%",
+ "row_limit": 100,
+ },
+ "use_cases": ["Regional scorecards", "Progress by team"],
+ },
+ }
+
# Table chart examples
table_chart_configs = {
"basic_table": {
@@ -357,6 +395,12 @@ def get_chart_configs_resource() -> str:
"Apply sort_by to highlight important data",
"Use ag-grid-table viz_type for large interactive datasets",
],
+ "bullet_charts": [
+ "Use one numeric metric; COUNT/COUNT_DISTINCT work for text columns",
+ "Use dimensions only when separate bullet rows are meaningful",
+ "Keep range/marker labels aligned one-for-one with their values",
+ "Use marker_lines for hard targets and markers for point comparisons",
+ ],
"interactive_pivots": [
"Use rows for AG Grid row groups and columns for pivot labels",
"Metric aggregates also seed AG Grid's group aggregation model",
@@ -394,6 +438,7 @@ def get_chart_configs_resource() -> str:
resource_data = {
"xy_chart_configs": xy_chart_configs,
+ "bullet_chart_configs": bullet_chart_configs,
"table_chart_configs": table_chart_configs,
"sunburst_chart_configs": sunburst_chart_configs,
"interactive_pivot_configs": interactive_pivot_configs,
diff --git a/superset/mcp_service/chart/schemas.py b/superset/mcp_service/chart/schemas.py
index de8509235eb..cac3dc46866 100644
--- a/superset/mcp_service/chart/schemas.py
+++ b/superset/mcp_service/chart/schemas.py
@@ -25,7 +25,7 @@ import difflib
import logging
import math
import re
-from collections.abc import Mapping
+from collections.abc import Mapping, Sequence
from copy import deepcopy
from datetime import datetime, time
from typing import Annotated, Any, Dict, get_args, List, Literal, Protocol, Tuple
@@ -40,6 +40,7 @@ from pydantic import (
JsonValue,
model_serializer,
model_validator,
+ PrivateAttr,
StrictBool,
StrictInt,
StrictStr,
@@ -89,6 +90,7 @@ from superset.mcp_service.utils.serialization import (
OptionalRowCount,
RowCount,
)
+from superset.utils import json
logger = logging.getLogger(__name__)
@@ -3700,6 +3702,123 @@ def _metric_display_label(col: ColumnRef) -> str:
return col.label or col.name or ""
+def _require_unique_bullet_order_match(
+ matches: list[tuple[str, int | None]], target: str, role: str
+) -> tuple[str, int | None] | None:
+ """Return a unique sort-role match or reject ambiguity."""
+ if len(matches) == 1:
+ return matches[0]
+ if len(matches) > 1:
+ raise ValueError(f"ambiguous {role}: {target}")
+ return None
+
+
+def normalize_metric_aggregate(aggregate: str | None) -> str:
+ """Return the native aggregate, including defaulting and shorthand aliases."""
+ aliases = {"STDDEV": "STDDEV_SAMP", "VAR": "VAR_SAMP"}
+ native = aliases.get((aggregate or "SUM").upper(), (aggregate or "SUM").upper())
+ valid = {
+ "SUM",
+ "COUNT",
+ "AVG",
+ "MIN",
+ "MAX",
+ "COUNT_DISTINCT",
+ "STDDEV_SAMP",
+ "VAR_SAMP",
+ "MEDIAN",
+ "PERCENTILE",
+ }
+ return native if native in valid else "SUM"
+
+
+def _bullet_metric_output_label(metric: ColumnRef) -> str:
+ """Return the field name emitted by Bullet's native metric shape."""
+ if metric.saved_metric:
+ # Saved metrics are serialized as a bare metric-name string; a
+ # ColumnRef display label does not change the query output alias.
+ return metric.name or ""
+ if metric.sql_expression:
+ return metric.label or ""
+ return (
+ metric.label or f"{normalize_metric_aggregate(metric.aggregate)}({metric.name})"
+ )
+
+
+def resolve_bullet_order_target(
+ target: str,
+ dimensions: Sequence[ColumnRef | str | dict[str, Any]],
+ metric: ColumnRef,
+) -> tuple[str, int | None]:
+ """Resolve a Bullet sort target to one actual query output role.
+
+ Physical dimension names and the emitted metric label are authoritative.
+ Friendly dimension labels and a metric's source name are accepted only as
+ secondary input aliases. This makes an exact physical name win when, for
+ example, another dimension uses that name as its display label.
+ Inherited native dimensions use their query output labels instead.
+ """
+
+ from superset.mcp_service.chart.chart_helpers import _column_label
+
+ physical = [
+ (
+ dimension.name
+ if isinstance(dimension, ColumnRef)
+ else _column_label(dimension)
+ )
+ or ""
+ for dimension in dimensions
+ ]
+ metric_output = _bullet_metric_output_label(metric)
+
+ # Exact actual output fields are always preferred, with physical columns
+ # taking precedence over all display/source aliases.
+ for index, name in enumerate(physical):
+ if target == name:
+ return "dimension", index
+ if target == metric_output:
+ return "metric", None
+
+ actual_matches: list[tuple[str, int | None]] = [
+ ("dimension", index)
+ for index, name in enumerate(physical)
+ if name.casefold() == target.casefold()
+ ]
+ if metric_output.casefold() == target.casefold():
+ actual_matches.append(("metric", None))
+ if match := _require_unique_bullet_order_match(
+ actual_matches, target, "actual output"
+ ):
+ return match
+
+ aliases: list[tuple[str, int | None, str]] = [
+ ("dimension", index, dimension.label)
+ for index, dimension in enumerate(dimensions)
+ if isinstance(dimension, ColumnRef) and dimension.label
+ ]
+ for alias in (metric.name, metric.label):
+ if alias and alias != metric_output:
+ aliases.append(("metric", None, alias))
+
+ exact_aliases = [(kind, index) for kind, index, alias in aliases if alias == target]
+ if match := _require_unique_bullet_order_match(
+ exact_aliases, target, "display alias"
+ ):
+ return match
+
+ folded_aliases = [
+ (kind, index)
+ for kind, index, alias in aliases
+ if alias.casefold() == target.casefold()
+ ]
+ if match := _require_unique_bullet_order_match(
+ folded_aliases, target, "display alias"
+ ):
+ return match
+ raise ValueError(f"unknown: {target}")
+
+
def _coerce_sort_item(item: Any) -> Any:
if isinstance(item, str):
return SortByConfig(column=item, ascending=False)
@@ -3985,6 +4104,504 @@ class XYChartConfig(BaseChartConfig):
return self
+class BulletChartConfig(BaseChartConfig):
+ """Config for bullet charts (viz_type ``bullet``)."""
+
+ # Semantic field names are exposed to MCP clients; validation aliases and the
+ # native adapter accept saved Explore ``form_data`` without weakening the
+ # unknown-field checks that catch misspelled controls.
+ model_config = ConfigDict(extra="ignore", populate_by_name=True)
+
+ # Saved query roles are internal sort context, never typed authoring input.
+ # Keeping them out of dimensions preserves omission and native SQL shapes.
+ _inherited_groupby: list[str | dict[str, Any]] | None = PrivateAttr(default=None)
+
+ @property
+ def order_dimensions(self) -> Sequence[ColumnRef | str | dict[str, Any]]:
+ """Return authored dimensions or the inherited native sort hierarchy."""
+ if self.dimensions is None and self._inherited_groupby is not None:
+ return self._inherited_groupby
+ return self.dimensions or []
+
+ chart_type: Literal["bullet"] = "bullet"
+ metric: ColumnRef = Field(
+ ...,
+ description="Numeric measure shown by each bullet bar",
+ )
+ dimensions: List[ColumnRef] | None = Field(
+ None,
+ validation_alias=AliasChoices("dimensions", "groupby"),
+ description=(
+ "Category hierarchy; one bullet row per unique combination. Omit to "
+ "keep the saved hierarchy on update; [] clears it."
+ ),
+ max_length=20,
+ )
+ filters: List[FilterConfig] | None = Field(None, max_length=100)
+ time_range: str | None = Field(
+ None,
+ min_length=1,
+ max_length=1000,
+ description=(
+ "Superset time range, e.g. 'Last 30 days' or '2025-01-01 : 2025-12-31'"
+ ),
+ )
+ row_limit: int = Field(
+ 10000,
+ ge=1,
+ le=50000,
+ description="Maximum bullet rows",
+ )
+ order_by: List[SortByConfig] = Field(
+ default_factory=list,
+ validation_alias=AliasChoices("order_by", "orderby", "order_by_cols"),
+ max_length=20,
+ description="Row order by a dimension name or the metric output label/name",
+ )
+
+ # Presentation fields map one-for-one onto Bullet/transformProps.ts controls.
+ ranges: List[float] = Field(
+ default_factory=list,
+ max_length=100,
+ description="Qualitative range thresholds shaded behind the measure",
+ )
+ range_labels: List[str] = Field(
+ default_factory=list,
+ validation_alias=AliasChoices("range_labels", "rangeLabels"),
+ max_length=100,
+ )
+ markers: List[float] = Field(
+ default_factory=list,
+ max_length=100,
+ description="Target values drawn as point markers",
+ )
+ marker_labels: List[str] = Field(
+ default_factory=list,
+ validation_alias=AliasChoices("marker_labels", "markerLabels"),
+ max_length=100,
+ )
+ marker_lines: List[float] = Field(
+ default_factory=list,
+ validation_alias=AliasChoices("marker_lines", "markerLines"),
+ max_length=100,
+ description="Reference values drawn as vertical lines",
+ )
+ marker_line_labels: List[str] = Field(
+ default_factory=list,
+ validation_alias=AliasChoices("marker_line_labels", "markerLineLabels"),
+ max_length=100,
+ )
+ y_axis_format: str = Field(
+ "SMART_NUMBER",
+ validation_alias=AliasChoices("y_axis_format", "yAxisFormat"),
+ max_length=100,
+ )
+ show_labels: bool = Field(
+ False,
+ validation_alias=AliasChoices("show_labels", "showLabels"),
+ )
+ show_legend: bool = Field(
+ False,
+ validation_alias=AliasChoices("show_legend", "showLegend"),
+ )
+
+ @staticmethod
+ def _native_metric_label(value: dict[str, Any]) -> Any:
+ """Derive the output label used by native metrics and metric sorts."""
+ return value.get("label") or (
+ value.get("sqlExpression") if value.get("expressionType") == "SQL" else None
+ )
+
+ @classmethod
+ def _adapt_native_metric(cls, value: Any) -> Any:
+ """Translate QueryFormMetric shapes into the shared ColumnRef contract."""
+ if isinstance(value, str):
+ return {"name": value, "saved_metric": True}
+ if not isinstance(value, dict):
+ return value
+ if "expressionType" not in value:
+ # QueryObject's documented legacy saved-metric representation is a
+ # label-only object. Keep this adapter deliberately narrow: objects
+ # carrying ad-hoc fields must declare expressionType explicitly, and
+ # semantic ColumnRef objects continue through normal validation.
+ if set(value) == {"label"}:
+ label = value["label"]
+ if not isinstance(label, str) or not label or len(label) > 255:
+ raise ValueError(
+ "legacy saved metric label must be a non-empty string of "
+ "at most 255 characters"
+ )
+ return {"name": label, "saved_metric": True}
+ return value
+ expression_type = value.get("expressionType")
+ if expression_type == "SQL":
+ return {
+ "sql_expression": value.get("sqlExpression"),
+ "label": cls._native_metric_label(value),
+ }
+ if expression_type != "SIMPLE":
+ raise ValueError("metric.expressionType must be 'SIMPLE' or 'SQL'")
+ column = value.get("column")
+ if isinstance(column, dict):
+ name = column.get("column_name")
+ else:
+ name = column
+ return {
+ "name": name,
+ "aggregate": value.get("aggregate"),
+ "label": value.get("label"),
+ }
+
+ @staticmethod
+ def _canonical_dimension_alias(value: Any, field_name: str) -> list[str]:
+ """Canonicalize semantic/native dimension aliases for conflict checks."""
+ if not isinstance(value, list):
+ raise ValueError(f"{field_name} must be an array")
+ canonical: list[str] = []
+ for index, item in enumerate(value):
+ name: str | None
+ if isinstance(item, str):
+ name = item
+ elif isinstance(item, ColumnRef):
+ name = item.name
+ elif isinstance(item, dict):
+ name = next(
+ (
+ item[key]
+ for key in ("name", "column_name", "column")
+ if isinstance(item.get(key), str)
+ ),
+ None,
+ )
+ else:
+ name = None
+ if not name:
+ raise ValueError(
+ f"{field_name}[{index}] must identify a physical column"
+ )
+ canonical.append(name)
+ return canonical
+
+ @classmethod
+ def _adapt_native_order_by(cls, value: Any) -> Any: # noqa: C901
+ if value is None:
+ return []
+ if not isinstance(value, list):
+ raise ValueError("order_by must be an array")
+ result: list[Any] = []
+ for index, entry in enumerate(value):
+ if isinstance(entry, str):
+ if len(entry) > 2000:
+ raise ValueError(f"order_by[{index}] is too long")
+ try:
+ entry = json.loads(entry)
+ except json.JSONDecodeError:
+ # A bare output/column name is the ergonomic typed form.
+ result.append({"column": entry, "ascending": False})
+ continue
+ if isinstance(entry, dict):
+ result.append(entry)
+ continue
+ if not isinstance(entry, (list, tuple)) or len(entry) != 2:
+ raise ValueError(
+ f"order_by[{index}] must be [column, ascending_boolean]"
+ )
+ target, ascending = entry
+ if isinstance(target, dict):
+ target = cls._native_metric_label(target) or target.get("metric_name")
+ if not isinstance(target, str) or not target:
+ raise ValueError(f"order_by[{index}] needs a column or metric label")
+ if not isinstance(ascending, bool):
+ raise ValueError(f"order_by[{index}] ascending value must be boolean")
+ result.append({"column": target, "ascending": ascending})
+ return result
+
+ @staticmethod
+ def _adapt_native_filters(data: dict[str, Any]) -> None: # noqa: C901
+ if "adhoc_filters" not in data:
+ return
+ if "filters" in data:
+ raise ValueError("Use either filters or native adhoc_filters, not both")
+ raw_filters = data.pop("adhoc_filters")
+ if not isinstance(raw_filters, list):
+ raise ValueError("adhoc_filters must be an array")
+ filters: list[dict[str, Any]] = []
+ temporal_pairs: list[tuple[str, str]] = []
+ inert_subjects: set[str] = set()
+ for index, raw_filter in enumerate(raw_filters):
+ if not isinstance(raw_filter, dict):
+ raise ValueError(f"adhoc_filters[{index}] must be an object")
+ if raw_filter.get("expressionType") != "SIMPLE":
+ raise ValueError(
+ f"adhoc_filters[{index}] must use expressionType='SIMPLE'"
+ )
+ if raw_filter.get("clause") not in (None, "WHERE"):
+ raise ValueError(f"adhoc_filters[{index}] must use clause='WHERE'")
+ subject = raw_filter.get("subject")
+ operator = raw_filter.get("operator")
+ comparator = raw_filter.get("comparator")
+ if operator == "TEMPORAL_RANGE":
+ if not isinstance(subject, str) or not subject:
+ raise ValueError(
+ f"adhoc_filters[{index}] temporal filter needs subject"
+ )
+ if not isinstance(comparator, str) or not comparator:
+ raise ValueError(
+ f"adhoc_filters[{index}] temporal filter needs a range"
+ )
+ if comparator.casefold() == "no filter":
+ inert_subjects.add(subject)
+ else:
+ temporal_pairs.append((subject, comparator))
+ continue
+ if not isinstance(operator, str):
+ raise ValueError(f"adhoc_filters[{index}] needs an operator")
+ operator_map = {
+ "==": "=",
+ "EQUALS": "=",
+ "NOT_EQUALS": "!=",
+ "LESS_THAN": "<",
+ "LESS_THAN_OR_EQUAL": "<=",
+ "GREATER_THAN": ">",
+ "GREATER_THAN_OR_EQUAL": ">=",
+ "NOT_IN": "NOT IN",
+ "IS_NULL": "IS NULL",
+ "IS_NOT_NULL": "IS NOT NULL",
+ }
+ operator = operator_map.get(operator, operator)
+ filters.append({"column": subject, "op": operator, "value": comparator})
+ if len(temporal_pairs) > 1:
+ raise ValueError(
+ "Multiple active native temporal filters cannot be represented by "
+ "a single temporal_column/time_range pair"
+ )
+ if temporal_pairs:
+ subject, comparator = temporal_pairs[0]
+ if data.get("temporal_column") not in (None, subject) or data.get(
+ "time_range"
+ ) not in (None, "No filter", comparator):
+ raise ValueError(
+ "Native temporal filter conflicts with temporal_column/time_range"
+ )
+ data["temporal_column"] = subject
+ data["time_range"] = comparator
+ elif len(inert_subjects) == 1:
+ data.setdefault("temporal_column", next(iter(inert_subjects)))
+ data["filters"] = filters
+
+ @model_validator(mode="before")
+ @classmethod
+ def adapt_native_form_data(cls, raw: Any) -> Any: # noqa: C901
+ """Accept recognized saved Bullet form_data and reject ambiguous state."""
+ if not isinstance(raw, dict):
+ return raw
+ data = dict(raw)
+ # Null means no hierarchy was supplied, not a conflicting alias or a
+ # request to clear saved dimensions. Only an explicit [] clears them.
+ for key in ("dimensions", "groupby"):
+ if data.get(key) is None:
+ data.pop(key, None)
+ if "dimensions" in data and "groupby" in data:
+ dimensions = cls._canonical_dimension_alias(
+ data["dimensions"], "dimensions"
+ )
+ groupby = cls._canonical_dimension_alias(data["groupby"], "groupby")
+ if dimensions != groupby:
+ raise ValueError(
+ "Conflicting Bullet dimension aliases: 'dimensions' and "
+ "native 'groupby' must identify the same physical columns in "
+ "the same order; provide only one or make them equivalent"
+ )
+ # Avoid relying on AliasChoices precedence or JSON key order.
+ data.pop("groupby")
+ # The request normalizer maps viz_type to chart_type before this model
+ # runs and records native provenance with the shared marker instead.
+ native_marker = data.pop(_NATIVE_FORM_DATA_MARKER, False)
+ if data.get("viz_type") == "bullet" or native_marker is True:
+ from superset.mcp_service.chart.preview_utils import (
+ _bullet_numeric_control_tokens,
+ )
+
+ data.setdefault("chart_type", "bullet")
+ data.pop("viz_type", None)
+ # Saved Explore controls are lenient; newly authored typed lists
+ # still undergo the strict List[float] validation below.
+ for control in ("ranges", "markers", "marker_lines"):
+ if control in data:
+ data[control] = _bullet_numeric_control_tokens(
+ data[control], control
+ )
+ for key in (
+ "annotation_layers",
+ "dashboards",
+ "datasource",
+ "datasource_id",
+ "datasource_type",
+ "extra_form_data",
+ "slice_id",
+ "slice_name",
+ ):
+ data.pop(key, None)
+
+ if (marker_key := "_mcp_dashboard_time_filter_subject") in data:
+ marker = data.pop(marker_key)
+ if not isinstance(marker, str) or not marker:
+ raise ValueError(f"{marker_key} must be a physical column name")
+ raw_filters = data.get("adhoc_filters")
+ if not isinstance(raw_filters, list):
+ raise ValueError(
+ f"{marker_key} requires an adhoc_filters array containing its "
+ "generated binding"
+ )
+ provenance_matches = [
+ filter_
+ for filter_ in raw_filters
+ if isinstance(filter_, dict)
+ and filter_.get("subject") == marker
+ and filter_.get("operator") == "TEMPORAL_RANGE"
+ ]
+ if len(provenance_matches) != 1:
+ raise ValueError(
+ f"{marker_key} must match exactly one TEMPORAL_RANGE filter "
+ f"for subject {marker!r}; found {len(provenance_matches)}"
+ )
+ data.setdefault("temporal_column", marker)
+
+ if "metric" in data:
+ data["metric"] = cls._adapt_native_metric(data["metric"])
+ for key in ("groupby", "dimensions"):
+ if key in data:
+ if not isinstance(data[key], list):
+ raise ValueError(f"{key} must be an array")
+ data[key] = [
+ {"name": item} if isinstance(item, str) else item
+ for item in data[key]
+ ]
+ # Native extractQueryFields concatenates orderby and order_by_cols in
+ # key order; AliasChoices would keep only the first alias present.
+ order_keys = [
+ key for key in data if key in ("order_by", "orderby", "order_by_cols")
+ ]
+ if order_keys:
+ ordering: list[Any] = []
+ for key in order_keys:
+ ordering.extend(cls._adapt_native_order_by(data.pop(key)))
+ data["order_by"] = ordering
+ cls._adapt_native_filters(data)
+ return data
+
+ @field_validator("ranges", "markers", "marker_lines", mode="before")
+ @classmethod
+ def tokenize_native_numeric_lists(cls, value: Any) -> Any:
+ """Parse numeric controls without creating values for empty tokens."""
+ if value is None:
+ return []
+ if isinstance(value, str):
+ return [token.strip() for token in value.split(",") if token.strip()]
+ return value
+
+ @field_validator(
+ "range_labels", "marker_labels", "marker_line_labels", mode="before"
+ )
+ @classmethod
+ def tokenize_native_label_lists(cls, value: Any) -> Any:
+ """Parse label controls while preserving positional empty tokens."""
+ if value is None or value == "":
+ return []
+ if isinstance(value, str):
+ return [token.strip() for token in value.split(",")]
+ return value
+
+ @field_validator("ranges", "markers", "marker_lines")
+ @classmethod
+ def reject_non_finite_values(cls, values: List[float]) -> List[float]:
+ if any(not math.isfinite(value) for value in values):
+ raise ValueError("Bullet thresholds and markers must be finite numbers")
+ return values
+
+ @field_validator("range_labels", "marker_labels", "marker_line_labels")
+ @classmethod
+ def validate_presentation_labels(cls, labels: List[str]) -> List[str]:
+ result: list[str] = []
+ for label in labels:
+ if "," in label:
+ raise ValueError(
+ "Bullet labels cannot contain commas because the frontend "
+ "comma-separated controls have no escaping"
+ )
+ if label == "":
+ result.append("")
+ continue
+ sanitized = sanitize_user_input(
+ label, "Bullet label", max_length=200, allow_empty=True
+ )
+ result.append(sanitized or "")
+ return result
+
+ @field_validator("time_range")
+ @classmethod
+ def sanitize_time_range(cls, value: str | None) -> str | None:
+ return sanitize_user_input(
+ value, "Time range", max_length=1000, allow_empty=True
+ )
+
+ @model_validator(mode="after")
+ def validate_roles_and_outputs(self) -> "BulletChartConfig": # noqa: C901
+ dimensions = self.dimensions or []
+ seen_names: set[str] = set()
+ for index, dimension in enumerate(dimensions):
+ _reject_sql_expression_on_dimension(dimension, f"dimensions[{index}]")
+ if dimension.saved_metric or dimension.aggregate:
+ raise ValueError(
+ f"dimensions[{index}] must be a physical dimension, not a metric"
+ )
+ name = dimension.name or ""
+ if name in seen_names:
+ raise ValueError(f"Duplicate Bullet dimension: {name!r}")
+ seen_names.add(name)
+
+ # ``map_bullet_config`` deliberately emits physical groupby names. The
+ # frontend therefore reads dimension results under those names even when
+ # a friendly ``ColumnRef.label`` was supplied. Only the metric is emitted
+ # with an output alias. Keep validation aligned with those actual result
+ # fields instead of treating dimension display labels as SQL aliases.
+ if self.dimensions is None and self._inherited_groupby is not None:
+ # An omitted hierarchy keeps the saved native dimensions, whose
+ # query output labels share the result row with the metric.
+ from superset.mcp_service.chart.chart_helpers import _column_label
+
+ seen_names = {
+ label
+ for dimension in self._inherited_groupby
+ if (label := _column_label(dimension))
+ }
+ if (metric_output := _bullet_metric_output_label(self.metric)) in seen_names:
+ raise ValueError(
+ f"Bullet metric output label {metric_output!r} conflicts with a "
+ "dimension (its physical output name); provide a unique metric label"
+ )
+
+ if self.dimensions is None and self._inherited_groupby is None:
+ # Partial updates resolve the saved hierarchy before mapping.
+ # Creation validates with an empty hierarchy in map_bullet_config.
+ return self
+ resolved_order: list[tuple[str, int | None]] = []
+ for item in self.order_by:
+ try:
+ resolved_order.append(
+ resolve_bullet_order_target(
+ item.column, self.order_dimensions, self.metric
+ )
+ )
+ except ValueError as ex:
+ raise ValueError(
+ f"order_by must reference a Bullet dimension or metric output; {ex}"
+ ) from ex
+ if len(resolved_order) != len(set(resolved_order)):
+ raise ValueError("order_by cannot contain duplicate outputs")
+ return self
+
+
DEFAULT_HISTOGRAM_BINS: int = 5
@@ -5198,6 +5815,7 @@ CHART_TYPE_DISCRIMINATOR: str = "chart_type"
ChartConfig = Annotated[
XYChartConfig
+ | BulletChartConfig
| TableChartConfig
| PieChartConfig
| SunburstChartConfig
@@ -5219,7 +5837,7 @@ ChartConfig = Annotated[
Field(
discriminator=CHART_TYPE_DISCRIMINATOR,
description=(
- "Chart configuration - specify chart_type as 'xy', 'table', "
+ "Chart configuration - specify chart_type as 'xy', 'bullet', 'table', "
"'pie', 'sunburst', 'gauge', 'treemap_v2', 'bubble_v2', 'pivot_table', "
"'interactive_pivot', 'mixed_timeseries', 'handlebars', "
"'big_number', 'histogram', 'box_plot', 'waterfall', or 'gantt'"
@@ -5286,6 +5904,7 @@ _VIZ_TYPE_TO_CHART_TYPE: dict[str, tuple[str, str | None]] = {
"echarts_area": ("xy", "area"),
"scatter": ("xy", "scatter"),
"echarts_timeseries_scatter": ("xy", "scatter"),
+ "bullet": ("bullet", None),
"ag-grid-table": ("table", None),
"big_number_total": ("big_number", None),
"gauge_chart": ("gauge", None),
@@ -5319,7 +5938,7 @@ def _normalize_chart_request_input(data: Any) -> Any: # noqa: C901
# Explore form data always carries ``viz_type``; typed requests use
# ``chart_type``. Only native payloads get native metric semantics.
is_native_payload = isinstance(viz_type, str)
- if viz_type == "sunburst_v2":
+ if viz_type in ("sunburst_v2", "bullet"):
# The discriminator normalization below removes viz_type before the
# nested model runs. Carry native provenance through that boundary
# so saved Explore payloads retain their bounded round-trip mode.
@@ -5950,7 +6569,12 @@ class ChartData(BaseModel):
# LLM-friendly summaries
summary: str = Field(description="Human-readable data summary")
insights: List[str] = Field(description="Key patterns discovered in the data")
- data_quality: Dict[str, Any] = Field(description="Data quality assessment")
+ data_quality: Dict[str, Any] = Field(
+ description=(
+ "Data quality assessment. Sampled completeness includes "
+ "completeness_is_approximate=true and sampled_rows."
+ )
+ )
recommended_visualizations: List[str] = Field(
description="Suggested chart types for this data"
)
diff --git a/superset/mcp_service/chart/tool/generate_chart.py b/superset/mcp_service/chart/tool/generate_chart.py
index f5c5c874a90..bd2f0f53d3b 100644
--- a/superset/mcp_service/chart/tool/generate_chart.py
+++ b/superset/mcp_service/chart/tool/generate_chart.py
@@ -116,7 +116,7 @@ async def generate_chart( # noqa: C901
- Set save_chart=True to permanently save the chart
- LLM clients MUST display returned chart URL to users
- Use numeric dataset ID or UUID (NOT schema.table_name format)
- - MUST include chart_type in config (one of: 'xy', 'table', 'pie',
+ - MUST include chart_type in config (one of: 'xy', 'table', 'pie', 'bullet',
'sunburst', 'gauge', 'treemap_v2', 'bubble_v2', 'pivot_table', 'mixed_timeseries',
'handlebars', 'big_number', 'histogram', 'box_plot', 'waterfall',
'gantt', 'country_map', 'world_map', 'deck_scatter', plus host-gated
@@ -134,6 +134,7 @@ async def generate_chart( # noqa: C901
- "bar chart" / "line chart" / "area chart" / "scatter plot"
-> chart_type='xy', kind='bar'/'line'/'area'/'scatter'
- "pie chart" / "donut chart" -> chart_type='pie'
+ - "bullet chart" / "progress against target" -> chart_type='bullet'
- "sunburst" / "hierarchical rings" -> chart_type='sunburst'
- "table" / "data grid" -> chart_type='table'
- "pivot table" / "cross-tab" -> chart_type='pivot_table'
@@ -208,8 +209,13 @@ async def generate_chart( # noqa: C901
with event_logger.log_context(action="mcp.generate_chart.validation"):
from superset.mcp_service.chart.validation import ValidationPipeline
+ # Preserve omissions, including nested optional controls. A full dump
+ # turns defaults into explicit values before schema revalidation.
+ request_data = request.model_dump(exclude_unset=True)
+ # Typed callers may rely on the model's default discriminator.
+ request_data["config"]["chart_type"] = request.config.chart_type
validation_result = ValidationPipeline.validate_request_with_warnings(
- request.model_dump()
+ request_data
)
if validation_result.is_valid and validation_result.request is not None:
diff --git a/superset/mcp_service/chart/tool/get_chart_data.py b/superset/mcp_service/chart/tool/get_chart_data.py
index a2067b1dec0..0ab50426f6c 100644
--- a/superset/mcp_service/chart/tool/get_chart_data.py
+++ b/superset/mcp_service/chart/tool/get_chart_data.py
@@ -66,6 +66,7 @@ from superset.mcp_service.chart.query_result import (
normalize_chart_query_result,
null_data_is_empty,
query_result_failure,
+ temporal_json_numbers,
validate_query_result_envelope,
)
from superset.mcp_service.chart.response_preflight import (
@@ -127,6 +128,13 @@ _GENERIC_TYPE_MAP: dict[int, str] = {
}
+def _data_plugin(viz_type: str | None) -> Any:
+ """Return the plugin that owns chart-data rows for ``viz_type``."""
+ from superset.mcp_service.chart.registry import plugin_for_viz_type
+
+ return plugin_for_viz_type(viz_type)
+
+
def _normalizes_data_results(form_data: dict[str, Any]) -> bool:
"""Return whether the owning plugin validates get_chart_data rows/exports."""
from superset.mcp_service.chart.registry import plugin_for_viz_type
@@ -158,6 +166,7 @@ _VIZ_CATEGORY: dict[str, str] = {
"scatter": "scatter",
"bubble": "bubble",
"bubble_v2": "bubble",
+ "bullet": "bullet",
"treemap_v2": "treemap",
"sunburst_v2": "sunburst",
"heatmap_v2": "heatmap",
@@ -311,7 +320,7 @@ def _candidates_categorical_numeric(
def _candidates_single_numeric(col: DataColumn, row_count: int) -> list[str]:
- candidates = ["big number / KPI", "gauge chart"]
+ candidates = ["big number / KPI", "bullet chart", "gauge chart"]
if row_count > 20 and col.unique_count > 10:
candidates.insert(0, "histogram")
return candidates
@@ -338,6 +347,7 @@ _CANDIDATE_CATEGORY: dict[str, str] = {
"bar chart": "bar",
"scatter plot": "scatter",
"bubble chart": "bubble",
+ "bullet chart": "bullet",
"pie chart": "pie",
"treemap": "treemap",
"sunburst chart": "sunburst",
@@ -936,6 +946,9 @@ async def _get_chart_data( # noqa: C901
if result_error := validate_query_result_envelope(
result,
none_as_empty=null_data_is_empty(effective_form_data.get("viz_type")),
+ temporal_json_numbers=temporal_json_numbers(
+ effective_form_data.get("viz_type")
+ ),
):
return result_error
if _normalizes_data_results(effective_form_data):
@@ -980,6 +993,13 @@ async def _get_chart_data( # noqa: C901
query_result = result["queries"][0]
data = query_result.get("data", [])
raw_columns = query_result.get("colnames", [])
+ data_plugin = _data_plugin(effective_form_data.get("viz_type"))
+ if data_plugin is not None:
+ data, rows_error = data_plugin.sanitize_data_rows(
+ data, effective_form_data
+ )
+ if rows_error is not None:
+ return rows_error
await ctx.debug(
"Query results received: row_count=%s, column_count=%s, "
@@ -992,7 +1012,9 @@ async def _get_chart_data( # noqa: C901
)
# Check if we have data to work with
- if not any(query.get("data") for query in result["queries"]):
+ if not (data_plugin and data_plugin.allows_empty_data_result) and not any(
+ query.get("data") for query in result["queries"]
+ ):
await ctx.warning("No data in query results: chart_id=%s" % (chart_id,))
logger.warning(
"get_chart_data: no data in query results for chart_id=%s",
@@ -1291,7 +1313,9 @@ async def _query_from_form_data( # noqa: C901
result = command.run()
if result_error := validate_query_result_envelope(
- result, none_as_empty=null_data_is_empty(viz_type)
+ result,
+ none_as_empty=null_data_is_empty(viz_type),
+ temporal_json_numbers=temporal_json_numbers(viz_type),
):
return result_error
if _normalizes_data_results(form_data):
@@ -1328,8 +1352,15 @@ async def _query_from_form_data( # noqa: C901
query_result = result["queries"][0]
data = query_result.get("data", [])
raw_columns = query_result.get("colnames", [])
-
- if not any(query.get("data") for query in result["queries"]):
+ data_plugin = _data_plugin(viz_type)
+ if data_plugin is not None:
+ data, rows_error = data_plugin.sanitize_data_rows(data, form_data)
+ if rows_error is not None:
+ return rows_error
+
+ if not (data_plugin and data_plugin.allows_empty_data_result) and not any(
+ query.get("data") for query in result["queries"]
+ ):
logger.warning(
"get_chart_data: no data for unsaved chart (form_data_key=%s)",
request.form_data_key,
@@ -1427,7 +1458,9 @@ def _export_data_as_csv(
# Create CSV content
output = io.StringIO()
- if data and columns:
+ # An empty result still exports its header row (for example a Bullet
+ # chart, whose empty result is valid).
+ if columns:
writer = csv.DictWriter(output, fieldnames=columns)
writer.writeheader()
@@ -1506,7 +1539,9 @@ def _create_excel_with_openpyxl(
ws = wb.active
ws.title = chart.slice_name[:31] if chart.slice_name else "Chart Data"
- if data and columns:
+ # An empty result still exports its header row (for example a Bullet
+ # chart, whose empty result is valid).
+ if columns:
_write_excel_headers(ws, columns)
_write_excel_data(ws, data, columns, temporal_columns)
@@ -1539,6 +1574,7 @@ _EXCEL_MIN_DATETIME = dt.datetime.combine(_EXCEL_MIN_DATE, dt.time.min)
# supported day or time cannot roll over past Excel's range.
_EXCEL_MAX_TIME = dt.time(23, 59, 59, 999000)
_EXCEL_MAX_DATETIME = dt.datetime.combine(dt.date.max, _EXCEL_MAX_TIME)
+_EXCEL_EPOCH = dt.datetime(1970, 1, 1)
def _temporal_result_columns(query_result: Any) -> frozenset[str]:
@@ -1559,10 +1595,14 @@ def _excel_temporal_value(value: Any) -> Any:
projection. Excel stores dates as typed serial values, so temporal columns
are restored here. Excel cannot store offsets; like the Superset Excel
export, aware values keep their wall-clock time and drop the offset.
- Unparseable text, and values outside Excel's 1900 date system (before
- 1900-01-01, or rounding past its final supported day or time), are
+ Plugins that read temporals as Chart Data numbers (for example Bullet)
+ carry epoch milliseconds instead; those are restored as naive UTC
+ datetimes. Unparseable text, and values outside Excel's 1900 date system
+ (before 1900-01-01, or rounding past its final supported day or time), are
written unchanged.
"""
+ if type(value) is int or type(value) is float:
+ return _excel_epoch_ms_value(value)
if type(value) is not str:
return value
try:
@@ -1585,6 +1625,19 @@ def _excel_temporal_value(value: Any) -> Any:
return moment
+def _excel_epoch_ms_value(value: int | float) -> Any:
+ """Restore an epoch-millisecond temporal cell as an Excel datetime."""
+ if not math.isfinite(value):
+ return value
+ try:
+ moment = _EXCEL_EPOCH + dt.timedelta(milliseconds=value)
+ except OverflowError:
+ return value
+ if not _EXCEL_MIN_DATETIME <= moment <= _EXCEL_MAX_DATETIME:
+ return value
+ return moment
+
+
def _excel_scalar(value: Any, temporal: bool) -> Any:
"""Project one validated cell for an Excel writer."""
projected = _export_scalar(value)
@@ -1654,7 +1707,9 @@ def _create_excel_with_xlsxwriter(
sheet_name = chart.slice_name[:31] if chart.slice_name else "Chart Data"
worksheet = workbook.add_worksheet(sheet_name)
- if data and columns:
+ # An empty result still exports its header row (for example a Bullet
+ # chart, whose empty result is valid).
+ if columns:
# xlsxwriter writes unformatted serial numbers for temporal values, so
# give each Python temporal type the date format openpyxl applies.
temporal_formats = {
diff --git a/superset/mcp_service/chart/tool/get_chart_preview.py b/superset/mcp_service/chart/tool/get_chart_preview.py
index 992ff565424..e0435ca76c3 100644
--- a/superset/mcp_service/chart/tool/get_chart_preview.py
+++ b/superset/mcp_service/chart/tool/get_chart_preview.py
@@ -42,6 +42,7 @@ from superset.mcp_service.chart.chart_helpers import (
from superset.mcp_service.chart.chart_utils import validate_chart_dataset
from superset.mcp_service.chart.preview_utils import (
fallback_vega_lite_preview,
+ fit_ascii_frame,
plugin_ascii_preview,
plugin_table_preview,
plugin_unsupported_preview,
@@ -335,6 +336,14 @@ class ASCIIPreviewStrategy(PreviewFormatStrategy):
self.request.ascii_width or 80,
self.request.ascii_height or 20,
)
+ if isinstance(ascii_chart, str):
+ # Plugin renderers may ignore the frame; clip like the unsaved
+ # path so the reported width/height describe the content.
+ ascii_chart = fit_ascii_frame(
+ ascii_chart,
+ self.request.ascii_width or 80,
+ self.request.ascii_height or 20,
+ )
if ascii_chart is None:
ascii_chart = generate_ascii_chart(
data,
@@ -370,6 +379,10 @@ class TablePreviewStrategy(PreviewFormatStrategy):
"""Generate table preview of chart data."""
def generate(self) -> TablePreview | ChartError:
+ if (
+ unsupported := plugin_unsupported_preview(self.chart.viz_type, "table")
+ ) is not None:
+ return unsupported
try:
from superset.commands.chart.data.get_data_command import ChartDataCommand
from superset.utils import json as utils_json
diff --git a/superset/mcp_service/chart/tool/get_chart_type_schema.py b/superset/mcp_service/chart/tool/get_chart_type_schema.py
index 17fd98ef1c0..9ec016ed788 100644
--- a/superset/mcp_service/chart/tool/get_chart_type_schema.py
+++ b/superset/mcp_service/chart/tool/get_chart_type_schema.py
@@ -35,6 +35,7 @@ from superset.mcp_service.chart.schemas import (
BigNumberChartConfig,
BoxPlotChartConfig,
BubbleChartConfig,
+ BulletChartConfig,
CountryMapChartConfig,
DeckScatterChartConfig,
GanttChartConfig,
@@ -72,6 +73,7 @@ _CHART_TYPE_ADAPTERS: Dict[str, TypeAdapter[Any]] = {
"world_map": TypeAdapter(WorldMapChartConfig),
"deck_scatter": TypeAdapter(DeckScatterChartConfig),
"xy": TypeAdapter(XYChartConfig),
+ "bullet": TypeAdapter(BulletChartConfig),
"table": TypeAdapter(TableChartConfig),
"pie": TypeAdapter(PieChartConfig),
"sunburst": TypeAdapter(SunburstChartConfig),
@@ -132,6 +134,26 @@ _CHART_EXAMPLES: Dict[str, list[Dict[str, Any]]] = {
"y": [{"name": "sales", "aggregate": "SUM"}],
},
],
+ "bullet": [
+ {
+ "chart_type": "bullet",
+ "metric": {"name": "revenue", "aggregate": "SUM"},
+ "ranges": [100000, 250000, 500000],
+ "range_labels": ["Minimum", "Target", "Stretch"],
+ "markers": [300000],
+ "marker_labels": ["Plan"],
+ "show_labels": True,
+ },
+ {
+ "chart_type": "bullet",
+ "metric": {"name": "attainment", "saved_metric": True},
+ "dimensions": [{"name": "region"}],
+ "marker_lines": [1],
+ "marker_line_labels": ["Goal"],
+ "y_axis_format": ".0%",
+ "row_limit": 100,
+ },
+ ],
"table": [
{
"chart_type": "table",
@@ -416,7 +438,8 @@ def get_chart_type_schema(
for a chart configuration before calling generate_chart or update_chart.
Valid chart_type values depend on the host deployment. Core types are xy,
- country_map, world_map, deck_scatter, table, pie, sunburst, gauge, treemap_v2,
+ country_map, world_map, deck_scatter, table, pie, sunburst, bullet, gauge,
+ treemap_v2,
bubble_v2, pivot_table, mixed_timeseries, handlebars, big_number, histogram,
box_plot, waterfall, and gantt. Deployments that enable an AG Grid pivot
extension also expose interactive_pivot.
diff --git a/superset/mcp_service/chart/tool/update_chart.py b/superset/mcp_service/chart/tool/update_chart.py
index 0ed6012ee58..6d7079ff419 100644
--- a/superset/mcp_service/chart/tool/update_chart.py
+++ b/superset/mcp_service/chart/tool/update_chart.py
@@ -569,7 +569,9 @@ def _validate_update_against_dataset( # noqa: C901
try:
merged_plugin = plugin_for_viz_type(form_data.get("viz_type"))
merged_config = (
- merged_plugin.validate_merged_form_data(form_data, dataset.id)
+ merged_plugin.validate_merged_form_data(
+ form_data, dataset.id, update_config=parsed_config
+ )
if merged_plugin is not None
else None
)
diff --git a/superset/mcp_service/chart/tool/update_chart_preview.py b/superset/mcp_service/chart/tool/update_chart_preview.py
index 850e2c4282d..0d5b75e45c9 100644
--- a/superset/mcp_service/chart/tool/update_chart_preview.py
+++ b/superset/mcp_service/chart/tool/update_chart_preview.py
@@ -360,6 +360,7 @@ def update_chart_preview( # noqa: C901
new_form_data,
request.dataset_id,
dataset_context=lambda: dataset_context,
+ update_config=config,
)
if merged_plugin is not None
else None
diff --git a/superset/mcp_service/chart/validation/dataset_validator.py b/superset/mcp_service/chart/validation/dataset_validator.py
index 53d1c0ef3ff..19482b20c83 100644
--- a/superset/mcp_service/chart/validation/dataset_validator.py
+++ b/superset/mcp_service/chart/validation/dataset_validator.py
@@ -227,6 +227,42 @@ def resolve_dataset_reference(
return matches[0] if matches else None
+def resolve_dataset_column(
+ column_name: str, dataset_context: DatasetContext
+) -> Mapping[str, Any] | None:
+ """Resolve an exact, otherwise unique case-insensitive dataset column.
+
+ Metadata ordering must never decide which SQL type is used for validation.
+ Exact case is authoritative; a case-insensitive fallback is safe only when
+ it identifies one column.
+ """
+
+ exact = [
+ column
+ for column in dataset_context.available_columns
+ if column["name"] == column_name
+ ]
+ if len(exact) == 1:
+ return exact[0]
+ if len(exact) > 1:
+ raise ValueError(f"Duplicate exact dataset column {column_name!r}")
+
+ folded = [
+ column
+ for column in dataset_context.available_columns
+ if column["name"].casefold() == column_name.casefold()
+ ]
+ if len(folded) == 1:
+ return folded[0]
+ if len(folded) > 1:
+ raise AmbiguousDatasetReferenceError(
+ column_name,
+ [column["name"] for column in folded],
+ "column",
+ )
+ return None
+
+
class DatasetValidator:
"""Validates chart configuration against dataset schema."""
diff --git a/superset/mcp_service/chart/validation/pipeline.py b/superset/mcp_service/chart/validation/pipeline.py
index c42737fdf67..1657ce33983 100644
--- a/superset/mcp_service/chart/validation/pipeline.py
+++ b/superset/mcp_service/chart/validation/pipeline.py
@@ -119,20 +119,9 @@ class ValidationPipeline:
request.dataset_id
)
- # Layer 2: Dataset validation (reuses context)
- is_valid, error = ValidationPipeline._validate_dataset(
- typed_config, request.dataset_id, dataset_context
- )
- if not is_valid:
- return ValidationResult(is_valid=False, request=request, error=error)
-
- # Layer 3: Runtime validation - returns warnings as metadata, not errors
- _is_valid, warnings_metadata = ValidationPipeline._validate_runtime(
- typed_config, request.dataset_id
- )
- # Runtime validation always returns True now, warnings are informational
-
- # Layer 4: Column name normalization (reuses context)
+ # Canonicalize against the same authorized schema used by
+ # validation. Ambiguous case-insensitive matches must fail rather
+ # than silently selecting one dataset field.
from .dataset_validator import GanttSemanticNormalizationError
try:
@@ -140,6 +129,16 @@ class ValidationPipeline:
request, dataset_context, typed_config=typed_config
)
except GanttSemanticNormalizationError as ex:
+ # A semantic conflict can be the downstream symptom of dataset
+ # metadata that collides only by case. Report that more specific
+ # diagnosis when dataset validation can name it.
+ _is_valid, dataset_error = ValidationPipeline._validate_dataset(
+ typed_config, request.dataset_id, dataset_context
+ )
+ if dataset_error is not None:
+ return ValidationResult(
+ is_valid=False, request=request, error=dataset_error
+ )
return ValidationResult(
is_valid=False,
request=request,
@@ -157,6 +156,34 @@ class ValidationPipeline:
error_code="GANTT_SEMANTIC_VALIDATION_ERROR",
),
)
+ except ValueError as ex:
+ return ValidationResult(
+ is_valid=False,
+ request=request,
+ error=ChartGenerationError(
+ error_type="ambiguous_column_reference",
+ message="Chart references could not be canonicalized",
+ details=str(ex),
+ suggestions=[
+ "Use get_dataset_info and copy exact-case field names"
+ ],
+ error_code="AMBIGUOUS_COLUMN_REFERENCE",
+ ),
+ )
+ typed_config = normalized_request.config
+
+ # Layer 2: Dataset validation (reuses context)
+ is_valid, error = ValidationPipeline._validate_dataset(
+ typed_config, request.dataset_id, dataset_context
+ )
+ if not is_valid:
+ return ValidationResult(is_valid=False, request=request, error=error)
+
+ # Layer 3: Runtime validation - returns warnings as metadata, not errors
+ _is_valid, warnings_metadata = ValidationPipeline._validate_runtime(
+ typed_config, request.dataset_id
+ )
+ # Runtime validation always returns True now, warnings are informational
return ValidationResult(
is_valid=True,
@@ -272,6 +299,7 @@ class ValidationPipeline:
"""
try:
from .dataset_validator import (
+ AmbiguousDatasetReferenceError,
DatasetValidator,
GanttSemanticNormalizationError,
)
@@ -289,7 +317,10 @@ class ValidationPipeline:
return GenerateChartRequest.model_validate(request_dict)
- except GanttSemanticNormalizationError:
+ except (GanttSemanticNormalizationError, AmbiguousDatasetReferenceError):
+ # These name a real, actionable schema conflict rather than a
+ # transient normalization failure; callers map them to structured
+ # errors instead of silently querying an unresolved reference.
raise
except (ImportError, AttributeError, KeyError, ValueError, TypeError) as e:
# If normalization fails, return the original request
diff --git a/tests/unit_tests/dataframe_test.py b/tests/unit_tests/dataframe_test.py
index 02d974a061d..7f70cd31485 100644
--- a/tests/unit_tests/dataframe_test.py
+++ b/tests/unit_tests/dataframe_test.py
@@ -204,6 +204,25 @@ def test_df_to_records_normalizes_only_exact_nonfinite_decimals() -> None:
]
+@pytest.mark.parametrize(
+ "value, expected",
+ [
+ (np.timedelta64("NaT"), None),
+ (np.timedelta64(123456789, "ns"), pd.Timedelta(123456789, unit="ns")),
+ (np.datetime64("NaT"), None),
+ (np.datetime64("2026-01-01"), pd.Timestamp("2026-01-01")),
+ ],
+)
+def test_df_to_records_boxes_numpy_temporals(value: Any, expected: Any) -> None:
+ """Object-column temporal scalars retain pandas' JSON-compatible boxing."""
+ frame = pd.DataFrame({"value": pd.Series([value], dtype=object)})
+
+ result = df_to_records(frame)[0]["value"]
+
+ assert type(result) is type(expected)
+ assert result == expected
+
+
def test_df_to_records() -> None:
data = [("a1", "b1", "c1"), ("a2", "b2", "c2")]
cursor_descr: DbapiDescription = [
@@ -441,6 +460,73 @@ def test_df_to_records_with_inf_and_nan() -> None:
assert records[4]["result"] == 42.5
+def test_df_to_records_avoids_object_equality_during_nonfinite_cleanup() -> None:
+ class AcceptedEnum(Enum):
+ VALUE = "accepted"
+
+ class HostileEquality:
+ def __eq__(self, _other: object) -> bool:
+ raise AssertionError("object equality must not run during projection")
+
+ hostile = HostileEquality()
+ enum_value = AcceptedEnum.VALUE
+ frame = pd.DataFrame(
+ {
+ "hostile": pd.Series([hostile], dtype=object),
+ "enum": pd.Series([enum_value], dtype=object),
+ "infinite": [np.inf],
+ "missing": pd.Series([pd.NA], dtype=object),
+ }
+ )
+
+ records = df_to_records(frame, convert_big_integers=False)
+
+ assert records[0]["hostile"] is hostile
+ assert records[0]["enum"] is enum_value
+ assert records[0]["infinite"] is None
+ assert records[0]["missing"] is None
+
+
+def test_df_to_records_normalizes_decimals_without_integer_conversion() -> None:
+ """Exact non-finite Decimal cells become null without subclass hooks."""
+
+ class HostileDecimal(Decimal):
+ def is_finite(self) -> bool:
+ raise AssertionError("hostile Decimal is_finite hook executed")
+
+ def __eq__(self, _other: object) -> bool:
+ raise AssertionError("hostile Decimal equality hook executed")
+
+ def __float__(self) -> float:
+ raise AssertionError("hostile Decimal float hook executed")
+
+ finite = Decimal("0.10000000000000000001")
+ hostile = HostileDecimal("NaN")
+ values = [
+ Decimal("NaN"),
+ Decimal("sNaN"),
+ Decimal("Infinity"),
+ Decimal("-Infinity"),
+ finite,
+ hostile,
+ ]
+ frame = pd.DataFrame({"value": pd.Series(values, dtype=object)})
+
+ records = df_to_records(frame, convert_big_integers=False)
+
+ assert [records[index]["value"] for index in range(4)] == [None] * 4
+ assert records[4]["value"] is finite
+ assert records[5]["value"] is hostile
+ strict_json = superset_json.dumps(records[:5], ignore_nan=False)
+ assert superset_json.loads(strict_json) == [
+ {"value": None},
+ {"value": None},
+ {"value": None},
+ {"value": None},
+ {"value": 0.1},
+ ]
+
+
def test_df_to_records_nan_json_serialization() -> None:
"""
Test that NaN values are properly converted to None for JSON serialization.
diff --git a/tests/unit_tests/mcp_service/chart/test_bullet_chart.py b/tests/unit_tests/mcp_service/chart/test_bullet_chart.py
new file mode 100644
index 00000000000..e5ae9e8c7fd
--- /dev/null
+++ b/tests/unit_tests/mcp_service/chart/test_bullet_chart.py
@@ -0,0 +1,5308 @@
+# 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.
+
+"""Product-path coverage for typed ECharts Bullet MCP support."""
+
+import asyncio
+import math
+from datetime import date, datetime, time, timedelta, timezone, tzinfo
+from decimal import Decimal
+from enum import Enum, IntEnum, StrEnum
+from types import SimpleNamespace
+from typing import Any
+from unittest.mock import AsyncMock, MagicMock, patch
+from uuid import UUID
+from zoneinfo import ZoneInfo
+
+import numpy as np
+import pandas as pd
+import pytest
+import pytz
+from dateutil import tz as dateutil_tz
+from dateutil.zoneinfo import get_zonefile_instance
+from pydantic import TypeAdapter, ValidationError
+
+from superset.mcp_service.chart.chart_helpers import (
+ build_query_dicts_from_form_data,
+)
+from superset.mcp_service.chart.chart_utils import (
+ analyze_chart_capabilities,
+ map_bullet_config,
+ map_config_to_form_data,
+ MCP_DASHBOARD_TIME_FILTER_SUBJECT,
+ merge_update_form_data,
+ validate_merged_bullet_form_data,
+)
+from superset.mcp_service.chart.compile import _compile_chart, CompileResult
+from superset.mcp_service.chart.plugins.bullet import BulletChartPlugin
+from superset.mcp_service.chart.preview_utils import (
+ _generate_ascii_preview_from_data,
+ _generate_vega_lite_preview_from_data,
+ _javascript_number_string,
+ BulletOutputError,
+ generate_preview_from_form_data,
+ resolve_bullet_render_model,
+)
+from superset.mcp_service.chart.query_result import (
+ _chart_data_duration_text,
+ _chart_data_temporal_number,
+)
+from superset.mcp_service.chart.schemas import (
+ ASCIIPreview,
+ BulletChartConfig,
+ ChartConfig,
+ ChartError,
+ ChartInfo,
+ DataColumn,
+ GenerateChartRequest,
+ GetChartPreviewRequest,
+ UpdateChartPreviewRequest,
+ UpdateChartRequest,
+ VegaLitePreview,
+ XYChartConfig,
+)
+from superset.mcp_service.chart.tool.generate_chart import generate_chart
+from superset.mcp_service.chart.tool.get_chart_data import (
+ _candidates_single_numeric,
+ _VIZ_CATEGORY,
+)
+from superset.mcp_service.chart.tool.get_chart_preview import (
+ ASCIIPreviewStrategy,
+ TablePreviewStrategy,
+ VegaLitePreviewStrategy,
+)
+from superset.mcp_service.chart.tool.get_chart_type_schema import (
+ _get_chart_type_schema_impl,
+ VALID_CHART_TYPES,
+)
+from superset.mcp_service.chart.tool.update_chart import (
+ _build_preview_form_data,
+ _build_update_payload,
+ update_chart,
+)
+from superset.mcp_service.chart.tool.update_chart_preview import update_chart_preview
+from superset.mcp_service.chart.validation.dataset_validator import (
+ AmbiguousDatasetReferenceError,
+ DatasetValidator,
+)
+from superset.mcp_service.chart.validation.pipeline import ValidationPipeline
+from superset.mcp_service.common.error_schemas import DatasetContext
+from superset.utils import json
+from superset.utils.core import GenericDataType
+from superset.utils.json import json_int_dttm_ser
+
+
+def _reject_scalar_conversion(*_args: object, **_kwargs: object) -> Any:
+ raise AssertionError("hostile query scalar method must not run")
+
+
+class _PathHostileStr(str):
+ __getitem__ = _reject_scalar_conversion
+ __str__ = _reject_scalar_conversion
+
+
+class _PathHostileEnum(str, Enum):
+ FAILED = "warehouse unavailable"
+
+ @property
+ def value(self) -> str:
+ """Reject the public descriptor while preserving Enum's stored value."""
+ return _reject_scalar_conversion()
+
+ __getitem__ = _reject_scalar_conversion
+ __str__ = _reject_scalar_conversion
+
+
+class _OutputHostileInt(int):
+ __float__ = _reject_scalar_conversion
+ __str__ = _reject_scalar_conversion
+
+
+class _OutputHostileFloat(float):
+ __float__ = _reject_scalar_conversion
+ __str__ = _reject_scalar_conversion
+
+
+class _OutputHostileStr(str):
+ __str__ = _reject_scalar_conversion
+ strip = _reject_scalar_conversion
+
+
+class _OutputHostileDecimal(Decimal):
+ __float__ = _reject_scalar_conversion
+ __str__ = _reject_scalar_conversion
+
+
+class _OutputSafeIntEnum(IntEnum):
+ VALUE = 12
+
+
+class _OutputSafeStrEnum(StrEnum):
+ VALUE = "12.5"
+
+
+_OutputSafeIntEnum.__float__ = _reject_scalar_conversion # type: ignore[method-assign]
+_OutputSafeIntEnum.__str__ = _reject_scalar_conversion # type: ignore[method-assign]
+_OutputSafeStrEnum.__float__ = _reject_scalar_conversion # type: ignore[attr-defined]
+_OutputSafeStrEnum.__str__ = _reject_scalar_conversion # type: ignore[method-assign]
+
+
+def _simple_metric(name: str = "revenue") -> dict[str, str]:
+ return {"name": name, "aggregate": "SUM"}
+
+
+def _tool_user() -> SimpleNamespace:
+ return SimpleNamespace(id=1, username="admin", roles=[], groups=[])
+
+
+def _orm_dataset() -> SimpleNamespace:
+ def column(
+ name: str, type_: str, *, temporal: bool = False, numeric: bool = False
+ ) -> SimpleNamespace:
+ return SimpleNamespace(
+ column_name=name,
+ type=type_,
+ is_temporal=temporal,
+ is_numeric=numeric,
+ is_dttm=temporal,
+ python_date_format=None,
+ )
+
+ return SimpleNamespace(
+ id=7,
+ table_name="sales",
+ schema=None,
+ main_dttm_col="OrderDate",
+ database=SimpleNamespace(database_name="main", db_engine_spec=None),
+ columns=[
+ column("Revenue", "NUMERIC", numeric=True),
+ column("Region", "VARCHAR"),
+ column("Team", "VARCHAR"),
+ column("Country", "VARCHAR"),
+ column("region", "VARCHAR"),
+ column("Status", "VARCHAR"),
+ column("OrderDate", "TIMESTAMP", temporal=True),
+ column("EventDate", "TIMESTAMP", temporal=True),
+ ],
+ metrics=[
+ SimpleNamespace(
+ metric_name="SavedRevenue",
+ expression="SUM(Revenue)",
+ description=None,
+ )
+ ],
+ )
+
+
+def test_bullet_discriminated_union_uses_exact_tag() -> None:
+ config = TypeAdapter(ChartConfig).validate_python(
+ {"chart_type": "bullet", "metric": _simple_metric()}
+ )
+ assert isinstance(config, BulletChartConfig)
+ with pytest.raises(ValidationError):
+ TypeAdapter(ChartConfig).validate_python(
+ {"chart_type": "bullet_chart", "metric": _simple_metric()}
+ )
+
+
+def test_bullet_equal_dimension_aliases_are_order_independent_and_round_trip() -> None:
+ for payload in (
+ {
+ "dimensions": [{"name": "Region", "label": "Market"}, "Team"],
+ "groupby": ["Region", {"column_name": "Team"}],
+ },
+ {
+ "groupby": ["Region", {"column": "Team"}],
+ "dimensions": [{"name": "Region", "label": "Market"}, "Team"],
+ },
+ ):
+ config = BulletChartConfig.model_validate(
+ {"metric": _simple_metric(), **payload}
+ )
+ assert [dimension.name for dimension in config.dimensions or []] == [
+ "Region",
+ "Team",
+ ]
+ mapped = map_bullet_config(config)
+ assert mapped["groupby"] == ["Region", "Team"]
+ round_trip = BulletChartConfig.model_validate(mapped)
+ assert [dimension.name for dimension in round_trip.dimensions or []] == [
+ "Region",
+ "Team",
+ ]
+
+
+def test_bullet_dimension_aliases_require_exact_physical_identity() -> None:
+ """Schema-only alias resolution must not merge quoted case variants."""
+ with pytest.raises(ValidationError, match="Conflicting Bullet dimension aliases"):
+ BulletChartConfig.model_validate(
+ {
+ "metric": _simple_metric(),
+ "dimensions": ["Region"],
+ "groupby": ["region"],
+ }
+ )
+
+
+@pytest.mark.parametrize(
+ "request_payload",
+ [
+ {"dataset_id": 7},
+ {"identifier": 9},
+ {"dataset_id": 7, "form_data_key": "preview"},
+ ],
+)
+@pytest.mark.parametrize("reverse", [False, True])
+def test_bullet_request_models_reject_conflicting_dimension_aliases(
+ request_payload: dict[str, object], reverse: bool
+) -> None:
+ aliases = [
+ ("dimensions", [{"name": "Region"}, {"name": "Team"}]),
+ ("groupby", ["Team", "Region"]),
+ ]
+ if reverse:
+ aliases.reverse()
+ config = {"chart_type": "bullet", "metric": _simple_metric(), **dict(aliases)}
+ payload = {**request_payload, "config": config}
+ request_type = (
+ UpdateChartRequest
+ if "identifier" in request_payload
+ else (
+ UpdateChartPreviewRequest
+ if "form_data_key" in request_payload
+ else GenerateChartRequest
+ )
+ )
+ with pytest.raises(ValidationError, match="Conflicting Bullet dimension aliases"):
+ request_type.model_validate(payload)
+
+
+@pytest.mark.parametrize(
+ "metric",
+ [
+ {"name": "revenue", "aggregate": "SUM", "label": "Revenue"},
+ {"name": "saved_revenue", "saved_metric": True},
+ {"sql_expression": "SUM(revenue) / COUNT(*)", "label": "Average"},
+ ],
+)
+def test_bullet_accepts_simple_saved_and_sql_metrics(metric: dict[str, object]) -> None:
+ config = BulletChartConfig(metric=metric)
+ form_data = map_bullet_config(config)
+ assert form_data["viz_type"] == "bullet"
+ assert form_data["metric"]
+
+
+def test_bullet_native_form_data_round_trip_is_semantically_stable() -> None:
+ native = {
+ "viz_type": "bullet",
+ "datasource": "7__table",
+ "metric": {
+ "aggregate": "SUM",
+ "column": {"column_name": "Revenue"},
+ "expressionType": "SIMPLE",
+ "label": "Total Revenue",
+ },
+ "groupby": ["Region", "Team"],
+ "ranges": "100,250,500",
+ "range_labels": "Minimum,Target,Stretch",
+ "markers": "300",
+ "marker_labels": "Plan",
+ "marker_lines": "400",
+ "marker_line_labels": "Forecast",
+ "y_axis_format": "$,.0f",
+ "show_labels": False,
+ "show_legend": True,
+ "row_limit": 250,
+ "orderby": [["Region", True], ["Total Revenue", False]],
+ "adhoc_filters": [
+ {
+ "clause": "WHERE",
+ "expressionType": "SIMPLE",
+ "subject": "Status",
+ "operator": "==",
+ "comparator": "Active",
+ }
+ ],
+ }
+ config = BulletChartConfig.model_validate(native)
+ mapped = map_bullet_config(config)
+
+ assert mapped["metric"]["label"] == "Total Revenue"
+ assert mapped["groupby"] == ["Region", "Team"]
+ assert mapped["ranges"] == "100,250,500"
+ assert mapped["range_labels"] == "Minimum,Target,Stretch"
+ assert mapped["markers"] == "300"
+ assert mapped["marker_lines"] == "400"
+ assert mapped["show_labels"] is False
+ assert mapped["show_legend"] is True
+ assert mapped["orderby"][0] == ["Region", True]
+ assert mapped["orderby"][1][0]["label"] == "Total Revenue"
+ assert mapped["adhoc_filters"][0]["subject"] == "Status"
+
+
+def test_bullet_presentation_numbers_use_shortest_round_trip_safe_tokens() -> None:
+ ranges = [1.2345678901234567, 1.7976931348623157e308]
+ markers = [5e-324, -0.0]
+ marker_lines = [9.876543210987654e-200]
+ config = BulletChartConfig(
+ metric=_simple_metric(),
+ ranges=ranges,
+ markers=markers,
+ marker_lines=marker_lines,
+ show_legend=True,
+ )
+ mapped = map_bullet_config(config)
+
+ for key, expected in (
+ ("ranges", ranges),
+ ("markers", markers),
+ ("marker_lines", marker_lines),
+ ):
+ tokens = mapped[key].split(",")
+ assert [float(token) for token in tokens] == expected
+ assert all(
+ float(token).hex() == value.hex()
+ for token, value in zip(tokens, expected, strict=True)
+ )
+
+ round_trip = BulletChartConfig.model_validate(mapped)
+ assert round_trip.ranges == ranges
+ assert round_trip.markers == markers
+ assert round_trip.marker_lines == marker_lines
+
+ model = resolve_bullet_render_model(
+ [{"SUM(revenue)": 1.0}],
+ mapped,
+ )
+ assert model.ranges == ranges
+ assert model.markers == markers
+ assert model.marker_lines == marker_lines
+ assert (
+ "1.7976931348623157e+308"
+ in _generate_ascii_preview_from_data(
+ [{"SUM(revenue)": 1.0}], mapped
+ ).ascii_content
+ )
+ vega = _generate_vega_lite_preview_from_data([{"SUM(revenue)": 1.0}], mapped)
+ assert vega.specification["layer"]
+
+
+def test_bullet_native_saved_metric_and_legacy_metric_aliases() -> None:
+ saved = BulletChartConfig.model_validate(
+ {"viz_type": "bullet", "metric": "saved_revenue"}
+ )
+ legacy = BulletChartConfig.model_validate(
+ {"viz_type": "bullet", "metric": "sum__revenue"}
+ )
+ assert saved.metric.saved_metric is True
+ assert saved.metric.name == "saved_revenue"
+ assert legacy.metric.name == "sum__revenue"
+ assert legacy.metric.saved_metric is True
+
+
+@pytest.mark.parametrize("metric_name", ["sum__num", "sum__SP_POP_TOTL"])
+@pytest.mark.parametrize(
+ ("request_type", "request_fields"),
+ [
+ (GenerateChartRequest, {"dataset_id": 7}),
+ (UpdateChartRequest, {"identifier": 9}),
+ (UpdateChartPreviewRequest, {"dataset_id": 7}),
+ ],
+)
+def test_bullet_repository_metric_names_round_trip_as_saved_metrics_on_all_requests(
+ metric_name: str,
+ request_type: type[
+ GenerateChartRequest | UpdateChartRequest | UpdateChartPreviewRequest
+ ],
+ request_fields: dict[str, object],
+) -> None:
+ request = request_type.model_validate(
+ {**request_fields, "config": {"chart_type": "bullet", "metric": metric_name}}
+ )
+ config = request.config
+ assert isinstance(config, BulletChartConfig)
+ assert config.metric.saved_metric is True
+ assert map_bullet_config(config)["metric"] == metric_name
+
+
+@pytest.mark.parametrize(
+ ("native_operator", "typed_operator", "round_trip_operator"),
+ [
+ ("EQUALS", "=", "=="),
+ ("NOT_EQUALS", "!=", "!="),
+ ("LESS_THAN", "<", "<"),
+ ("LESS_THAN_OR_EQUAL", "<=", "<="),
+ ("GREATER_THAN", ">", ">"),
+ ("GREATER_THAN_OR_EQUAL", ">=", ">="),
+ ("IN", "IN", "IN"),
+ ("NOT_IN", "NOT IN", "NOT IN"),
+ ("LIKE", "LIKE", "LIKE"),
+ ("ILIKE", "ILIKE", "ILIKE"),
+ ("IS_NULL", "IS NULL", "IS NULL"),
+ ("IS_NOT_NULL", "IS NOT NULL", "IS NOT NULL"),
+ ],
+)
+def test_bullet_native_filter_operator_names_round_trip(
+ native_operator: str, typed_operator: str, round_trip_operator: str
+) -> None:
+ comparator: object
+ if native_operator.startswith("IS_"):
+ comparator = None
+ elif native_operator in {"IN", "NOT_IN"}:
+ comparator = ["North"]
+ else:
+ comparator = "North"
+ config = BulletChartConfig.model_validate(
+ {
+ "viz_type": "bullet",
+ "metric": "SavedRevenue",
+ "adhoc_filters": [
+ {
+ "clause": "WHERE",
+ "expressionType": "SIMPLE",
+ "subject": "Region",
+ "operator": native_operator,
+ "comparator": comparator,
+ }
+ ],
+ }
+ )
+
+ assert config.filters is not None
+ assert config.filters[0].op == typed_operator
+ mapped = map_bullet_config(config)
+ assert mapped["adhoc_filters"][0]["operator"] == round_trip_operator
+
+
+def test_bullet_legacy_label_only_saved_metric_adapter_is_strict_and_bounded() -> None:
+ config = BulletChartConfig.model_validate(
+ {"viz_type": "bullet", "metric": {"label": "sum__num"}}
+ )
+ assert config.metric.saved_metric is True
+ assert map_bullet_config(config)["metric"] == "sum__num"
+
+ with pytest.raises(ValidationError):
+ BulletChartConfig.model_validate(
+ {
+ "viz_type": "bullet",
+ "metric": {"label": "sum__num", "aggregate": "SUM"},
+ }
+ )
+ with pytest.raises(ValidationError, match="at most 255"):
+ BulletChartConfig.model_validate(
+ {"viz_type": "bullet", "metric": {"label": "m" * 256}}
+ )
+
+
+@pytest.mark.parametrize(
+ "metric",
+ [
+ "SavedRevenue",
+ {
+ "aggregate": "SUM",
+ "column": {"column_name": "Revenue"},
+ "expressionType": "SIMPLE",
+ "label": "Simple Revenue",
+ },
+ {
+ "aggregate": None,
+ "column": None,
+ "expressionType": "SQL",
+ "sqlExpression": "SUM(Revenue)",
+ "label": "SQL Revenue",
+ },
+ ],
+)
+def test_bullet_all_metric_shapes_round_trip_full_native_presentation(
+ metric: object,
+) -> None:
+ native = {
+ "viz_type": "bullet",
+ "metric": metric,
+ "groupby": ["Region"],
+ "ranges": "50,100",
+ "range_labels": "Low,High",
+ "markers": "75",
+ "marker_labels": "Plan",
+ "marker_lines": "90",
+ "marker_line_labels": "Forecast",
+ "y_axis_format": "$,.0f",
+ "show_labels": True,
+ "show_legend": True,
+ }
+ mapped = map_bullet_config(BulletChartConfig.model_validate(native))
+ assert validate_merged_bullet_form_data(mapped) is not None
+ assert mapped["groupby"] == ["Region"]
+ assert mapped["ranges"] == "50,100"
+ assert mapped["marker_line_labels"] == "Forecast"
+
+
+def test_bullet_rejects_invalid_roles_and_output_collisions() -> None:
+ with pytest.raises(ValidationError, match="physical dimension"):
+ BulletChartConfig(
+ metric=_simple_metric(),
+ dimensions=[{"name": "region", "aggregate": "COUNT"}],
+ )
+ with pytest.raises(ValidationError, match="Duplicate Bullet dimension"):
+ BulletChartConfig(
+ metric=_simple_metric(),
+ dimensions=[{"name": "Region"}, {"name": "Region"}],
+ )
+ with pytest.raises(ValidationError, match="conflicts with a dimension"):
+ BulletChartConfig(
+ metric={"name": "revenue", "aggregate": "SUM", "label": "Region"},
+ dimensions=[{"name": "Region", "label": "Friendly"}],
+ )
+
+
+def test_bullet_accepts_short_labels_and_rejects_bad_order_target() -> None:
+ config = BulletChartConfig(
+ metric=_simple_metric(), ranges=[1, 2], range_labels=["Only one"]
+ )
+ assert config.range_labels == ["Only one"]
+ with pytest.raises(ValidationError, match="unknown: not_a_role"):
+ BulletChartConfig(
+ metric=_simple_metric(), dimensions=[], order_by=[{"column": "not_a_role"}]
+ )
+
+
+@pytest.mark.parametrize("empty_label", ["", " ", "\u200b", "<b></b>"])
+@pytest.mark.parametrize(
+ ("value_control", "label_control"),
+ [
+ ("ranges", "range_labels"),
+ ("markers", "marker_labels"),
+ ("marker_lines", "marker_line_labels"),
+ ],
+)
+def test_bullet_sanitized_empty_labels_preserve_slots(
+ empty_label: str, value_control: str, label_control: str
+) -> None:
+ """Empty annotations must not shift later labels to earlier values."""
+ config = BulletChartConfig.model_validate(
+ {
+ "metric": _simple_metric(),
+ value_control: [10, 20],
+ label_control: [empty_label, "goal"],
+ }
+ )
+ assert getattr(config, label_control) == ["", "goal"]
+ mapped = map_bullet_config(config)
+ assert mapped[value_control] == "10,20"
+ assert mapped[label_control] == ",goal"
+
+
+def test_bullet_dimension_labels_are_input_aliases_not_result_aliases() -> None:
+ config = BulletChartConfig(
+ metric={"name": "Revenue", "aggregate": "SUM", "label": "Total"},
+ dimensions=[
+ {"name": "Team", "label": "Region"},
+ {"name": "Region", "label": "Market"},
+ ],
+ # The exact physical Region must win over Team's display label.
+ order_by=[
+ {"column": "Region", "ascending": True},
+ {"column": "Revenue", "ascending": False},
+ ],
+ )
+ form_data = map_bullet_config(config)
+ assert form_data["groupby"] == ["Team", "Region"]
+ assert form_data["orderby"] == [
+ ["Region", True],
+ [form_data["metric"], False],
+ ]
+
+ label_order = map_bullet_config(
+ BulletChartConfig(
+ metric=config.metric,
+ dimensions=config.dimensions,
+ order_by=[{"column": "Market"}],
+ )
+ )
+ assert label_order["orderby"] == [["Region", False]]
+
+ model = resolve_bullet_render_model(
+ [{"Team": "Blue", "Region": "North", "Total": 10}], form_data
+ )
+ assert model.dimensions == ["Team", "Region"]
+ assert [model.rows[0][name] for name in model.dimensions] == ["Blue", "North"]
+
+
+def test_bullet_rejects_ambiguous_display_alias_for_ordering() -> None:
+ with pytest.raises(ValidationError, match="ambiguous display alias"):
+ BulletChartConfig(
+ metric=_simple_metric(),
+ dimensions=[
+ {"name": "Region", "label": "Area"},
+ {"name": "Team", "label": "area"},
+ ],
+ order_by=[{"column": "AREA"}],
+ )
+
+
+@pytest.mark.parametrize(
+ ("metric", "order_target", "output"),
+ [
+ (
+ {"name": "SavedRevenue", "saved_metric": True, "label": "Friendly"},
+ "Friendly",
+ "SavedRevenue",
+ ),
+ (
+ {"name": "Revenue", "aggregate": "SUM", "label": "Simple Total"},
+ "Revenue",
+ "Simple Total",
+ ),
+ (
+ {"sql_expression": "SUM(Revenue)", "label": "SQL Total"},
+ "SQL Total",
+ "SQL Total",
+ ),
+ ],
+)
+def test_bullet_metric_shapes_share_physical_dimension_output_contract(
+ metric: dict[str, object], order_target: str, output: str
+) -> None:
+ config = BulletChartConfig(
+ metric=metric,
+ dimensions=[{"name": "Region", "label": "Market"}],
+ order_by=[{"column": order_target}],
+ )
+ form_data = map_bullet_config(config)
+ assert form_data["groupby"] == ["Region"]
+ assert form_data["orderby"] == [[form_data["metric"], False]]
+ model = resolve_bullet_render_model([{"Region": "North", output: 12}], form_data)
+ assert model.metric_field == output
+ assert model.dimensions == ["Region"]
+
+
+def test_bullet_mapper_preserves_omission_and_honors_explicit_values() -> None:
+ omitted = map_bullet_config(BulletChartConfig(metric=_simple_metric()))
+ explicit = map_bullet_config(
+ BulletChartConfig(
+ metric=_simple_metric(),
+ dimensions=[],
+ filters=[],
+ ranges=[],
+ show_labels=False,
+ show_legend=False,
+ row_limit=42,
+ time_range=None,
+ )
+ )
+ for key in (
+ "groupby",
+ "adhoc_filters",
+ "ranges",
+ "show_labels",
+ "show_legend",
+ "time_range",
+ ):
+ assert key not in omitted
+ # A new chart is bounded by the advertised schema default.
+ assert omitted["row_limit"] == 10000
+ assert explicit["groupby"] == []
+ assert explicit["adhoc_filters"] == []
+ assert explicit["ranges"] == ""
+ assert explicit["show_labels"] is False
+ assert explicit["show_legend"] is False
+ assert explicit["row_limit"] == 42
+ assert explicit["time_range"] is None
+
+
+def test_bullet_registry_schema_and_recommendation_metadata() -> None:
+ from superset.mcp_service.app import get_default_instructions
+ from superset.mcp_service.chart.registry import display_name_for_viz_type, get
+
+ plugin = get("bullet")
+ assert plugin is not None
+ assert plugin.resolve_viz_type(None) == "bullet"
+ assert display_name_for_viz_type("bullet") == "Bullet Chart"
+ assert "bullet" in VALID_CHART_TYPES
+ discovered = _get_chart_type_schema_impl("bullet")
+ assert discovered["chart_type"] == "bullet"
+ assert discovered["examples"][0]["ranges"] == [100000, 250000, 500000]
+ assert _VIZ_CATEGORY["bullet"] == "bullet"
+ candidates = _candidates_single_numeric(
+ DataColumn(
+ name="Revenue",
+ display_name="Revenue",
+ data_type="numeric",
+ sample_values=[1],
+ null_count=0,
+ unique_count=1,
+ ),
+ row_count=1,
+ )
+ assert "bullet chart" in candidates
+ guidance = get_default_instructions()
+ assert 'chart_type="bullet": Bullet Chart' in guidance
+ assert "waterfall, gantt, bullet, bubble_v2, and interactive_pivot" in guidance
+
+
+def test_bullet_dataset_normalization_canonicalizes_every_reference() -> None:
+ from superset.mcp_service.chart.registry import get
+
+ context = DatasetContext(
+ id=7,
+ table_name="sales",
+ schema=None,
+ database_name="main",
+ available_columns=[
+ {"name": "Revenue", "type": "NUMERIC", "is_numeric": True},
+ {"name": "Region", "type": "VARCHAR"},
+ {"name": "OrderDate", "type": "TIMESTAMP", "is_temporal": True},
+ {"name": "Status", "type": "VARCHAR"},
+ ],
+ available_metrics=[],
+ )
+ config = BulletChartConfig(
+ metric={"name": "revenue", "aggregate": "SUM"},
+ dimensions=[{"name": "region"}],
+ temporal_column="orderdate",
+ filters=[{"column": "status", "op": "=", "value": "active"}],
+ order_by=[{"column": "region", "ascending": True}],
+ )
+ plugin = get("bullet")
+ assert plugin is not None
+ normalized = plugin.normalize_column_refs(config, context)
+ assert normalized.metric.name == "Revenue"
+ assert normalized.dimensions[0].name == "Region"
+ assert normalized.temporal_column == "OrderDate"
+ assert normalized.filters[0].column == "Status"
+ assert normalized.order_by[0].column == "Region"
+ assert normalized.model_fields_set == config.model_fields_set
+
+
+def test_bullet_dataset_normalization_rejects_ambiguous_casefold_candidates() -> None:
+ from superset.mcp_service.chart.registry import get
+
+ context = DatasetContext(
+ id=7,
+ table_name="sales",
+ schema=None,
+ database_name="main",
+ available_columns=[
+ {"name": "Revenue", "type": "NUMERIC", "is_numeric": True},
+ {"name": "revenue", "type": "NUMERIC", "is_numeric": True},
+ ],
+ available_metrics=[],
+ )
+ plugin = get("bullet")
+ assert plugin is not None
+ config = BulletChartConfig(metric={"name": "REVENUE", "aggregate": "SUM"})
+ with pytest.raises(ValueError, match="'Revenue', 'revenue'"):
+ plugin.normalize_column_refs(config, context)
+
+
+def test_bullet_numeric_output_constraint_rejects_text_min() -> None:
+ from superset.mcp_service.chart.registry import get
+
+ context = DatasetContext(
+ id=7,
+ table_name="sales",
+ schema=None,
+ database_name="main",
+ available_columns=[{"name": "status", "type": "VARCHAR"}],
+ available_metrics=[],
+ )
+ plugin = get("bullet")
+ assert plugin is not None
+ config = BulletChartConfig(metric={"name": "status", "aggregate": "MIN"})
+ with patch.object(DatasetValidator, "_get_dataset_context", return_value=context):
+ error = plugin.post_map_validate(config, {}, dataset_id=7)
+ assert error is not None
+ assert error.error_type == "non_numeric_bullet_metric"
+
+
+@pytest.mark.parametrize("reverse_metadata", [False, True])
+def test_bullet_exact_case_type_and_role_resolution_is_order_independent(
+ reverse_metadata: bool,
+) -> None:
+ from superset.mcp_service.chart.registry import get
+
+ columns = [
+ {"name": "Revenue", "type": "NUMERIC", "is_numeric": True},
+ {"name": "revenue", "type": "VARCHAR", "is_numeric": False},
+ {"name": "Region", "type": "VARCHAR"},
+ ]
+ if reverse_metadata:
+ columns.reverse()
+ context = DatasetContext(
+ id=7,
+ table_name="sales",
+ schema=None,
+ database_name="main",
+ available_columns=columns,
+ available_metrics=[],
+ )
+ plugin = get("bullet")
+ assert plugin is not None
+
+ numeric = BulletChartConfig(metric={"name": "Revenue", "aggregate": "SUM"})
+ text = BulletChartConfig(metric={"name": "revenue", "aggregate": "MIN"})
+ with patch.object(DatasetValidator, "_get_dataset_context", return_value=context):
+ assert plugin.post_map_validate(numeric, {}, dataset_id=7) is None
+ error = plugin.post_map_validate(text, {}, dataset_id=7)
+ assert error is not None
+ assert error.error_type == "non_numeric_bullet_metric"
+
+ roles = BulletChartConfig(
+ metric={"name": "Revenue", "aggregate": "SUM"},
+ dimensions=[{"name": "revenue"}, {"name": "Region"}],
+ filters=[{"column": "revenue", "op": "=", "value": "retail"}],
+ order_by=[{"column": "revenue", "ascending": True}],
+ )
+ normalized = plugin.normalize_column_refs(roles, context)
+ assert normalized.metric.name == "Revenue"
+ assert [dimension.name for dimension in normalized.dimensions or []] == [
+ "revenue",
+ "Region",
+ ]
+ assert normalized.filters
+ assert normalized.filters[0].column == "revenue"
+ assert normalized.order_by[0].column == "revenue"
+
+ ambiguous = BulletChartConfig(metric={"name": "REVENUE", "aggregate": "SUM"})
+ with pytest.raises(
+ AmbiguousDatasetReferenceError, match="Bullet metric column reference"
+ ):
+ plugin.normalize_column_refs(ambiguous, context)
+
+
+@pytest.mark.parametrize("reverse_metadata", [False, True])
+def test_generic_aggregation_validation_uses_exact_case_before_type(
+ reverse_metadata: bool,
+) -> None:
+ from superset.mcp_service.chart.schemas import PieChartConfig
+
+ columns = [
+ {"name": "Amount", "type": "BIGINT", "is_numeric": True},
+ {"name": "amount", "type": "VARCHAR", "is_numeric": False},
+ ]
+ if reverse_metadata:
+ columns.reverse()
+ context = DatasetContext(
+ id=7,
+ table_name="sales",
+ schema=None,
+ database_name="main",
+ available_columns=columns,
+ available_metrics=[],
+ )
+
+ assert (
+ DatasetValidator._validate_aggregations(
+ [BulletChartConfig(metric={"name": "Amount", "aggregate": "SUM"}).metric],
+ context,
+ )
+ == []
+ )
+ errors = DatasetValidator._validate_aggregations(
+ [BulletChartConfig(metric={"name": "amount", "aggregate": "SUM"}).metric],
+ context,
+ )
+ assert errors
+ assert errors[0].error_type == "invalid_aggregation"
+
+ ambiguous = DatasetValidator._validate_aggregations(
+ [BulletChartConfig(metric={"name": "AMOUNT", "aggregate": "SUM"}).metric],
+ context,
+ )
+ assert ambiguous
+ assert ambiguous[0].error_type == "ambiguous_dataset_reference"
+
+ valid, error = DatasetValidator.validate_against_dataset(
+ PieChartConfig(
+ dimension={"name": "amount"},
+ metric={"name": "Amount", "aggregate": "SUM"},
+ ),
+ 7,
+ dataset_context=context,
+ )
+ assert valid is True
+ assert error is None
+
+
+@pytest.mark.parametrize(
+ ("metric", "field"),
+ [
+ ({"name": "Revenue", "aggregate": "SUM", "label": "Simple"}, "Simple"),
+ ({"name": "SavedRevenue", "saved_metric": True}, "SavedRevenue"),
+ ({"sql_expression": "SUM(Revenue)", "label": "SQL Total"}, "SQL Total"),
+ ],
+)
+def test_bullet_result_roles_are_exact_for_every_metric_shape(
+ metric: dict[str, object], field: str
+) -> None:
+ form_data = map_bullet_config(BulletChartConfig(metric=metric))
+ model = resolve_bullet_render_model([{field.swapcase(): "12.5"}], form_data)
+ assert model.metric_field == field.swapcase()
+ assert model.measures == [12.5]
+
+
+@pytest.mark.parametrize(
+ "rows, message",
+ [
+ ([{"other": 123}], "missing"),
+ ([{"Revenue": "not a number"}], "non-numeric text"),
+ ([{"Revenue": math.nan}], "NaN or infinite"),
+ ([{"Revenue": math.inf}], "NaN or infinite"),
+ ([{"Revenue": 1}, {}], "row 1.*missing"),
+ ([{"REVENUE": 1, "revenue": 2}], "ambiguous"),
+ ],
+)
+def test_bullet_result_validation_rejects_malformed_rows(
+ rows: list[dict[str, object]], message: str
+) -> None:
+ form_data = map_bullet_config(
+ BulletChartConfig(
+ metric={"name": "amount", "aggregate": "SUM", "label": "Revenue"}
+ )
+ )
+ with pytest.raises(BulletOutputError, match=message):
+ resolve_bullet_render_model(rows, form_data)
+
+
+def test_bullet_result_validation_accepts_null_and_numeric_strings() -> None:
+ form_data = map_bullet_config(
+ BulletChartConfig(
+ metric={"name": "amount", "aggregate": "SUM", "label": "Revenue"},
+ dimensions=[{"name": "Region"}],
+ )
+ )
+ model = resolve_bullet_render_model(
+ [
+ {"Region": "North", "Revenue": None},
+ {"Region": "South", "Revenue": " 4.25 "},
+ ],
+ form_data,
+ )
+ assert model.measures == [0.0, 4.25]
+
+
+@pytest.mark.parametrize(
+ ("presentation", "message"),
+ [
+ ({"markers": "Infinity"}, r"markers\[0\].*NaN or infinite"),
+ ],
+)
+def test_bullet_result_validation_rejects_malformed_presentation(
+ presentation: dict[str, object], message: str
+) -> None:
+ form_data = {
+ **map_bullet_config(
+ BulletChartConfig(metric={"name": "amount", "aggregate": "SUM"})
+ ),
+ **presentation,
+ }
+ with pytest.raises(BulletOutputError, match=message):
+ resolve_bullet_render_model([{"SUM(amount)": 1}], form_data)
+
+
+def test_bullet_compile_accepts_empty_ungrouped_result() -> None:
+ form_data = map_bullet_config(
+ BulletChartConfig(
+ metric={"name": "Revenue", "aggregate": "SUM", "label": "Revenue"}
+ )
+ )
+ factory = MagicMock()
+ factory.create.return_value = MagicMock()
+ command = MagicMock()
+ command.run.return_value = {"queries": [{"data": []}]}
+ with (
+ patch(
+ "superset.common.query_context_factory.QueryContextFactory",
+ return_value=factory,
+ ),
+ patch(
+ "superset.commands.chart.data.get_data_command.ChartDataCommand",
+ return_value=command,
+ ),
+ ):
+ result = _compile_chart(form_data, 7)
+ assert result.success is True
+ assert result.row_count == 0
+
+
+def test_bullet_compile_inspects_top_level_and_query_error_envelopes() -> None:
+ form_data = map_bullet_config(
+ BulletChartConfig(metric={"name": "Revenue", "aggregate": "SUM"})
+ )
+ factory = MagicMock()
+ factory.create.return_value = MagicMock()
+ command = MagicMock()
+ command.run.return_value = {
+ "status": "success",
+ "queries": [{"status": "failed", "message": "warehouse timeout"}],
+ }
+ with (
+ patch(
+ "superset.common.query_context_factory.QueryContextFactory",
+ return_value=factory,
+ ),
+ patch(
+ "superset.commands.chart.data.get_data_command.ChartDataCommand",
+ return_value=command,
+ ),
+ ):
+ result = _compile_chart(form_data, 7)
+ assert result.success is False
+ assert "warehouse timeout" in (result.error or "")
+
+
+_MALFORMED_QUERY_ENVELOPES: list[object] = [
+ None,
+ [],
+ {},
+ {"queries": None},
+ {"queries": []},
+ {"queries": [None]},
+ {"queries": [{}]},
+ {"queries": [{"data": None}]},
+ {"queries": [{"data": []}, {"data": "not-an-array"}]},
+ {
+ "queries": [
+ {
+ "data": [{"Revenue": 12}],
+ "colnames": ["Revenue"],
+ "coltypes": [],
+ }
+ ]
+ },
+]
+
+
+def _compile_bullet_with_result(result: object) -> Any:
+ form_data = map_bullet_config(
+ BulletChartConfig(
+ metric={"name": "Revenue", "aggregate": "SUM", "label": "Revenue"}
+ )
+ )
+ factory = MagicMock()
+ factory.create.return_value = MagicMock()
+ command = MagicMock()
+ command.run.return_value = result
+ with (
+ patch(
+ "superset.common.query_context_factory.QueryContextFactory",
+ return_value=factory,
+ ),
+ patch(
+ "superset.commands.chart.data.get_data_command.ChartDataCommand",
+ return_value=command,
+ ),
+ ):
+ return _compile_chart(form_data, 7)
+
+
+@pytest.mark.parametrize("envelope", _MALFORMED_QUERY_ENVELOPES)
+def test_bullet_compile_returns_stable_error_for_malformed_envelopes(
+ envelope: object,
+) -> None:
+ result = _compile_bullet_with_result(envelope)
+ assert result.success is False
+ assert result.error_code == "CHART_COMPILE_FAILED"
+ assert result.error_obj is not None
+ assert result.error_obj.error_type == "compile_error"
+
+
+@pytest.mark.parametrize(
+ ("data", "expected_code", "expected_type"),
+ [
+ ([1], "CHART_COMPILE_FAILED", "compile_error"),
+ (
+ [{"Revenue": 10**10000}],
+ "CHART_COMPILE_FAILED",
+ "compile_error",
+ ),
+ ],
+)
+def test_bullet_compile_returns_malformed_output_for_bad_rows(
+ data: list[object],
+ expected_code: str,
+ expected_type: str,
+) -> None:
+ result = _compile_bullet_with_result({"queries": [{"data": data}]})
+ assert result.success is False
+ assert result.error_code == expected_code
+ assert result.error_obj is not None
+ assert result.error_obj.error_type == expected_type
+
+
+def test_bullet_query_builder_forwards_configured_roles_sort_and_limit() -> None:
+ metric = map_bullet_config(
+ BulletChartConfig(
+ metric={"name": "Revenue", "aggregate": "SUM", "label": "Revenue"},
+ dimensions=[{"name": "Region"}],
+ row_limit=25,
+ order_by=[{"column": "Revenue", "ascending": False}],
+ )
+ )
+ with patch(
+ "superset.mcp_service.chart.chart_helpers.resolve_datasource_engine",
+ return_value="base",
+ ):
+ queries = build_query_dicts_from_form_data(metric, 7, "table")
+ assert len(queries) == 1
+ assert queries[0]["columns"] == ["Region"]
+ assert queries[0]["metrics"] == [metric["metric"]]
+ assert queries[0]["orderby"] == metric["orderby"]
+ assert queries[0]["row_limit"] == 25
+
+
+def test_bullet_compile_path_uses_groupby_metric_orderby_and_usable_result() -> None:
+ form_data = map_bullet_config(
+ BulletChartConfig(
+ metric={"name": "Revenue", "aggregate": "SUM", "label": "Revenue"},
+ dimensions=[{"name": "Region"}],
+ order_by=[{"column": "Revenue", "ascending": False}],
+ )
+ )
+ context = MagicMock()
+ factory = MagicMock()
+ factory.create.return_value = context
+ command = MagicMock()
+ command.run.return_value = {
+ "queries": [{"data": [{"Region": "North", "Revenue": "12.5"}]}]
+ }
+ with (
+ patch(
+ "superset.common.query_context_factory.QueryContextFactory",
+ return_value=factory,
+ ),
+ patch(
+ "superset.commands.chart.data.get_data_command.ChartDataCommand",
+ return_value=command,
+ ),
+ ):
+ result = _compile_chart(form_data, 7)
+ query = factory.create.call_args.kwargs["queries"][0]
+ assert query["columns"] == ["Region"]
+ assert query["metrics"] == [form_data["metric"]]
+ assert query["orderby"] == form_data["orderby"]
+ assert result.success is True
+ assert result.row_count == 1
+
+
+def test_bullet_compile_projects_dataframe_timestamp_before_validation() -> None:
+ from superset.dataframe import df_to_records
+
+ dublin = dateutil_tz.gettz("Europe/Dublin")
+ new_york = dateutil_tz.gettz("America/New_York")
+ assert dublin is not None
+ assert new_york is not None
+ source_values = [
+ pd.Timestamp("2024-01-02 03:04:05.123456789"),
+ datetime(2024, 10, 27, 1, 30, tzinfo=dublin, fold=1),
+ datetime(2024, 3, 10, 2, 30, tzinfo=new_york),
+ datetime(2040, 7, 1, 12, tzinfo=new_york),
+ ]
+ form_data = map_bullet_config(
+ BulletChartConfig(
+ metric={"name": "Revenue", "aggregate": "SUM", "label": "Revenue"},
+ dimensions=[{"name": "Category"}],
+ )
+ )
+ rows = df_to_records(
+ pd.DataFrame(
+ {
+ "Category": pd.Series(source_values, dtype=object),
+ "Revenue": range(1, len(source_values) + 1),
+ }
+ ),
+ convert_big_integers=False,
+ )
+ factory = MagicMock()
+ factory.create.return_value = MagicMock()
+ command = MagicMock()
+ command.run.return_value = {"queries": [{"data": rows}]}
+ captured: list[list[dict[str, Any]]] = []
+
+ def validate(
+ data: list[dict[str, Any]],
+ config: dict[str, Any],
+ *,
+ validate_format: bool = True,
+ bound_labels: bool = True,
+ ) -> Any:
+ """Capture the projected data while preserving render-model options."""
+ captured.append(data)
+ return resolve_bullet_render_model(
+ data,
+ config,
+ validate_format=validate_format,
+ bound_labels=bound_labels,
+ )
+
+ with (
+ patch(
+ "superset.common.query_context_factory.QueryContextFactory",
+ return_value=factory,
+ ),
+ patch(
+ "superset.commands.chart.data.get_data_command.ChartDataCommand",
+ return_value=command,
+ ),
+ patch(
+ "superset.mcp_service.chart.preview_utils.resolve_bullet_render_model",
+ side_effect=validate,
+ ),
+ ):
+ result = _compile_chart(form_data, 7)
+
+ assert result.success is True
+ assert [row["Category"] for row in captured[0]] == [
+ 1704164645123.456,
+ 1729989000000.0,
+ 1710052200000.0,
+ 2224774800000.0,
+ ]
+
+
+def test_bullet_compile_projects_real_dataframe_durations_to_chart_data_wire() -> None:
+ from superset.commands.chart.data.get_data_command import ChartDataCommand
+ from superset.common.chart_data import ChartDataResultType
+ from superset.dataframe import df_to_records
+ from superset.utils import json
+
+ source_values = [
+ timedelta(0),
+ timedelta(days=1, seconds=2, microseconds=3),
+ pd.Timedelta(-1, unit="ns"),
+ pd.Timedelta("1 days 00:00:02.000003004"),
+ np.timedelta64(123456789, "ns"),
+ np.timedelta64("NaT"),
+ ]
+ rows = df_to_records(
+ pd.DataFrame(
+ {
+ "Duration": pd.Series(source_values, dtype=object),
+ "Revenue": range(1, len(source_values) + 1),
+ }
+ ),
+ convert_big_integers=False,
+ )
+ assert type(rows[4]["Duration"]) is pd.Timedelta
+ assert rows[5]["Duration"] is None
+ expected = [
+ None
+ if row["Duration"] is None
+ else json.loads(json.dumps(row["Duration"], default=json.json_int_dttm_ser))
+ for row in rows
+ ]
+
+ class _ProducerContext:
+ result_type = ChartDataResultType.FULL
+
+ def get_payload(self, **_kwargs: Any) -> dict[str, Any]:
+ return {"queries": [{"data": rows, "rowcount": len(rows)}]}
+
+ producer_result = ChartDataCommand(_ProducerContext()).run() # type: ignore[arg-type]
+ form_data = {
+ "viz_type": "bullet",
+ "metric": "Revenue",
+ "groupby": ["Duration"],
+ }
+ factory = MagicMock()
+ factory.create.return_value = MagicMock()
+ command = MagicMock()
+ command.run.return_value = producer_result
+ captured: list[list[dict[str, Any]]] = []
+
+ def validate(
+ data: list[dict[str, Any]],
+ config: dict[str, Any],
+ *,
+ validate_format: bool = True,
+ bound_labels: bool = True,
+ ) -> Any:
+ """Capture the projected data while preserving render-model options."""
+ captured.append(data)
+ return resolve_bullet_render_model(
+ data,
+ config,
+ validate_format=validate_format,
+ bound_labels=bound_labels,
+ )
+
+ with (
+ patch(
+ "superset.common.query_context_factory.QueryContextFactory",
+ return_value=factory,
+ ),
+ patch(
+ "superset.commands.chart.data.get_data_command.ChartDataCommand",
+ return_value=command,
+ ),
+ patch(
+ "superset.mcp_service.chart.preview_utils.resolve_bullet_render_model",
+ side_effect=validate,
+ ),
+ ):
+ result = _compile_chart(form_data, 7)
+
+ assert result.success is True
+ assert [row["Duration"] for row in captured[0]] == expected
+
+
+def test_bullet_compile_accepts_transitionless_dateutil_dataframe_producer() -> None:
+ from superset.commands.chart.data.get_data_command import ChartDataCommand
+ from superset.common.chart_data import ChartDataResultType
+ from superset.dataframe import df_to_records
+
+ names = [
+ "UTC",
+ "GMT",
+ "Universal",
+ "Zulu",
+ "EST",
+ "HST",
+ "MST",
+ "Etc/GMT+1",
+ "Etc/GMT-2",
+ ]
+ values = []
+ for getter in (dateutil_tz.gettz, get_zonefile_instance().get):
+ for name in names:
+ timezone_value = getter(name)
+ assert timezone_value is not None
+ values.append(
+ datetime(2040, 7, 1, 12, 34, 56, 123456, tzinfo=timezone_value)
+ )
+ rows = df_to_records(
+ pd.DataFrame(
+ {
+ "Category": pd.Series(values, dtype=object),
+ "Revenue": range(1, len(values) + 1),
+ }
+ ),
+ convert_big_integers=False,
+ )
+
+ class _ProducerContext:
+ result_type = ChartDataResultType.FULL
+
+ def get_payload(self, **_kwargs: Any) -> dict[str, Any]:
+ return {
+ "queries": [
+ {
+ "data": rows,
+ "colnames": ["Category", "Revenue"],
+ "coltypes": [
+ GenericDataType.TEMPORAL,
+ GenericDataType.NUMERIC,
+ ],
+ "rowcount": len(rows),
+ }
+ ]
+ }
+
+ producer_result = ChartDataCommand(_ProducerContext()).run() # type: ignore[arg-type]
+ form_data = map_bullet_config(
+ BulletChartConfig(
+ metric={"name": "Revenue", "aggregate": "SUM", "label": "Revenue"},
+ dimensions=[{"name": "Category"}],
+ )
+ )
+ factory = MagicMock()
+ factory.create.return_value = MagicMock()
+ command = MagicMock()
+ command.run.return_value = producer_result
+ captured: list[list[dict[str, Any]]] = []
+
+ def validate(
+ data: list[dict[str, Any]],
+ config: dict[str, Any],
+ *,
+ validate_format: bool = True,
+ bound_labels: bool = True,
+ ) -> Any:
+ """Capture the projected data while preserving render-model options."""
+ captured.append(data)
+ return resolve_bullet_render_model(
+ data,
+ config,
+ validate_format=validate_format,
+ bound_labels=bound_labels,
+ )
+
+ with (
+ patch(
+ "superset.common.query_context_factory.QueryContextFactory",
+ return_value=factory,
+ ),
+ patch(
+ "superset.commands.chart.data.get_data_command.ChartDataCommand",
+ return_value=command,
+ ),
+ patch(
+ "superset.mcp_service.chart.preview_utils.resolve_bullet_render_model",
+ side_effect=validate,
+ ),
+ ):
+ result = _compile_chart(form_data, 7)
+
+ assert result.success is True
+ assert result.row_count == len(values)
+ assert [row["Category"] for row in captured[0]] == [
+ json_int_dttm_ser(value) for value in values
+ ]
+
+
+def test_bullet_unsaved_preview_path_returns_faithful_vega_spec() -> None:
+ form_data = map_bullet_config(
+ BulletChartConfig(
+ metric={
+ "name": "revenue_amount",
+ "aggregate": "SUM",
+ "label": "Total Revenue",
+ },
+ dimensions=[{"name": "Region"}, {"name": "Team"}],
+ ranges=[100, 200],
+ markers=[150],
+ marker_lines=[175],
+ )
+ )
+ preview = _generate_vega_lite_preview_from_data(
+ [{"Region": "North", "Team": "Blue", "Total Revenue": 123}], form_data
+ )
+ specification = preview.specification
+ assert specification["data"]["values"][0]["__mcp_bullet_category"] == (
+ "North, Blue"
+ )
+ bar_layer = next(
+ layer for layer in specification["layer"] if layer["mark"]["type"] == "bar"
+ )
+ assert bar_layer["encoding"]["x"]["field"] == "Total Revenue"
+ assert bar_layer["encoding"]["y"]["field"] == "__mcp_bullet_row"
+ assert [item["field"] for item in bar_layer["encoding"]["tooltip"]] == [
+ "__mcp_bullet_category",
+ "Total Revenue",
+ ]
+ assert bar_layer["encoding"]["tooltip"][0]["title"] == "Region, Team"
+ assert {layer["mark"]["type"] for layer in specification["layer"]} == {
+ "rect",
+ "bar",
+ "point",
+ "rule",
+ "text",
+ }
+
+
+def test_bullet_vega_internal_category_key_avoids_adversarial_row_aliases() -> None:
+ form_data = map_bullet_config(
+ BulletChartConfig(
+ metric={"name": "value", "aggregate": "SUM", "label": "Metric"},
+ dimensions=[{"name": "__mcp_bullet_category"}],
+ )
+ )
+ preview = _generate_vega_lite_preview_from_data(
+ [
+ {
+ "__mcp_bullet_category": "North",
+ "__mcp_bullet_category_1": "occupied",
+ "__mcp_bullet_row": "occupied",
+ "Metric": 10,
+ }
+ ],
+ form_data,
+ )
+ specification = preview.specification
+ bar = next(
+ layer for layer in specification["layer"] if layer["mark"]["type"] == "bar"
+ )
+ category_field = bar["encoding"]["tooltip"][0]["field"]
+ assert category_field == "__mcp_bullet_category_2"
+ assert specification["data"]["values"][0][category_field] == "North"
+ assert bar["encoding"]["y"]["field"] == "__mcp_bullet_row_1"
+ assert specification["data"]["values"][0]["__mcp_bullet_row_1"] == 0
+ assert "transform" not in specification
+
+
+def test_bullet_duration_categories_match_chart_data_wire_in_ascii_and_vega() -> None:
+ values = [
+ timedelta(0),
+ timedelta(microseconds=-1),
+ timedelta(days=1, seconds=2, microseconds=3),
+ pd.Timedelta(0),
+ pd.Timedelta(-1, unit="ns"),
+ pd.Timedelta("1 days 00:00:02.000003004"),
+ np.timedelta64(123456789, "ns"),
+ np.timedelta64("NaT"),
+ ]
+ expected = []
+ for value in values:
+ projected, reason = _chart_data_duration_text(value)
+ assert reason is None
+ expected.append("null" if projected is None else projected)
+ form_data = {
+ "viz_type": "bullet",
+ "metric": "Revenue",
+ "groupby": ["Duration"],
+ }
+ data = [
+ {"Duration": value, "Revenue": index + 1} for index, value in enumerate(values)
+ ]
+
+ vega = _generate_vega_lite_preview_from_data(data, form_data).specification
+ bar = next(layer for layer in vega["layer"] if layer["mark"]["type"] == "bar")
+ category_field = bar["encoding"]["tooltip"][0]["field"]
+ assert [row[category_field] for row in vega["data"]["values"]] == expected
+ assert [row["Duration"] for row in vega["data"]["values"]] == [
+ None if value == "null" else value for value in expected
+ ]
+ assert bar["encoding"]["tooltip"][0]["field"] == category_field
+
+ ascii_preview = _generate_ascii_preview_from_data(data, form_data).ascii_content
+ for value in expected:
+ assert value[:20] in ascii_preview
+
+
+def test_bullet_duration_category_rejects_subclass_without_hooks() -> None:
+ class HostileTimedelta(timedelta):
+ calls = 0
+
+ def _call(self, *_args: object) -> Any:
+ type(self).calls += 1
+ raise AssertionError("duration hook executed")
+
+ __abs__ = _call
+ __eq__ = _call
+ __lt__ = _call
+ __str__ = _call
+
+ with pytest.raises(BulletOutputError, match="unsupported value type"):
+ resolve_bullet_render_model(
+ [{"Duration": HostileTimedelta(seconds=1), "Revenue": 1}],
+ {"metric": "Revenue", "groupby": ["Duration"]},
+ )
+ assert HostileTimedelta.calls == 0
+
+
+@pytest.mark.parametrize(
+ ("value", "expected"),
+ [
+ (9007199254740993, "9007199254740992"),
+ (9007199254740995, "9007199254740996"),
+ (Decimal("1.0000000000000001"), "1"),
+ (Decimal("1.0000000000000002"), "1.0000000000000002"),
+ (Decimal("1e20"), "100000000000000000000"),
+ (Decimal("1e21"), "1e+21"),
+ (Decimal("1e-6"), "0.000001"),
+ (Decimal("1e-7"), "1e-7"),
+ (Decimal("1.7976931348623157e308"), "1.7976931348623157e+308"),
+ (Decimal("1.7976931348623159e308"), "Infinity"),
+ (Decimal("-1e309"), "-Infinity"),
+ (10**400, "Infinity"),
+ (Decimal("-0"), "0"),
+ ],
+)
+def test_bullet_number_categories_match_node_ieee754_string_semantics(
+ value: int | Decimal, expected: str
+) -> None:
+ assert _javascript_number_string(value) == expected
+
+
+@pytest.mark.parametrize(
+ "value",
+ [float("inf"), Decimal("NaN"), Decimal("Infinity")],
+)
+def test_bullet_number_categories_reject_nonfinite_source_values(value: object) -> None:
+ with pytest.raises(BulletOutputError, match="non-finite"):
+ resolve_bullet_render_model(
+ [{"Category": value, "Revenue": 1}],
+ {"metric": "Revenue", "groupby": ["Category"]},
+ )
+
+
+def test_bullet_nan_category_retains_shared_null_normalization() -> None:
+ model = resolve_bullet_render_model(
+ [{"Category": float("nan"), "Revenue": 1}],
+ {"metric": "Revenue", "groupby": ["Category"]},
+ )
+ assert model.rows[0]["Category"] is None
+
+
+def test_bullet_number_category_boundaries_flow_through_ascii_and_vega() -> None:
+ form_data = {
+ "viz_type": "bullet",
+ "metric": "Revenue",
+ "groupby": ["Category"],
+ }
+ values = [
+ 9007199254740993,
+ Decimal("1.0000000000000001"),
+ Decimal("1e20"),
+ Decimal("1e21"),
+ Decimal("1e-7"),
+ Decimal("1.7976931348623159e308"),
+ Decimal("-0"),
+ ]
+ expected = [
+ "9007199254740992",
+ "1",
+ "100000000000000000000",
+ "1e+21",
+ "1e-7",
+ "Infinity",
+ "0",
+ ]
+ data = [
+ {"Category": value, "Revenue": index + 1} for index, value in enumerate(values)
+ ]
+
+ vega = _generate_vega_lite_preview_from_data(data, form_data).specification
+ category_field = next(
+ layer for layer in vega["layer"] if layer["mark"]["type"] == "bar"
+ )["encoding"]["tooltip"][0]["field"]
+ assert [row[category_field] for row in vega["data"]["values"]] == expected
+ assert "transform" not in vega
+ tooltip = next(layer for layer in vega["layer"] if layer["mark"]["type"] == "bar")[
+ "encoding"
+ ]["tooltip"]
+ assert tooltip[0] == {
+ "field": category_field,
+ "type": "nominal",
+ "title": "Category",
+ }
+ assert all(item.get("field") != "Category" for item in tooltip)
+ # Raw normalized dimensions stay available for inspection, while every
+ # category labels use the frontend-coerced derived value.
+ assert vega["data"]["values"][0]["Category"] == 9007199254740993
+ assert vega["data"]["values"][1]["Category"] == Decimal("1.0000000000000001")
+
+ ascii_preview = _generate_ascii_preview_from_data(data, form_data)
+ for category in ("9007199254740992", "1e+21", "1e-7", "Infinity"):
+ assert category in ascii_preview.ascii_content
+
+
+def test_bullet_vega_tooltip_uses_joined_derived_numeric_category() -> None:
+ form_data = {
+ "viz_type": "bullet",
+ "metric": "Revenue",
+ "groupby": ["Large integer", "Precise decimal"],
+ }
+ specification = _generate_vega_lite_preview_from_data(
+ [
+ {
+ "Large integer": 9007199254740993,
+ "Precise decimal": Decimal("1.0000000000000001"),
+ "Revenue": 1,
+ }
+ ],
+ form_data,
+ ).specification
+ bar = next(
+ layer for layer in specification["layer"] if layer["mark"]["type"] == "bar"
+ )
+ category_field = bar["encoding"]["tooltip"][0]["field"]
+
+ assert specification["data"]["values"][0][category_field] == ("9007199254740992, 1")
+ assert bar["encoding"]["tooltip"][0] == {
+ "field": category_field,
+ "type": "nominal",
+ "title": "Large integer, Precise decimal",
+ }
+ assert "transform" not in specification
+
+
+def test_bullet_temporal_category_rejects_hostile_timezone_without_hooks() -> None:
+ class _HostileTimezone(tzinfo):
+ calls = 0
+
+ def utcoffset(self, _value: datetime | None) -> None:
+ type(self).calls += 1
+ raise AssertionError("timezone hook invoked")
+
+ def dst(self, _value: datetime | None) -> None:
+ type(self).calls += 1
+ raise AssertionError("timezone hook invoked")
+
+ def tzname(self, _value: datetime | None) -> str | None:
+ type(self).calls += 1
+ raise AssertionError("timezone hook invoked")
+
+ with pytest.raises(BulletOutputError, match="unsupported timezone"):
+ resolve_bullet_render_model(
+ [
+ {
+ "Category": datetime(2026, 9, 2, tzinfo=_HostileTimezone()),
+ "Revenue": 1,
+ }
+ ],
+ {"metric": "Revenue", "groupby": ["Category"]},
+ )
+ assert _HostileTimezone.calls == 0
+
+
+def test_bullet_timestamp_categories_match_chart_data_wire_and_all_previews() -> None:
+ zoneinfo_tz = ZoneInfo("America/New_York")
+ pytz_tz = pytz.timezone("America/New_York")
+ values = [
+ pd.Timestamp("2024-01-02 03:04:05.123456789"),
+ pd.Timestamp("2024-01-02 03:04:05.123456789", tz="UTC"),
+ pd.Timestamp("2024-01-02 08:34:05.123456789+05:30"),
+ pd.Timestamp(datetime(2024, 11, 3, 1, 30, tzinfo=zoneinfo_tz, fold=0)),
+ pd.Timestamp(datetime(2024, 11, 3, 1, 30, tzinfo=zoneinfo_tz, fold=1)),
+ pd.Timestamp(pytz_tz.localize(datetime(2024, 11, 3, 1, 30), is_dst=True)),
+ pd.Timestamp(pytz_tz.localize(datetime(2024, 11, 3, 1, 30), is_dst=False)),
+ pd.Timestamp("1969-12-31 23:59:59.999999999"),
+ pd.Timestamp("1970-01-01 00:00:00.000000001"),
+ np.datetime64("2024-01-02", "D"),
+ np.datetime64("2024-01-02T03:04:05.123456789"),
+ date(2024, 1, 2),
+ pd.NaT,
+ np.datetime64("NaT"),
+ ]
+ expected = [
+ "1704164645123.456",
+ "1704164645123.456",
+ "1704164645123.456",
+ "1730611800000",
+ "1730615400000",
+ "1730611800000",
+ "1730615400000",
+ "-0.0010000000000287557",
+ "0",
+ "1704153600000",
+ "1704164645123.456",
+ "1704153600000",
+ "null",
+ "null",
+ ]
+ form_data = {
+ "viz_type": "bullet",
+ "metric": "Revenue",
+ "groupby": ["Category"],
+ }
+ data = [
+ {"Category": value, "Revenue": index + 1} for index, value in enumerate(values)
+ ]
+
+ # Exact pandas Timestamp values match Superset's production serializer,
+ # including its pre-epoch/sub-millisecond float behavior.
+ for value in values[:9]:
+ number, reason = _chart_data_temporal_number(value)
+ assert reason is None
+ assert number == json_int_dttm_ser(value)
+
+ vega = _generate_vega_lite_preview_from_data(data, form_data).specification
+ bar = next(layer for layer in vega["layer"] if layer["mark"]["type"] == "bar")
+ category_field = bar["encoding"]["tooltip"][0]["field"]
+ assert [row[category_field] for row in vega["data"]["values"]] == expected
+ assert bar["encoding"]["tooltip"][0]["field"] == category_field
+ assert bar["encoding"]["tooltip"][0]["title"] == "Category"
+ assert "transform" not in vega
+
+ for row, category in zip(data, expected, strict=True):
+ ascii_content = _generate_ascii_preview_from_data(
+ [row], form_data
+ ).ascii_content
+ assert category[:20] in ascii_content
+
+
+def test_bullet_dateutil_categories_preserve_chart_data_selected_instants() -> None:
+ dublin = dateutil_tz.gettz("Europe/Dublin")
+ new_york = dateutil_tz.gettz("America/New_York")
+ assert dublin is not None
+ assert new_york is not None
+ dateutil_values = [
+ datetime(2024, 10, 27, 1, 30, 0, 123456, tzinfo=dublin, fold=1),
+ datetime(2024, 3, 10, 2, 30, 0, 123456, tzinfo=new_york),
+ datetime(2040, 7, 1, 12, 0, 0, 123456, tzinfo=new_york),
+ datetime(
+ 2024,
+ 3,
+ 10,
+ 2,
+ 30,
+ 0,
+ 123456,
+ tzinfo=dateutil_tz.tzoffset("EDT", -4 * 3600),
+ ),
+ datetime(1969, 12, 31, 23, 59, 59, 999999, tzinfo=dateutil_tz.UTC),
+ ]
+ equivalent_values = [
+ datetime(2024, 3, 10, 6, 30, 0, 123456, tzinfo=timezone.utc),
+ pytz.timezone("America/New_York").localize(
+ datetime(2024, 3, 10, 1, 30, 0, 123456), is_dst=False
+ ),
+ datetime(
+ 2024,
+ 3,
+ 10,
+ 1,
+ 30,
+ 0,
+ 123456,
+ tzinfo=ZoneInfo("America/New_York"),
+ ),
+ ]
+ pandas_values = [pd.Timestamp(value) for value in dateutil_values[:3]]
+ values = [*dateutil_values, *equivalent_values, *pandas_values]
+ expected_numbers = [json_int_dttm_ser(value) for value in values]
+ expected_categories = [
+ "1729989000123.456",
+ "1710052200123.456",
+ "2224774800123.456",
+ "1710052200123.456",
+ "-0.001",
+ "1710052200123.456",
+ "1710052200123.456",
+ "1710052200123.456",
+ "1729989000123.456",
+ "1710052200123.456",
+ "2224774800123.456",
+ ]
+ form_data = {
+ "viz_type": "bullet",
+ "metric": "Revenue",
+ "groupby": ["Category"],
+ }
+ data = [
+ {"Category": value, "Revenue": index + 1} for index, value in enumerate(values)
+ ]
+
+ for value, expected_number in zip(values, expected_numbers, strict=True):
+ projected, reason = _chart_data_temporal_number(value)
+ assert reason is None
+ assert projected == expected_number
+
+ vega = _generate_vega_lite_preview_from_data(data, form_data).specification
+ bar = next(layer for layer in vega["layer"] if layer["mark"]["type"] == "bar")
+ category_field = bar["encoding"]["tooltip"][0]["field"]
+ assert [row[category_field] for row in vega["data"]["values"]] == (
+ expected_categories
+ )
+ assert bar["encoding"]["tooltip"][0]["field"] == category_field
+
+ for row, category in zip(data, expected_categories, strict=True):
+ ascii_content = _generate_ascii_preview_from_data(
+ [row], form_data
+ ).ascii_content
+ assert category[:20] in ascii_content
+
+
+def test_bullet_pandas_timestamp_rejects_hostile_timezone_without_hooks() -> None:
+ class _ArmedTimezone(tzinfo):
+ armed = False
+ calls = 0
+
+ def _offset(self) -> timedelta:
+ type(self).calls += 1
+ if self.armed:
+ raise AssertionError("timezone hook invoked")
+ return timedelta(hours=1)
+
+ def utcoffset(self, _value: datetime | None) -> timedelta:
+ return self._offset()
+
+ def dst(self, _value: datetime | None) -> timedelta:
+ return timedelta(0)
+
+ def tzname(self, _value: datetime | None) -> str:
+ return "armed"
+
+ hostile_tz = _ArmedTimezone()
+ value = pd.Timestamp(datetime(2024, 1, 2, tzinfo=hostile_tz))
+ _ArmedTimezone.calls = 0
+ _ArmedTimezone.armed = True
+
+ with pytest.raises(BulletOutputError, match="unsupported timezone"):
+ resolve_bullet_render_model(
+ [{"Category": value, "Revenue": 1}],
+ {"metric": "Revenue", "groupby": ["Category"]},
+ )
+ assert _ArmedTimezone.calls == 0
+
+
+def test_bullet_categories_match_frontend_string_coercion_and_preserve_values() -> None:
+ identifier = UUID("12345678-1234-5678-1234-567812345678")
+ values = [
+ None,
+ "",
+ True,
+ False,
+ 12.0,
+ 12.5,
+ -0.0,
+ Decimal("12.00"),
+ Decimal("12.50"),
+ date(2026, 9, 2),
+ datetime(2026, 9, 2, 3, 4, 5, tzinfo=timezone.utc),
+ datetime(
+ 2023,
+ 11,
+ 5,
+ 1,
+ 30,
+ tzinfo=ZoneInfo("America/New_York"),
+ fold=0,
+ ),
+ datetime(
+ 2023,
+ 11,
+ 5,
+ 1,
+ 30,
+ tzinfo=ZoneInfo("America/New_York"),
+ fold=1,
+ ),
+ time(3, 4, 5),
+ identifier,
+ "null",
+ "true",
+ 12,
+ ]
+ form_data = {
+ "viz_type": "bullet",
+ "metric": "Revenue",
+ "groupby": ["Category"],
+ }
+ preview = _generate_vega_lite_preview_from_data(
+ [
+ {"Category": value, "Revenue": index + 1}
+ for index, value in enumerate(values)
+ ],
+ form_data,
+ )
+ rows = preview.specification["data"]["values"]
+ categories = [row["__mcp_bullet_category"] for row in rows]
+ assert categories == [
+ "null",
+ "",
+ "true",
+ "false",
+ "12",
+ "12.5",
+ "0",
+ "12",
+ "12.5",
+ "1788307200000",
+ "1788318245000",
+ "1699162200000",
+ "1699165800000",
+ "03:04:05",
+ str(identifier),
+ "null",
+ "true",
+ "12",
+ ]
+ # Null, string, boolean, integral-float, Decimal, and integer collisions
+ # are intentional display labels, but their row identities remain distinct.
+ assert len({row["__mcp_bullet_row"] for row in rows}) == len(rows)
+ assert categories.count("null") == 2
+ assert categories.count("true") == 2
+ assert categories.count("12") == 3
+ assert rows[0]["Category"] is None
+ assert rows[2]["Category"] is True
+ assert rows[4]["Category"] == 12.0
+ assert rows[7]["Category"] == Decimal("12.00")
+ assert rows[9]["Category"] == 1788307200000.0
+ assert rows[11]["Category"] == 1699162200000.0
+ assert rows[12]["Category"] == 1699165800000.0
+
+ ascii_preview = _generate_ascii_preview_from_data(
+ [{"Category": None, "Revenue": 1}, {"Category": True, "Revenue": 2}],
+ form_data,
+ )
+ assert "null" in ascii_preview.ascii_content
+ assert "true" in ascii_preview.ascii_content
+
+
+def test_bullet_category_text_is_bounded_without_truncation_collisions() -> None:
+ with pytest.raises(BulletOutputError, match="size limit"):
+ resolve_bullet_render_model(
+ [{"Category": "x" * 2001, "Revenue": 1}],
+ {"metric": "Revenue", "groupby": ["Category"]},
+ )
+
+
+@pytest.mark.parametrize("ranges", [None, "100"])
+def test_grouped_empty_bullet_has_clear_no_data_without_fabricated_category(
+ ranges: str | None,
+) -> None:
+ form_data = {
+ "viz_type": "bullet",
+ "metric": "Revenue",
+ "groupby": ["Region"],
+ }
+ if ranges is not None:
+ form_data["ranges"] = ranges
+ model = resolve_bullet_render_model([], form_data)
+ assert model.rows == []
+ assert model.measures == []
+ assert model.dimensions == ["Region"]
+
+ vega = _generate_vega_lite_preview_from_data([], form_data)
+ assert vega.specification["data"]["values"] == []
+ assert vega.specification["description"] == (
+ "No data available for grouped Bullet chart"
+ )
+ ascii_preview = _generate_ascii_preview_from_data([], form_data)
+ assert ascii_preview.ascii_content == ("No data available for grouped Bullet chart")
+
+
+def test_bullet_preview_applies_default_band_and_every_presentation_control() -> None:
+ form_data = map_bullet_config(
+ BulletChartConfig(
+ metric={"name": "Revenue", "aggregate": "SUM", "label": "Revenue"},
+ dimensions=[{"name": "Region"}],
+ range_labels=["Baseline", "Capacity"],
+ markers=[90],
+ marker_labels=["Plan"],
+ marker_lines=[105],
+ marker_line_labels=["Forecast"],
+ y_axis_format="$,.1f",
+ show_labels=True,
+ show_legend=True,
+ )
+ )
+ preview = _generate_vega_lite_preview_from_data(
+ [
+ {"Region": "North", "Revenue": 100},
+ {"Region": "South", "Revenue": 50},
+ ],
+ form_data,
+ )
+ bullet = preview.specification["usermeta"]["bullet"]
+ assert bullet["metric"] == "Revenue"
+ assert bullet["dimensions"] == ["Region"]
+ assert bullet["ranges"] == pytest.approx([0, 110])
+ assert bullet["range_labels"] == ["Baseline", "Capacity"]
+ assert bullet["markers"] == [90.0]
+ assert bullet["marker_labels"] == ["Plan"]
+ assert bullet["marker_lines"] == [105.0]
+ assert bullet["marker_line_labels"] == ["Forecast"]
+ assert bullet["y_axis_format"] == "$,.1f"
+ assert bullet["show_labels"] is True
+ assert bullet["show_legend"] is True
+ bar = next(
+ layer
+ for layer in preview.specification["layer"]
+ if layer["mark"]["type"] == "bar"
+ )
+ range_tooltip = next(
+ item for item in bar["encoding"]["tooltip"] if item.get("title") == "Range"
+ )
+ assert preview.specification["data"]["values"][0][range_tooltip["field"]] == (
+ "Capacity"
+ )
+ assert any(
+ layer["mark"]["type"] == "text" for layer in preview.specification["layer"]
+ )
+ assert any(
+ encoding.get("color", {}).get("legend") == {"title": None}
+ for layer in preview.specification["layer"]
+ if isinstance((encoding := layer.get("encoding")), dict)
+ )
+
+
+@pytest.mark.parametrize(
+ ("measure", "ranges", "labels", "expected"),
+ [
+ (100, [300, 100, 200], ["High", "Low", "Mid"], "Low"),
+ (120.0, [300, 100, 200], ["High", "Low", "Mid"], "Mid"),
+ (Decimal("450"), [300, 100, 200], ["High", "Low", "Mid"], "> High"),
+ (-5, [10, -10, 0], ["Positive", "Negative", "Zero"], "Zero"),
+ (0, [10, 0], ["Positive", "Zero"], "Zero"),
+ (50, [100, 200], ["", "High"], None),
+ (250, [100, 200], ["Low", ""], None),
+ ],
+)
+def test_bullet_vega_measure_tooltip_matches_frontend_containing_range(
+ measure: int | float | Decimal,
+ ranges: list[int],
+ labels: list[str],
+ expected: str | None,
+) -> None:
+ form_data = {
+ "viz_type": "bullet",
+ "metric": "Revenue",
+ "ranges": ",".join(str(value) for value in ranges),
+ "range_labels": ",".join(labels),
+ }
+ specification = _generate_vega_lite_preview_from_data(
+ [{"Revenue": measure}], form_data
+ ).specification
+ bar = next(
+ layer for layer in specification["layer"] if layer["mark"]["type"] == "bar"
+ )
+ range_tooltip = [
+ item for item in bar["encoding"]["tooltip"] if item.get("title") == "Range"
+ ]
+
+ if expected is None:
+ assert range_tooltip == []
+ assert all(
+ not key.startswith("__mcp_bullet_range")
+ for key in specification["data"]["values"][0]
+ )
+ else:
+ assert len(range_tooltip) == 1
+ range_field = range_tooltip[0]["field"]
+ assert specification["data"]["values"][0][range_field] == expected
+
+
+def test_bullet_vega_range_tooltip_is_grouped_per_row_and_collision_safe() -> None:
+ form_data = {
+ "viz_type": "bullet",
+ "metric": "Revenue",
+ "groupby": ["Region"],
+ "ranges": "100,200,300",
+ "range_labels": "Low,Mid,High",
+ }
+ rows = [
+ {
+ "Region": "North",
+ "Revenue": 100,
+ "__mcp_bullet_range": "occupied",
+ "__mcp_bullet_range_1": "occupied",
+ },
+ {
+ "Region": "South",
+ "Revenue": 120,
+ "__mcp_bullet_range": "occupied",
+ "__mcp_bullet_range_1": "occupied",
+ },
+ {
+ "Region": "West",
+ "Revenue": 350,
+ "__mcp_bullet_range": "occupied",
+ "__mcp_bullet_range_1": "occupied",
+ },
+ ]
+ specification = _generate_vega_lite_preview_from_data(rows, form_data).specification
+ bar = next(
+ layer for layer in specification["layer"] if layer["mark"]["type"] == "bar"
+ )
+ range_tooltip = next(
+ item for item in bar["encoding"]["tooltip"] if item.get("title") == "Range"
+ )
+ range_field = range_tooltip["field"]
+
+ assert range_field == "__mcp_bullet_range_2"
+ assert [row[range_field] for row in specification["data"]["values"]] == [
+ "Low",
+ "Mid",
+ "> High",
+ ]
+ assert "transform" not in specification
+
+
+def test_bullet_vega_omits_range_tooltip_for_frontend_default_labels() -> None:
+ specification = _generate_vega_lite_preview_from_data(
+ [{"Revenue": 5}],
+ {"viz_type": "bullet", "metric": "Revenue", "ranges": "10,20"},
+ ).specification
+ bar = next(
+ layer for layer in specification["layer"] if layer["mark"]["type"] == "bar"
+ )
+ assert all(item.get("title") != "Range" for item in bar["encoding"]["tooltip"])
+
+
+def test_bullet_vega_empty_range_tooltip_matches_grouping_semantics() -> None:
+ presentation = {
+ "viz_type": "bullet",
+ "metric": "Revenue",
+ "ranges": "-1,1",
+ "range_labels": "Negative,Positive",
+ }
+ ungrouped = _generate_vega_lite_preview_from_data([], presentation).specification
+ grouped = _generate_vega_lite_preview_from_data(
+ [], {**presentation, "groupby": ["Region"]}
+ ).specification
+ ungrouped_bar = next(
+ layer for layer in ungrouped["layer"] if layer["mark"]["type"] == "bar"
+ )
+ grouped_bar = next(
+ layer for layer in grouped["layer"] if layer["mark"]["type"] == "bar"
+ )
+
+ range_tooltip = next(
+ item
+ for item in ungrouped_bar["encoding"]["tooltip"]
+ if item.get("title") == "Range"
+ )
+ assert ungrouped["data"]["values"][0][range_tooltip["field"]] == "Positive"
+ assert all(
+ item.get("title") != "Range" for item in grouped_bar["encoding"]["tooltip"]
+ )
+ assert grouped["data"]["values"] == []
+
+
+def test_bullet_ascii_uses_shared_roles_labels_and_format() -> None:
+ form_data = map_bullet_config(
+ BulletChartConfig(
+ metric={"name": "Revenue", "aggregate": "SUM", "label": "Revenue"},
+ dimensions=[{"name": "Region"}],
+ ranges=[100],
+ range_labels=["Healthy"],
+ markers=[80],
+ marker_labels=["Plan"],
+ marker_lines=[90],
+ marker_line_labels=["Forecast"],
+ y_axis_format="$,.0f",
+ show_legend=True,
+ )
+ )
+ preview = _generate_ascii_preview_from_data(
+ [{"Region": "North", "Revenue": "75"}], form_data
+ )
+ assert "North" in preview.ascii_content
+ assert "$75" in preview.ascii_content
+ assert "Healthy" in preview.ascii_content
+ assert "Plan" in preview.ascii_content
+ assert "Forecast" in preview.ascii_content
+
+
+@pytest.mark.parametrize("show_labels", [False, True])
+def test_bullet_marker_line_labels_ignore_show_labels_like_frontend(
+ show_labels: bool,
+) -> None:
+ config = BulletChartConfig(
+ metric={"name": "Revenue", "aggregate": "SUM", "label": "Revenue"},
+ marker_lines=[90, 110],
+ marker_line_labels=["Plan", "Stretch"],
+ show_labels=show_labels,
+ )
+ form_data = map_bullet_config(config)
+ round_trip = map_bullet_config(BulletChartConfig.model_validate(form_data))
+ assert round_trip["marker_lines"] == "90,110"
+ assert round_trip["marker_line_labels"] == "Plan,Stretch"
+
+ vega = _generate_vega_lite_preview_from_data(
+ [{"Revenue": 100}], form_data
+ ).specification
+ visible_text = [
+ layer["encoding"]["text"]["value"]
+ for layer in vega["layer"]
+ if layer["mark"]["type"] == "text"
+ and layer.get("encoding", {}).get("text", {}).get("value")
+ in {"Plan", "Stretch"}
+ ]
+ assert visible_text == ["Plan", "Stretch"]
+
+ ascii_preview = _generate_ascii_preview_from_data([{"Revenue": 100}], form_data)
+ assert ascii_preview.ascii_content.count("line Plan") == 1
+ assert ascii_preview.ascii_content.count("line Stretch") == 1
+
+
+def test_bullet_unsaved_preview_handles_empty_and_error_results() -> None:
+ dataset = SimpleNamespace(id=7)
+ factory = MagicMock()
+ factory.create.return_value = MagicMock()
+ command = MagicMock()
+ command.run.return_value = {"queries": []}
+ with (
+ patch("superset.extensions.db.session.get", return_value=dataset),
+ patch(
+ "superset.common.query_context_factory.QueryContextFactory",
+ return_value=factory,
+ ),
+ patch(
+ "superset.commands.chart.data.get_data_command.ChartDataCommand",
+ return_value=command,
+ ),
+ ):
+ result = generate_preview_from_form_data(
+ {"viz_type": "bullet", "metric": "count"}, 7, "table"
+ )
+ assert isinstance(result, ChartError)
+ assert result.error_type == "UnsupportedFormat"
+
+
+def test_bullet_unsaved_preview_rejects_embedded_query_error() -> None:
+ dataset = SimpleNamespace(id=7)
+ factory = MagicMock()
+ factory.create.return_value = MagicMock()
+ command = MagicMock()
+ command.run.return_value = {
+ "queries": [{"status": "error", "error": "metric failed"}]
+ }
+ with (
+ patch("superset.extensions.db.session.get", return_value=dataset),
+ patch(
+ "superset.common.query_context_factory.QueryContextFactory",
+ return_value=factory,
+ ),
+ patch(
+ "superset.commands.chart.data.get_data_command.ChartDataCommand",
+ return_value=command,
+ ),
+ ):
+ result = generate_preview_from_form_data(
+ {"viz_type": "bullet", "metric": "count"}, 7, "vega_lite"
+ )
+ assert isinstance(result, ChartError)
+ assert result.error_type == "QueryError"
+ assert "metric failed" in result.error
+
+
+def _unsaved_bullet_result_with_result(
+ result: object, form_data: dict[str, Any] | None = None, format_: str = "vega_lite"
+) -> ChartError | ASCIIPreview | VegaLitePreview:
+ dataset = SimpleNamespace(id=7)
+ factory = MagicMock()
+ factory.create.return_value = MagicMock()
+ command = MagicMock()
+ command.run.return_value = result
+ with (
+ patch("superset.extensions.db.session.get", return_value=dataset),
+ patch(
+ "superset.common.query_context_factory.QueryContextFactory",
+ return_value=factory,
+ ),
+ patch(
+ "superset.commands.chart.data.get_data_command.ChartDataCommand",
+ return_value=command,
+ ),
+ ):
+ preview = generate_preview_from_form_data(
+ form_data or {"viz_type": "bullet", "metric": "Revenue"}, 7, format_
+ )
+ assert isinstance(preview, (ChartError, ASCIIPreview, VegaLitePreview))
+ return preview
+
+
+def _unsaved_bullet_preview_with_result(result: object) -> ChartError:
+ preview = _unsaved_bullet_result_with_result(result)
+ assert isinstance(preview, ChartError)
+ return preview
+
+
+@pytest.mark.parametrize("envelope", _MALFORMED_QUERY_ENVELOPES)
+def test_bullet_unsaved_preview_structures_malformed_envelopes(
+ envelope: object,
+) -> None:
+ preview = _unsaved_bullet_preview_with_result(envelope)
+ assert preview.error_type == "InvalidQueryResult"
+
+
+def test_bullet_unsaved_preview_structures_oversized_numeric_output() -> None:
+ preview = _unsaved_bullet_preview_with_result(
+ {"queries": [{"data": [{"Revenue": 10**10000}]}]}
+ )
+ assert preview.error_type == "InvalidQueryResult"
+
+
+def test_bullet_empty_saved_and_unsaved_vega_use_same_no_data_contract() -> None:
+ envelope: dict[str, Any] = {"queries": [{"data": []}]}
+ unsaved = _unsaved_bullet_result_with_result(envelope)
+ saved = _saved_bullet_result_with_result(envelope, "vega_lite")
+ assert isinstance(unsaved, VegaLitePreview)
+ assert isinstance(saved, VegaLitePreview)
+ for preview in (unsaved, saved):
+ assert preview.specification["data"]["values"] == [
+ {"Revenue": 0.0, "__mcp_bullet_category": "", "__mcp_bullet_row": 0}
+ ]
+ assert preview.specification["usermeta"]["bullet"]["ranges"] == [0.0, 0.0]
+
+ chart = SimpleNamespace(
+ id=9,
+ params=__import__("json").dumps(
+ {"viz_type": "bullet", "metric": "SavedRevenue"}
+ ),
+ viz_type="bullet",
+ slice_name="Saved Bullet",
+ )
+ strategy = VegaLitePreviewStrategy(
+ chart, GetChartPreviewRequest(identifier=9, format="vega_lite")
+ )
+ specification = strategy._create_vega_lite_spec([])
+ assert specification["data"]["values"] == [
+ {"SavedRevenue": 0.0, "__mcp_bullet_category": "", "__mcp_bullet_row": 0}
+ ]
+
+
+def _saved_bullet_result_with_result(
+ result: object,
+ format_: str,
+ form_data: dict[str, Any] | None = None,
+) -> ChartError | ASCIIPreview | VegaLitePreview:
+ form_data = form_data or {"viz_type": "bullet", "metric": "Revenue"}
+ chart = SimpleNamespace(
+ id=9,
+ params=__import__("json").dumps(form_data),
+ viz_type="bullet",
+ slice_name="Saved Bullet",
+ datasource_id=7,
+ datasource_type="table",
+ )
+ request = GetChartPreviewRequest(identifier=9, format=format_)
+ strategy = (
+ ASCIIPreviewStrategy(chart, request)
+ if format_ == "ascii"
+ else VegaLitePreviewStrategy(chart, request)
+ )
+ command = MagicMock()
+ command.run.return_value = result
+ with (
+ patch(
+ "superset.mcp_service.chart.tool.get_chart_preview."
+ "build_query_context_from_form_data",
+ return_value=MagicMock(),
+ ),
+ patch.object(strategy, "_authorize_guest_query"),
+ patch(
+ "superset.commands.chart.data.get_data_command.ChartDataCommand",
+ return_value=command,
+ ),
+ ):
+ preview = strategy.generate()
+ assert isinstance(preview, (ChartError, ASCIIPreview, VegaLitePreview))
+ return preview
+
+
+def _saved_bullet_preview_with_result(result: object, format_: str) -> ChartError:
+ preview = _saved_bullet_result_with_result(result, format_)
+ assert isinstance(preview, ChartError)
+ return preview
+
+
+@pytest.mark.parametrize("message", ["short\ud800error", "é\ud800中" * 2000])
+def test_bullet_query_paths_replacement_sanitize_surrogate_errors(
+ message: str,
+) -> None:
+ envelope = {"error": message}
+ compiled = _compile_bullet_with_result(envelope)
+ unsaved = _unsaved_bullet_preview_with_result(envelope)
+ saved = _saved_bullet_preview_with_result(envelope, "ascii")
+
+ assert "\ud800" not in (compiled.error or "")
+ assert "\ud800" not in unsaved.error
+ assert "\ud800" not in saved.error
+ assert len((compiled.error or "").encode("utf-8")) <= 2100
+ assert len(unsaved.error.encode("utf-8")) <= 2100
+ assert len(saved.error.encode("utf-8")) <= 2100
+
+
+@pytest.mark.parametrize("format_", ["ascii", "vega_lite"])
+@pytest.mark.parametrize("envelope", _MALFORMED_QUERY_ENVELOPES)
+def test_bullet_saved_preview_structures_malformed_envelopes(
+ envelope: object, format_: str
+) -> None:
+ preview = _saved_bullet_preview_with_result(envelope, format_)
+ assert preview.error_type == "InvalidQueryResult"
+
+
+@pytest.mark.parametrize("format_", ["ascii", "vega_lite"])
+def test_bullet_saved_preview_structures_oversized_numeric_output(format_: str) -> None:
+ preview = _saved_bullet_preview_with_result(
+ {"queries": [{"data": [{"Revenue": 10**10000}]}]}, format_
+ )
+ assert preview.error_type == "InvalidQueryResult"
+
+
+@pytest.mark.parametrize(
+ "value",
+ [
+ _OutputHostileInt(12),
+ _OutputHostileFloat(12.5),
+ _OutputHostileStr("12.5"),
+ _OutputHostileDecimal("12.5"),
+ ],
+ ids=["int-subclass", "float-subclass", "str-subclass", "decimal-subclass"],
+)
+def test_bullet_output_rejects_scalar_subclasses_on_every_query_path(
+ value: object,
+) -> None:
+ envelope = {"queries": [{"data": [{"Revenue": value}]}]}
+ compiled = _compile_bullet_with_result(envelope)
+ unsaved = _unsaved_bullet_preview_with_result(envelope)
+ saved = _saved_bullet_preview_with_result(envelope, "vega_lite")
+
+ assert compiled.success is False
+ assert compiled.error_code == "CHART_COMPILE_FAILED"
+ assert unsaved.error_type == saved.error_type == "InvalidQueryResult"
+ assert len((compiled.error or "").encode("utf-8")) <= 2000
+ assert len(unsaved.error.encode("utf-8")) <= 2000
+ assert len(saved.error.encode("utf-8")) <= 2000
+
+
+@pytest.mark.parametrize(
+ "value",
+ [_OutputSafeIntEnum.VALUE, _OutputSafeStrEnum.VALUE],
+ ids=["int-enum", "str-enum"],
+)
+def test_bullet_output_uses_enum_backing_without_conversion(value: object) -> None:
+ model = resolve_bullet_render_model([{"Revenue": value}], {"metric": "Revenue"})
+ assert model.measures == [12.0 if isinstance(value, IntEnum) else 12.5]
+
+
+def test_bullet_dimension_rejects_custom_values_without_string_conversion() -> None:
+ class HostileDimension:
+ __repr__ = _reject_scalar_conversion
+ __str__ = _reject_scalar_conversion
+
+ with pytest.raises(BulletOutputError, match="unsupported value type"):
+ resolve_bullet_render_model(
+ [{"Region": HostileDimension(), "Revenue": 12}],
+ {"metric": "Revenue", "groupby": ["Region"]},
+ )
+
+
+def test_bullet_rejects_dict_subclass_rows_without_invoking_overrides() -> None:
+ class HostileRow(dict[str, object]):
+ __contains__ = _reject_scalar_conversion
+ __getitem__ = _reject_scalar_conversion
+ __iter__ = _reject_scalar_conversion
+ __len__ = _reject_scalar_conversion
+
+ envelope = {"queries": [{"data": [HostileRow(Revenue=12)]}]}
+ assert _compile_bullet_with_result(envelope).error_code == "CHART_COMPILE_FAILED"
+ assert _unsaved_bullet_preview_with_result(envelope).error_type == (
+ "InvalidQueryResult"
+ )
+
+
+def test_bullet_saved_table_preview_is_explicitly_unsupported() -> None:
+ chart = SimpleNamespace(
+ id=9,
+ params=__import__("json").dumps(
+ {"viz_type": "bullet", "metric": "SavedRevenue"}
+ ),
+ viz_type="bullet",
+ slice_name="Saved Bullet",
+ )
+ result = TablePreviewStrategy(
+ chart, GetChartPreviewRequest(identifier=9, format="table")
+ ).generate()
+ assert isinstance(result, ChartError)
+ assert result.error_type == "UnsupportedFormat"
+
+
+def test_bullet_capabilities_match_implemented_preview_and_data_roles() -> None:
+ ungrouped = BulletChartConfig(metric=_simple_metric())
+ grouped = BulletChartConfig(
+ metric=_simple_metric(), dimensions=[{"name": "Region"}]
+ )
+ assert analyze_chart_capabilities("bullet", ungrouped).model_dump() == {
+ "supports_interaction": True,
+ "supports_real_time": False,
+ "supports_drill_down": False,
+ "supports_export": True,
+ "optimal_formats": ["url", "vega_lite", "ascii"],
+ "data_types": ["metric"],
+ }
+ assert analyze_chart_capabilities("bullet", grouped).data_types == [
+ "metric",
+ "categorical",
+ ]
+
+
+def test_bullet_saved_preview_uses_native_roles_aliases_and_overlays() -> None:
+ form_data = {
+ "viz_type": "bullet",
+ "metric": {
+ "aggregate": "SUM",
+ "column": {"column_name": "revenue_amount"},
+ "expressionType": "SIMPLE",
+ "label": "Total Revenue",
+ },
+ "groupby": ["Region", "Team"],
+ "ranges": "100,250",
+ "markers": "200",
+ "marker_lines": "225",
+ }
+ chart = SimpleNamespace(
+ id=9,
+ params=__import__("json").dumps(form_data),
+ viz_type="bullet",
+ slice_name="Saved Bullet",
+ )
+ strategy = VegaLitePreviewStrategy(
+ chart,
+ GetChartPreviewRequest(identifier=9, format="vega_lite"),
+ )
+ specification = strategy._create_vega_lite_spec(
+ [{"Region": "North", "Team": "Blue", "Total Revenue": 123}]
+ )
+
+ assert specification["data"]["values"][0]["__mcp_bullet_category"] == (
+ "North, Blue"
+ )
+ assert "transform" not in specification
+ bar_layer = next(
+ layer for layer in specification["layer"] if layer["mark"]["type"] == "bar"
+ )
+ assert bar_layer["encoding"]["x"]["field"] == "Total Revenue"
+ assert bar_layer["encoding"]["y"]["field"] == "__mcp_bullet_row"
+ range_layers = [
+ layer for layer in specification["layer"] if layer["mark"]["type"] == "rect"
+ ]
+ assert range_layers
+ assert all(
+ layer["encoding"]["y"] == bar_layer["encoding"]["y"] for layer in range_layers
+ )
+ assert {layer["mark"]["type"] for layer in specification["layer"]} == {
+ "rect",
+ "bar",
+ "point",
+ "rule",
+ "text",
+ }
+
+
+def test_bullet_saved_sql_metric_without_label_uses_expression_result_field() -> None:
+ expression = "SUM(revenue) / COUNT(*)"
+ preview = _generate_vega_lite_preview_from_data(
+ [{"Region": "North", expression: 12.5}],
+ {
+ "viz_type": "bullet",
+ "metric": {
+ "expressionType": "SQL",
+ "sqlExpression": expression,
+ },
+ "groupby": ["Region"],
+ "ranges": "10,20",
+ },
+ )
+
+ bar = next(
+ layer
+ for layer in preview.specification["layer"]
+ if layer["mark"]["type"] == "bar"
+ )
+ assert bar["encoding"]["x"]["field"] == expression
+
+
+def test_bullet_update_paths_preserve_omitted_native_state() -> None:
+ existing = {
+ "viz_type": "bullet",
+ "metric": "old_metric",
+ "groupby": ["Region"],
+ "ranges": "10,20",
+ "markers": "15",
+ "show_labels": True,
+ "show_legend": True,
+ "adhoc_filters": [
+ {
+ "clause": "WHERE",
+ "expressionType": "SIMPLE",
+ "subject": "Status",
+ "operator": "==",
+ "comparator": "Active",
+ }
+ ],
+ }
+ chart = SimpleNamespace(
+ id=9,
+ datasource_id=7,
+ slice_name="Saved Bullet",
+ params=__import__("json").dumps(existing),
+ )
+ request = UpdateChartRequest(
+ identifier=9,
+ config=BulletChartConfig(metric=_simple_metric("NewRevenue")),
+ generate_preview=False,
+ )
+ payload = _build_update_payload(request, chart, request.config)
+ assert isinstance(payload, dict)
+ persisted = __import__("json").loads(payload["params"])
+ assert persisted["metric"]["column"]["column_name"] == "NewRevenue"
+ assert persisted["groupby"] == ["Region"]
+ assert persisted["ranges"] == "10,20"
+ assert persisted["markers"] == "15"
+ assert persisted["show_labels"] is True
+ assert persisted["adhoc_filters"] == existing["adhoc_filters"]
+
+ preview = _build_preview_form_data(request, chart, request.config)
+ assert isinstance(preview, dict)
+ assert preview["ranges"] == "10,20"
+ assert preview["show_legend"] is True
+
+
+def test_bullet_update_paths_honor_explicit_clear_and_false() -> None:
+ existing = {
+ "viz_type": "bullet",
+ "metric": "old_metric",
+ "groupby": ["Region"],
+ "ranges": "10,20",
+ "show_labels": True,
+ "adhoc_filters": [{"subject": "Status"}],
+ }
+ chart = SimpleNamespace(
+ id=9,
+ datasource_id=7,
+ slice_name="Saved Bullet",
+ params=__import__("json").dumps(existing),
+ )
+ config = BulletChartConfig(
+ metric=_simple_metric(),
+ dimensions=[],
+ filters=[],
+ ranges=[],
+ show_labels=False,
+ )
+ request = UpdateChartRequest(identifier=9, config=config, generate_preview=False)
+ payload = _build_update_payload(request, chart, config)
+ assert isinstance(payload, dict)
+ persisted = __import__("json").loads(payload["params"])
+ assert persisted["groupby"] == []
+ assert persisted["ranges"] == ""
+ assert persisted["show_labels"] is False
+ assert persisted["adhoc_filters"] == []
+
+
+def _saved_bullet_with_filters() -> dict[str, object]:
+ return {
+ "viz_type": "bullet",
+ "metric": "SavedRevenue",
+ "groupby": ["Region"],
+ "time_range": "Last year",
+ MCP_DASHBOARD_TIME_FILTER_SUBJECT: "OrderDate",
+ "adhoc_filters": [
+ {
+ "clause": "WHERE",
+ "expressionType": "SIMPLE",
+ "subject": "Status",
+ "operator": "==",
+ "comparator": "Active",
+ },
+ {
+ "clause": "WHERE",
+ "expressionType": "SIMPLE",
+ "subject": "OrderDate",
+ "operator": "TEMPORAL_RANGE",
+ "comparator": "Last year",
+ },
+ ],
+ }
+
+
+def _saved_bullet_with_opaque_filters() -> dict[str, object]:
+ return {
+ "viz_type": "bullet",
+ "metric": "SavedRevenue",
+ "groupby": ["Region"],
+ MCP_DASHBOARD_TIME_FILTER_SUBJECT: "OrderDate",
+ "adhoc_filters": [
+ {
+ "clause": "WHERE",
+ "expressionType": "SQL",
+ "sqlExpression": "Status = 'Active' ",
+ },
+ {
+ "clause": "HAVING",
+ "expressionType": "SIMPLE",
+ "subject": "SavedRevenue",
+ "operator": ">",
+ "comparator": 100,
+ },
+ {
+ "clause": "WHERE",
+ "expressionType": "SIMPLE",
+ "subject": "OrderDate",
+ "operator": "TEMPORAL_RANGE",
+ "comparator": "No filter",
+ },
+ ],
+ }
+
+
+@pytest.mark.parametrize("binding_index", [0, 1, 2, None])
+@pytest.mark.parametrize("preview_first", [False, True])
+def test_bullet_omitted_filters_preserve_exact_native_sequence_at_every_position(
+ binding_index: int | None, preview_first: bool
+) -> None:
+ opaque = _saved_bullet_with_opaque_filters()
+ raw_filters = opaque["adhoc_filters"]
+ assert isinstance(raw_filters, list)
+ filters = list(raw_filters)
+ binding = filters.pop()
+ if binding_index is not None:
+ filters.insert(binding_index, binding)
+ existing = {**opaque, "adhoc_filters": filters}
+ if binding_index is None:
+ existing.pop(MCP_DASHBOARD_TIME_FILTER_SUBJECT)
+ chart = SimpleNamespace(
+ id=9,
+ datasource_id=7,
+ slice_name="Saved Bullet",
+ params=__import__("json").dumps(existing),
+ )
+ config = BulletChartConfig(metric=_simple_metric("Revenue"))
+ request = UpdateChartRequest(identifier=9, config=config)
+ with patch(
+ "superset.mcp_service.chart.chart_utils._find_dataset_by_id_or_uuid",
+ return_value=_orm_dataset(),
+ ):
+ if preview_first:
+ merged = _build_preview_form_data(request, chart, config)
+ assert isinstance(merged, dict)
+ else:
+ payload = _build_update_payload(request, chart, config)
+ assert isinstance(payload, dict)
+ merged = __import__("json").loads(payload["params"])
+
+ assert merged["adhoc_filters"] == filters
+
+ cached = map_config_to_form_data(config, dataset_id=7)
+ merge_update_form_data(existing, cached, config)
+ assert cached["adhoc_filters"] == filters
+
+
+def test_bullet_temporal_binding_override_replaces_in_place() -> None:
+ existing = _saved_bullet_with_opaque_filters()
+ raw_filters = existing["adhoc_filters"]
+ assert isinstance(raw_filters, list)
+ filters = list(raw_filters)
+ binding = filters.pop()
+ filters.insert(1, binding)
+ existing["adhoc_filters"] = filters
+ config = BulletChartConfig(
+ metric=_simple_metric(),
+ temporal_column="EventDate",
+ time_range="Last 30 days",
+ )
+ with patch(
+ "superset.mcp_service.chart.chart_utils._is_temporal_for_dashboard_binding",
+ return_value=True,
+ ):
+ mapped = map_config_to_form_data(config, dataset_id=7)
+ merge_update_form_data(existing, mapped, config)
+
+ assert mapped["adhoc_filters"][0] == filters[0]
+ assert mapped["adhoc_filters"][2] == filters[2]
+ assert mapped["adhoc_filters"][1]["subject"] == "EventDate"
+ assert mapped["adhoc_filters"][1]["comparator"] == "Last 30 days"
+
+
+@pytest.mark.parametrize(
+ ("time_range", "expected_comparator"),
+ [("Last 30 days", "Last 30 days"), (None, "No filter")],
+)
+@pytest.mark.parametrize("path", ["immediate", "preview_first", "cached"])
+def test_bullet_time_range_only_updates_saved_subject_on_every_update_path(
+ time_range: str | None, expected_comparator: str, path: str
+) -> None:
+ existing = _saved_bullet_with_filters()
+ existing[MCP_DASHBOARD_TIME_FILTER_SUBJECT] = "EventDate"
+ existing_filters = existing["adhoc_filters"]
+ assert isinstance(existing_filters, list)
+ existing_filters[1] = {**existing_filters[1], "subject": "EventDate"}
+ config = BulletChartConfig(metric=_simple_metric(), time_range=time_range)
+ chart = SimpleNamespace(
+ id=9,
+ datasource_id=7,
+ slice_name="Saved Bullet",
+ params=__import__("json").dumps(existing),
+ )
+ request = UpdateChartRequest(identifier=9, config=config)
+
+ with (
+ patch(
+ "superset.mcp_service.chart.chart_utils._find_dataset_by_id_or_uuid",
+ return_value=_orm_dataset(),
+ ),
+ patch(
+ "superset.mcp_service.chart.chart_utils._is_temporal_for_dashboard_binding",
+ return_value=True,
+ ),
+ ):
+ if path == "immediate":
+ payload = _build_update_payload(request, chart, config)
+ assert isinstance(payload, dict)
+ merged = __import__("json").loads(payload["params"])
+ elif path == "preview_first":
+ result = _build_preview_form_data(request, chart, config)
+ assert isinstance(result, dict)
+ merged = result
+ else:
+ merged = map_config_to_form_data(config, dataset_id=7)
+ merge_update_form_data(existing, merged, config)
+
+ assert merged[MCP_DASHBOARD_TIME_FILTER_SUBJECT] == "EventDate"
+ assert [item["subject"] for item in merged["adhoc_filters"]] == [
+ "Status",
+ "EventDate",
+ ]
+ assert merged["adhoc_filters"][1]["comparator"] == expected_comparator
+
+
+@pytest.mark.parametrize(
+ ("time_range", "expected_comparator"),
+ [("Last month", "Last month"), (None, "No filter")],
+)
+@pytest.mark.parametrize("path", ["immediate", "preview_first", "cached"])
+def test_bullet_range_only_update_adopts_single_unmarked_native_binding(
+ time_range: str | None, expected_comparator: str, path: str
+) -> None:
+ # Explore saved the time filter on a non-main column with no MCP marker.
+ existing = _saved_bullet_with_filters()
+ existing.pop(MCP_DASHBOARD_TIME_FILTER_SUBJECT)
+ existing_filters = existing["adhoc_filters"]
+ assert isinstance(existing_filters, list)
+ existing_filters[1] = {**existing_filters[1], "subject": "EventDate"}
+ config = BulletChartConfig(metric=_simple_metric(), time_range=time_range)
+ chart = SimpleNamespace(
+ id=9,
+ datasource_id=7,
+ slice_name="Saved Bullet",
+ params=__import__("json").dumps(existing),
+ )
+ request = UpdateChartRequest(identifier=9, config=config)
+
+ with (
+ patch(
+ "superset.mcp_service.chart.chart_utils._find_dataset_by_id_or_uuid",
+ return_value=_orm_dataset(),
+ ),
+ patch(
+ "superset.mcp_service.chart.chart_utils._is_temporal_for_dashboard_binding",
+ return_value=True,
+ ),
+ ):
+ if path == "immediate":
+ payload = _build_update_payload(request, chart, config)
+ assert isinstance(payload, dict)
+ merged = __import__("json").loads(payload["params"])
+ elif path == "preview_first":
+ result = _build_preview_form_data(request, chart, config)
+ assert isinstance(result, dict)
+ merged = result
+ else:
+ merged = map_config_to_form_data(config, dataset_id=7)
+ merge_update_form_data(existing, merged, config)
+
+ assert merged[MCP_DASHBOARD_TIME_FILTER_SUBJECT] == "EventDate"
+ assert [item["subject"] for item in merged["adhoc_filters"]] == [
+ "Status",
+ "EventDate",
+ ]
+ assert merged["adhoc_filters"][1]["comparator"] == expected_comparator
+
+
+def test_bullet_range_only_update_keeps_several_unmarked_native_bindings() -> None:
+ # Several unmarked native ranges are ambiguous, so none is adopted: the
+ # saved predicates stay untouched and the dataset-default binding is added.
+ existing = _saved_bullet_with_filters()
+ existing.pop(MCP_DASHBOARD_TIME_FILTER_SUBJECT)
+ filters = existing["adhoc_filters"]
+ assert isinstance(filters, list)
+ filters[1] = {**filters[1], "subject": "EventDate"}
+ filters.append({**filters[1], "comparator": "Last week"})
+ saved = [dict(filter_) for filter_ in filters]
+ config = BulletChartConfig(metric=_simple_metric(), time_range="Last month")
+ with (
+ patch(
+ "superset.mcp_service.chart.chart_utils._find_dataset_by_id_or_uuid",
+ return_value=_orm_dataset(),
+ ),
+ patch(
+ "superset.mcp_service.chart.chart_utils._is_temporal_for_dashboard_binding",
+ return_value=True,
+ ),
+ ):
+ mapped = map_config_to_form_data(config, dataset_id=7)
+ merge_update_form_data(existing, mapped, config)
+
+ assert mapped["adhoc_filters"][:3] == saved
+ assert mapped["adhoc_filters"][3]["subject"] == "OrderDate"
+ assert mapped["adhoc_filters"][3]["comparator"] == "Last month"
+ assert mapped[MCP_DASHBOARD_TIME_FILTER_SUBJECT] == "OrderDate"
+
+
+def test_bullet_subject_only_update_preserves_saved_active_range() -> None:
+ existing = _saved_bullet_with_filters()
+ config = BulletChartConfig(metric=_simple_metric(), temporal_column="EventDate")
+ with patch(
+ "superset.mcp_service.chart.chart_utils._is_temporal_for_dashboard_binding",
+ return_value=True,
+ ):
+ mapped = map_config_to_form_data(config, dataset_id=7)
+ merge_update_form_data(existing, mapped, config)
+ assert mapped[MCP_DASHBOARD_TIME_FILTER_SUBJECT] == "EventDate"
+ assert mapped["adhoc_filters"][1]["subject"] == "EventDate"
+ assert mapped["adhoc_filters"][1]["comparator"] == "Last year"
+
+
+def test_bullet_rejects_ambiguous_provenance_subject_operator_matches() -> None:
+ existing = _saved_bullet_with_filters()
+ filters = existing["adhoc_filters"]
+ assert isinstance(filters, list)
+ filters.append({**filters[1], "comparator": "Last month"})
+ config = BulletChartConfig(metric=_simple_metric())
+ with pytest.raises(ValueError, match="must match exactly one"):
+ merge_update_form_data(existing, map_bullet_config(config), config)
+
+ with pytest.raises(ValidationError, match="must match exactly one"):
+ BulletChartConfig.model_validate(existing)
+
+
+def test_bullet_omitted_filters_preserve_missing_native_filter_key() -> None:
+ existing = {"viz_type": "bullet", "metric": "SavedRevenue"}
+ config = BulletChartConfig(metric=_simple_metric())
+ mapped = {
+ "viz_type": "bullet",
+ "metric": "new",
+ MCP_DASHBOARD_TIME_FILTER_SUBJECT: "OrderDate",
+ "adhoc_filters": [
+ {
+ "clause": "WHERE",
+ "expressionType": "SIMPLE",
+ "subject": "OrderDate",
+ "operator": "TEMPORAL_RANGE",
+ "comparator": "No filter",
+ }
+ ],
+ }
+
+ merge_update_form_data(existing, mapped, config)
+
+ assert "adhoc_filters" not in mapped
+ assert MCP_DASHBOARD_TIME_FILTER_SUBJECT not in mapped
+
+
+@pytest.mark.parametrize("preview_first", [False, True])
+def test_bullet_update_paths_preserve_opaque_filters_byte_for_byte(
+ preview_first: bool,
+) -> None:
+ existing = _saved_bullet_with_opaque_filters()
+ chart = SimpleNamespace(
+ id=9,
+ datasource_id=7,
+ slice_name="Saved Bullet",
+ params=__import__("json").dumps(existing),
+ )
+ config = BulletChartConfig(metric=_simple_metric("Revenue"))
+ request = UpdateChartRequest(identifier=9, config=config)
+ with (
+ patch(
+ "superset.mcp_service.chart.chart_utils._find_dataset_by_id_or_uuid",
+ return_value=_orm_dataset(),
+ ),
+ patch(
+ "superset.mcp_service.chart.chart_utils._is_temporal_for_dashboard_binding",
+ return_value=True,
+ ),
+ ):
+ if preview_first:
+ merged = _build_preview_form_data(request, chart, config)
+ assert isinstance(merged, dict)
+ else:
+ payload = _build_update_payload(request, chart, config)
+ assert isinstance(payload, dict)
+ merged = __import__("json").loads(payload["params"])
+
+ assert merged["adhoc_filters"] == existing["adhoc_filters"]
+ assert validate_merged_bullet_form_data(merged, config) is not None
+
+
+@pytest.mark.parametrize("preview_first", [False, True])
+def test_bullet_update_from_other_viz_ignores_preserved_unmodeled_keys(
+ preview_first: bool,
+) -> None:
+ """Registry-preserved presentation/time keys are not typed Bullet input."""
+ existing = {
+ "viz_type": "pie",
+ "datasource": "7__table",
+ "metric": "count",
+ "groupby": ["region"],
+ "color_scheme": "bnbColors",
+ "number_format": ",d",
+ "legendOrientation": "top",
+ "date_format": "smart_date",
+ "currency_format": {"symbol": "USD", "symbolPosition": "prefix"},
+ "linear_color_scheme": "blue_white_yellow",
+ "time_grain_sqla": "P1D",
+ "url_params": {},
+ "slice_id": 9,
+ }
+ chart = SimpleNamespace(
+ id=9,
+ datasource_id=7,
+ slice_name="Saved Pie",
+ params=__import__("json").dumps(existing),
+ )
+ config = BulletChartConfig(metric=_simple_metric("Revenue"))
+ request = UpdateChartRequest(identifier=9, config=config)
+ with patch(
+ "superset.mcp_service.chart.chart_utils._find_dataset_by_id_or_uuid",
+ return_value=_orm_dataset(),
+ ):
+ if preview_first:
+ merged = _build_preview_form_data(request, chart, config)
+ assert isinstance(merged, dict)
+ else:
+ payload = _build_update_payload(request, chart, config)
+ assert isinstance(payload, dict)
+ merged = __import__("json").loads(payload["params"])
+
+ assert merged["color_scheme"] == "bnbColors"
+ assert merged["legendOrientation"] == "top"
+ validated = validate_merged_bullet_form_data(merged, config)
+ assert validated is not None
+ assert validated.metric.name == "Revenue"
+ # Unrelated typos remain rejected rather than silently dropped.
+ with pytest.raises(ValidationError, match="Unknown field 'colour_scheme'"):
+ validate_merged_bullet_form_data({**merged, "colour_scheme": "x"}, config)
+
+
+def test_bullet_filter_provenance_keeps_strict_replacement_validation() -> None:
+ existing = _saved_bullet_with_opaque_filters()
+ config = BulletChartConfig(metric=_simple_metric(), filters=[])
+ mapped = map_bullet_config(config)
+ merge_update_form_data(existing, mapped, config)
+ assert mapped["adhoc_filters"] == []
+
+ # A native SQL filter cannot masquerade as an explicitly supplied typed
+ # replacement: only omitted, provenance-preserved filters get that path.
+ existing_filters = existing["adhoc_filters"]
+ assert isinstance(existing_filters, list)
+ mapped["adhoc_filters"] = [existing_filters[0]]
+ with pytest.raises(ValidationError, match="expressionType='SIMPLE'"):
+ validate_merged_bullet_form_data(mapped, config)
+
+
+@pytest.mark.parametrize("preview_first", [False, True])
+def test_bullet_saved_update_paths_preserve_omitted_user_filters_with_binding(
+ preview_first: bool,
+) -> None:
+ existing = _saved_bullet_with_filters()
+ chart = SimpleNamespace(
+ id=9,
+ datasource_id=7,
+ slice_name="Saved Bullet",
+ params=__import__("json").dumps(existing),
+ )
+ config = BulletChartConfig(metric=_simple_metric("Revenue"))
+ request = UpdateChartRequest(identifier=9, config=config)
+ with (
+ patch(
+ "superset.mcp_service.chart.chart_utils._find_dataset_by_id_or_uuid",
+ return_value=_orm_dataset(),
+ ),
+ patch(
+ "superset.mcp_service.chart.chart_utils._is_temporal_for_dashboard_binding",
+ return_value=True,
+ ),
+ ):
+ if preview_first:
+ merged = _build_preview_form_data(request, chart, config)
+ assert isinstance(merged, dict)
+ else:
+ payload = _build_update_payload(request, chart, config)
+ assert isinstance(payload, dict)
+ merged = __import__("json").loads(payload["params"])
+
+ assert merged["adhoc_filters"] == existing["adhoc_filters"]
+ assert merged[MCP_DASHBOARD_TIME_FILTER_SUBJECT] == "OrderDate"
+
+
+@pytest.mark.parametrize(
+ ("filters", "expected_subjects"),
+ [
+ ([], []),
+ (
+ [{"column": "Status", "op": "=", "value": "Inactive"}],
+ ["Status", "OrderDate"],
+ ),
+ ],
+)
+def test_bullet_filter_clear_and_replacement_are_authoritative(
+ filters: list[dict[str, object]], expected_subjects: list[str]
+) -> None:
+ existing = _saved_bullet_with_filters()
+ config = BulletChartConfig(metric=_simple_metric("Revenue"), filters=filters)
+ mapped: dict[str, Any] = {"viz_type": "bullet", "metric": "new"}
+ if filters:
+ mapped["adhoc_filters"] = [
+ {
+ "clause": "WHERE",
+ "expressionType": "SIMPLE",
+ "subject": "Status",
+ "operator": "==",
+ "comparator": "Inactive",
+ }
+ ]
+ merge_update_form_data(existing, mapped, config)
+ assert [item["subject"] for item in mapped["adhoc_filters"]] == expected_subjects
+
+
+def test_bullet_temporal_override_replaces_only_provenanced_binding() -> None:
+ existing = _saved_bullet_with_filters()
+ config = BulletChartConfig(
+ metric=_simple_metric("Revenue"),
+ temporal_column="EventDate",
+ time_range="Last 30 days",
+ )
+ with (
+ patch(
+ "superset.mcp_service.chart.chart_utils._find_dataset_by_id_or_uuid",
+ return_value=_orm_dataset(),
+ ),
+ patch(
+ "superset.mcp_service.chart.chart_utils._is_temporal_for_dashboard_binding",
+ return_value=True,
+ ),
+ ):
+ mapped = map_config_to_form_data(config, dataset_id=7)
+ merge_update_form_data(existing, mapped, config)
+ assert [item["subject"] for item in mapped["adhoc_filters"]] == [
+ "Status",
+ "EventDate",
+ ]
+ assert mapped["adhoc_filters"][1]["comparator"] == "Last 30 days"
+ assert mapped[MCP_DASHBOARD_TIME_FILTER_SUBJECT] == "EventDate"
+
+
+@pytest.mark.parametrize("path", ["immediate", "preview_first", "cached"])
+def test_bullet_filter_only_update_ignores_mapper_temporal_fallback(path: str) -> None:
+ existing = _saved_bullet_with_filters()
+ config = BulletChartConfig(
+ metric=_simple_metric(),
+ filters=[{"column": "Status", "op": "=", "value": "Inactive"}],
+ )
+ chart = SimpleNamespace(
+ id=9,
+ datasource_id=7,
+ slice_name="Saved Bullet",
+ params=__import__("json").dumps(existing),
+ )
+ request = UpdateChartRequest(identifier=9, config=config)
+ dataset = _orm_dataset()
+ dataset.main_dttm_col = "EventDate"
+ with (
+ patch(
+ "superset.mcp_service.chart.chart_utils._find_dataset_by_id_or_uuid",
+ return_value=dataset,
+ ),
+ patch(
+ "superset.mcp_service.chart.chart_utils._is_temporal_for_dashboard_binding",
+ return_value=True,
+ ),
+ ):
+ if path == "immediate":
+ payload = _build_update_payload(request, chart, config)
+ assert isinstance(payload, dict)
+ merged = __import__("json").loads(payload["params"])
+ elif path == "preview_first":
+ result = _build_preview_form_data(request, chart, config)
+ assert isinstance(result, dict)
+ merged = result
+ else:
+ merged = map_config_to_form_data(config, dataset_id=7)
+ merge_update_form_data(existing, merged, config)
+
+ assert merged[MCP_DASHBOARD_TIME_FILTER_SUBJECT] == "OrderDate"
+ assert [filter_["subject"] for filter_ in merged["adhoc_filters"]] == [
+ "Status",
+ "OrderDate",
+ ]
+ assert merged["adhoc_filters"][1]["comparator"] == "Last year"
+
+
+def test_xy_native_axis_change_replaces_obsolete_provenanced_binding() -> None:
+ existing = {
+ "viz_type": "echarts_timeseries_line",
+ "x_axis": "OrderDate",
+ "granularity_sqla": "OrderDate",
+ MCP_DASHBOARD_TIME_FILTER_SUBJECT: "OrderDate",
+ "adhoc_filters": [
+ {
+ "clause": "WHERE",
+ "expressionType": "SIMPLE",
+ "subject": "OrderDate",
+ "operator": "TEMPORAL_RANGE",
+ "comparator": "Last year",
+ }
+ ],
+ }
+ config = XYChartConfig(
+ x={"name": "EventDate"},
+ y=[_simple_metric()],
+ filters=[{"column": "Status", "op": "=", "value": "Active"}],
+ )
+ with patch(
+ "superset.mcp_service.chart.chart_utils.is_column_truly_temporal",
+ return_value=True,
+ ):
+ mapped = map_config_to_form_data(config, dataset_id=7)
+ merge_update_form_data(existing, mapped, config)
+
+ assert mapped[MCP_DASHBOARD_TIME_FILTER_SUBJECT] == "EventDate"
+ assert [filter_["subject"] for filter_ in mapped["adhoc_filters"]] == [
+ "Status",
+ "EventDate",
+ ]
+ assert mapped["adhoc_filters"][1]["comparator"] == "Last year"
+
+
+def test_bullet_presentation_updates_are_atomic_and_comma_safe() -> None:
+ with pytest.raises(ValidationError, match="cannot contain commas"):
+ BulletChartConfig(
+ metric=_simple_metric(), ranges=[1], range_labels=["Low, medium"]
+ )
+
+ partial = BulletChartConfig.model_validate(
+ {
+ "viz_type": "bullet",
+ "metric": "SavedRevenue",
+ "ranges": "10,20,30",
+ "range_labels": "Low,,High",
+ }
+ )
+ assert partial.range_labels == ["Low", "", "High"]
+ assert map_bullet_config(partial)["range_labels"] == "Low,,High"
+
+ existing = {
+ "viz_type": "bullet",
+ "metric": "SavedRevenue",
+ "ranges": "10,20",
+ "range_labels": "Low,High",
+ "markers": "15,18",
+ "marker_labels": "Plan,Stretch",
+ "marker_lines": "12,16",
+ "marker_line_labels": "Goal,Forecast",
+ }
+ preserved = BulletChartPlugin().merge_update_form_data(
+ existing,
+ map_bullet_config(BulletChartConfig(metric=_simple_metric())),
+ BulletChartConfig(metric=_simple_metric()),
+ dataset_rebind=False,
+ )
+ assert preserved is not None
+ for control in ("range_labels", "marker_labels", "marker_line_labels"):
+ assert preserved[control] == existing[control]
+ config = BulletChartConfig(
+ metric=_simple_metric(), ranges=[30], markers=[25], marker_lines=[28]
+ )
+ mapped = BulletChartPlugin().merge_update_form_data(
+ existing, map_bullet_config(config), config, dataset_rebind=False
+ )
+ assert mapped is not None
+ assert mapped["ranges"] == "30"
+ assert mapped["range_labels"] == ""
+ assert mapped["markers"] == "25"
+ assert mapped["marker_labels"] == ""
+ assert mapped["marker_lines"] == "28"
+ assert mapped["marker_line_labels"] == ""
+ assert validate_merged_bullet_form_data(mapped) is not None
+
+ stale = dict(existing)
+ stale["ranges"] = "10"
+ assert validate_merged_bullet_form_data(stale) is not None
+
+
+def test_mapping_other_registered_chart_type_is_unchanged() -> None:
+ from superset.mcp_service.chart.schemas import PieChartConfig
+
+ form_data = map_config_to_form_data(
+ PieChartConfig(dimension={"name": "region"}, metric=_simple_metric("revenue"))
+ )
+ assert form_data["viz_type"] == "pie"
+ assert form_data["groupby"] == ["region"]
+
+
+@pytest.mark.asyncio
+async def test_generate_chart_tool_emits_native_bullet_form_data() -> None:
+ request = GenerateChartRequest(
+ dataset_id=7,
+ config=BulletChartConfig(
+ metric={"name": "Revenue", "aggregate": "SUM", "label": "Revenue"},
+ dimensions=[{"name": "Region"}],
+ ranges=[100, 250],
+ markers=[200],
+ ),
+ preview_formats=["url"],
+ )
+ validation_result = SimpleNamespace(
+ is_valid=True,
+ request=request,
+ warnings={},
+ error=None,
+ )
+ ctx = MagicMock()
+ ctx.info = AsyncMock()
+ ctx.debug = AsyncMock()
+ ctx.warning = AsyncMock()
+ ctx.error = AsyncMock()
+ ctx.report_progress = AsyncMock()
+ with (
+ patch(
+ "superset.mcp_service.auth.get_user_from_request",
+ return_value=_tool_user(),
+ ),
+ patch(
+ "superset.mcp_service.chart.validation.ValidationPipeline."
+ "validate_request_with_warnings",
+ return_value=validation_result,
+ ),
+ patch(
+ "superset.mcp_service.chart.chart_utils.generate_explore_link",
+ return_value=(
+ "http://localhost:8088/explore/?form_data_key=bullet_preview_key"
+ ),
+ ),
+ patch("superset.daos.dataset.DatasetDAO.find_by_id", return_value=None),
+ ):
+ result = await generate_chart(request, ctx=ctx)
+
+ assert result.success is True
+ assert result.form_data["viz_type"] == "bullet"
+ assert result.form_data["groupby"] == ["Region"]
+ assert result.form_data["ranges"] == "100,250"
+ assert result.form_data["markers"] == "200"
+
+
+@pytest.mark.asyncio
+async def test_generate_chart_creates_bullet_with_dimensions_omitted() -> None:
+ """Exercise schema revalidation, normalization and creation without dimensions."""
+ from superset.mcp_service.chart.compile import CompileResult
+
+ raw_config = {
+ "chart_type": "bullet",
+ "metric": {"name": "sales", "aggregate": "SUM"},
+ "ranges": [100000, 300000, 600000],
+ "range_labels": ["Low", "Medium", "High"],
+ "markers": [400000],
+ "marker_labels": ["Target"],
+ "show_labels": True,
+ }
+ assert "dimensions" not in raw_config
+ assert BulletChartConfig.model_validate(raw_config).dimensions is None
+ request = GenerateChartRequest(
+ dataset_id=7, config=raw_config, save_chart=True, generate_preview=False
+ )
+ dataset_context = DatasetContext(
+ id=7,
+ table_name="sales",
+ schema=None,
+ database_name="main",
+ available_columns=[{"name": "sales", "type": "NUMERIC", "is_numeric": True}],
+ available_metrics=[],
+ )
+ dataset = MagicMock(id=7, datasource_name="sales", table_name="sales", sql=None)
+ chart = SimpleNamespace(
+ id=9, datasource_id=7, slice_name="Sales", viz_type="bullet", uuid=None
+ )
+ ctx = MagicMock()
+ for method in ("info", "debug", "warning", "error", "report_progress"):
+ setattr(ctx, method, AsyncMock())
+ with (
+ patch(
+ "superset.mcp_service.auth.get_user_from_request", return_value=_tool_user()
+ ),
+ patch.object(
+ DatasetValidator, "_get_dataset_context", return_value=dataset_context
+ ),
+ patch.object(
+ ValidationPipeline, "_validate_runtime", return_value=(True, None)
+ ),
+ patch("superset.daos.dataset.DatasetDAO.find_by_id", return_value=dataset),
+ patch(
+ "superset.mcp_service.chart.tool.generate_chart.has_dataset_access",
+ return_value=True,
+ ),
+ patch("superset.mcp_service.auth.has_dataset_access", return_value=True),
+ patch(
+ "superset.mcp_service.chart.tool.generate_chart._compile_chart",
+ return_value=CompileResult(success=True, warnings=[]),
+ ),
+ patch("superset.commands.chart.create.CreateChartCommand") as command,
+ patch("superset.db.session"),
+ patch("superset.daos.chart.ChartDAO.find_by_id", return_value=chart),
+ patch(
+ "superset.mcp_service.chart.schemas.serialize_chart_object",
+ return_value=ChartInfo(id=9, slice_name="Sales", viz_type="bullet"),
+ ),
+ patch(
+ "superset.mcp_service.commands.create_form_data.MCPCreateFormDataCommand",
+ return_value=MagicMock(run=MagicMock(return_value="bullet-key")),
+ ),
+ ):
+ command.return_value.run.return_value = chart
+ result = await generate_chart(request, ctx=ctx)
+
+ assert result.success is True, result.error
+ command.assert_called_once()
+ persisted = __import__("json").loads(command.call_args.args[0]["params"])
+ assert persisted.get("groupby", []) == []
+ assert persisted["metric"]["column"]["column_name"] == "sales"
+ assert persisted["ranges"] == "100000,300000,600000"
+ assert persisted["show_labels"] is True
+
+
+@pytest.mark.parametrize(
+ ("aliases", "expected"),
+ [
+ ({"dimensions": None}, []),
+ ({"groupby": None}, []),
+ ({"dimensions": None, "groupby": None}, []),
+ ({"dimensions": None, "groupby": ["Region"]}, ["Region"]),
+ ({"dimensions": ["Region"], "groupby": None}, ["Region"]),
+ ({"dimensions": [], "groupby": None}, []),
+ ],
+)
+def test_bullet_null_dimension_alias_is_absent(
+ aliases: dict[str, Any], expected: list[str]
+) -> None:
+ """Null aliases neither conflict with nor shadow a populated hierarchy."""
+ config = BulletChartConfig.model_validate({"metric": _simple_metric(), **aliases})
+ assert [column.name for column in config.dimensions or []] == expected
+ if not expected and aliases.get("dimensions") != []:
+ assert "dimensions" not in config.model_fields_set
+
+
+@pytest.mark.parametrize("dataset_rebind", [False])
+@pytest.mark.parametrize("order_by", [[], [{"column": "Region", "ascending": True}]])
+@pytest.mark.parametrize("sql_dimension", [False, True])
+def test_update_chart_preview_tool_preserves_omitted_bullet_state(
+ order_by: list[dict[str, Any]],
+ dataset_rebind: bool,
+ sql_dimension: bool,
+) -> None:
+ dimension: str | dict[str, Any] = (
+ {
+ "expressionType": "SQL",
+ "sqlExpression": "UPPER(Region)",
+ "label": "RegionUpper",
+ }
+ if sql_dimension
+ else "Region"
+ )
+ if sql_dimension and order_by:
+ order_by = [{"column": "RegionUpper", "ascending": True}]
+ request = UpdateChartPreviewRequest(
+ form_data_key="previous_bullet_key",
+ dataset_id=7,
+ config=BulletChartConfig(metric=_simple_metric("revenue"), order_by=order_by),
+ generate_preview=False,
+ )
+ dataset = _orm_dataset()
+ previous: dict[str, Any] = {
+ "datasource": "6__table" if dataset_rebind else "7__table",
+ "viz_type": "bullet",
+ "metric": "old_metric",
+ "groupby": [dimension, "Team"],
+ "ranges": "100,250",
+ "show_labels": True,
+ MCP_DASHBOARD_TIME_FILTER_SUBJECT: "OrderDate",
+ "adhoc_filters": [
+ {
+ "clause": "WHERE",
+ "expressionType": "SQL",
+ "sqlExpression": "Status = 'Active' ",
+ },
+ {
+ "clause": "HAVING",
+ "expressionType": "SIMPLE",
+ "subject": "SavedRevenue",
+ "operator": ">",
+ "comparator": 100,
+ },
+ {
+ "clause": "WHERE",
+ "expressionType": "SIMPLE",
+ "subject": "OrderDate",
+ "operator": "TEMPORAL_RANGE",
+ "comparator": "No filter",
+ },
+ ],
+ }
+ if dataset_rebind:
+ previous["adhoc_filters"] = previous["adhoc_filters"][1:]
+ link = MagicMock(
+ return_value="http://localhost:8088/explore/?form_data_key=new_bullet_key"
+ )
+ with (
+ patch(
+ "superset.mcp_service.auth.get_user_from_request",
+ return_value=_tool_user(),
+ ),
+ patch(
+ "superset.mcp_service.chart.tool.update_chart_preview._find_dataset",
+ return_value=dataset,
+ ),
+ patch(
+ "superset.mcp_service.chart.tool.update_chart_preview."
+ "_get_previous_form_data",
+ return_value=previous,
+ ),
+ patch("superset.daos.dataset.DatasetDAO.find_by_id", return_value=dataset),
+ patch(
+ "superset.mcp_service.chart.tool.update_chart_preview.has_dataset_access",
+ return_value=True,
+ ),
+ patch(
+ "superset.mcp_service.chart.chart_utils._is_temporal_for_dashboard_binding",
+ return_value=True,
+ ),
+ patch(
+ "superset.mcp_service.chart.tool.update_chart_preview.validate_and_compile",
+ return_value=SimpleNamespace(success=True),
+ ),
+ patch(
+ "superset.mcp_service.chart.tool.update_chart_preview."
+ "generate_explore_link",
+ link,
+ ),
+ patch(
+ "superset.mcp_service.chart.tool.update_chart_preview."
+ "analyze_chart_capabilities",
+ return_value=None,
+ ),
+ patch(
+ "superset.mcp_service.chart.tool.update_chart_preview."
+ "analyze_chart_semantics",
+ return_value=None,
+ ),
+ ):
+ result = asyncio.run(update_chart_preview(request, ctx=MagicMock()))
+
+ assert result["success"] is True, result
+ preview_form_data = link.call_args.args[1]
+ assert preview_form_data["metric"]["column"]["column_name"] == "Revenue"
+ assert preview_form_data["groupby"] == [dimension, "Team"]
+ assert preview_form_data["ranges"] == "100,250"
+ assert preview_form_data["show_labels"] is True
+ assert preview_form_data["adhoc_filters"] == previous["adhoc_filters"]
+
+ if order_by:
+ assert preview_form_data["orderby"] == [[dimension, True]]
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("dimensions", [[], ["Region"]])
+@pytest.mark.parametrize("sort_update", [False, True])
+@pytest.mark.parametrize("aliases", [{}, {"dimensions": None}, {"groupby": None}])
+async def test_update_chart_tool_persists_native_bullet_round_trip(
+ dimensions: list[str], aliases: dict[str, Any], sort_update: bool
+) -> None:
+ """Omitted/null dimensions preserve both empty and populated saved hierarchies."""
+ existing = {
+ "viz_type": "bullet",
+ "metric": "old_metric",
+ "groupby": dimensions,
+ "ranges": "100,250",
+ "range_labels": "Low",
+ "show_legend": True,
+ }
+ chart = SimpleNamespace(
+ id=9,
+ datasource_id=7,
+ slice_name="Saved Bullet",
+ params=__import__("json").dumps(existing),
+ viz_type="bullet",
+ uuid="bullet-uuid",
+ )
+ updated_chart = SimpleNamespace(
+ id=9,
+ datasource_id=7,
+ slice_name="Saved Bullet",
+ viz_type="bullet",
+ uuid="bullet-uuid",
+ )
+ command = MagicMock()
+ command.return_value.run.return_value = updated_chart
+ request = UpdateChartRequest(
+ identifier=9,
+ config={
+ "chart_type": "bullet",
+ "metric": _simple_metric("NewRevenue"),
+ **aliases,
+ **(
+ {"order_by": [{"column": "Region", "ascending": True}]}
+ if sort_update and dimensions
+ else {}
+ ),
+ },
+ generate_preview=False,
+ preview_formats=[],
+ )
+ ctx = MagicMock()
+ ctx.warning = AsyncMock()
+ ctx.error = AsyncMock()
+ with (
+ patch(
+ "superset.mcp_service.auth.get_user_from_request",
+ return_value=_tool_user(),
+ ),
+ patch(
+ "superset.mcp_service.chart.tool.update_chart.find_chart_by_identifier",
+ return_value=chart,
+ ),
+ patch(
+ "superset.mcp_service.auth.check_chart_data_access",
+ return_value=SimpleNamespace(is_valid=True, error=None),
+ ),
+ patch.object(
+ DatasetValidator,
+ "normalize_column_names",
+ side_effect=lambda config, dataset_id: config,
+ ),
+ patch(
+ "superset.mcp_service.chart.tool.update_chart."
+ "_validate_update_against_dataset",
+ return_value=None,
+ ),
+ patch("superset.commands.chart.update.UpdateChartCommand", command),
+ patch(
+ "superset.mcp_service.chart.tool.update_chart.analyze_chart_capabilities",
+ return_value=None,
+ ),
+ patch(
+ "superset.mcp_service.chart.tool.update_chart.analyze_chart_semantics",
+ return_value=None,
+ ),
+ ):
+ result = await update_chart(request, ctx=ctx)
+
+ assert result.success is True
+ payload = command.call_args.args[1]
+ persisted = __import__("json").loads(payload["params"])
+ assert persisted["metric"]["column"]["column_name"] == "NewRevenue"
+ assert persisted["groupby"] == dimensions
+ assert persisted["ranges"] == "100,250"
+ assert persisted["range_labels"] == "Low"
+ assert persisted["show_legend"] is True
+
+ if sort_update and dimensions:
+ assert persisted["orderby"] == [["Region", True]]
+
+
+def test_bullet_exact_case_dimensions_survive_normalization_and_query() -> None:
+ from superset.mcp_service.chart.registry import get
+
+ context = DatasetContext(
+ id=7,
+ table_name="sales",
+ schema=None,
+ database_name="main",
+ available_columns=[
+ {"name": "Revenue", "type": "NUMERIC", "is_numeric": True},
+ {"name": "Region", "type": "VARCHAR"},
+ {"name": "region", "type": "VARCHAR"},
+ ],
+ available_metrics=[],
+ )
+ config = BulletChartConfig(
+ metric={"name": "Revenue", "aggregate": "SUM", "label": "REGION"},
+ dimensions=[{"name": "Region"}, {"name": "region"}],
+ order_by=[{"column": "Region"}, {"column": "region"}],
+ )
+ plugin = get("bullet")
+ assert plugin is not None
+ normalized = plugin.normalize_column_refs(config, context)
+ assert [item.column for item in normalized.order_by] == ["Region", "region"]
+ form_data = map_bullet_config(normalized)
+ assert build_query_dicts_from_form_data(form_data, 7, "table")[0]["columns"] == [
+ "Region",
+ "region",
+ ]
+ model = resolve_bullet_render_model(
+ [{"Region": "North", "region": "South", "REGION": 42}],
+ form_data,
+ )
+ assert model.dimensions == ["Region", "region"]
+ assert model.metric_field == "REGION"
+ with pytest.raises(
+ AmbiguousDatasetReferenceError, match="Bullet dimension reference 'rEgIoN'"
+ ):
+ plugin.normalize_column_refs(
+ BulletChartConfig(metric=_simple_metric(), dimensions=[{"name": "rEgIoN"}]),
+ context,
+ )
+ with pytest.raises(ValidationError, match="ambiguous"):
+ BulletChartConfig(
+ metric=_simple_metric(),
+ dimensions=[{"name": "Region"}, {"name": "region"}],
+ order_by=[{"column": "REGION"}],
+ )
+
+
+@pytest.mark.parametrize(
+ "ambiguous_config,role",
+ [
+ ({"dimensions": [{"name": "rEgIoN"}]}, "dimension"),
+ (
+ {"filters": [{"column": "rEgIoN", "operator": "=", "value": "North"}]},
+ "filter column",
+ ),
+ ({"temporal_column": "oRdErEd_At"}, "temporal column"),
+ ],
+)
+def test_bullet_normalization_raises_named_ambiguity_error(
+ ambiguous_config: dict[str, Any], role: str
+) -> None:
+ """Normalization must propagate Bullet ambiguity, not warn and carry on.
+
+ ``_normalize_column_names`` re-raises only named normalization errors and
+ downgrades every other ``ValueError`` to a warning, returning the original
+ request. A bare ``ValueError`` here would leave the unresolved reference in
+ place, which is exactly the silent field selection the canonicalization
+ exists to prevent.
+ """
+ context = DatasetContext(
+ id=7,
+ table_name="sales",
+ schema=None,
+ database_name="main",
+ available_columns=[
+ {"name": "Revenue", "type": "NUMERIC", "is_numeric": True},
+ {"name": "Region", "type": "VARCHAR"},
+ {"name": "region", "type": "VARCHAR"},
+ {"name": "Ordered_At", "type": "TIMESTAMP", "is_temporal": True},
+ {"name": "ordered_at", "type": "TIMESTAMP", "is_temporal": True},
+ ],
+ available_metrics=[],
+ )
+ request = GenerateChartRequest.model_validate(
+ {
+ "dataset_id": 7,
+ "config": {
+ "chart_type": "bullet",
+ "metric": {"name": "Revenue", "aggregate": "SUM"},
+ **ambiguous_config,
+ },
+ }
+ )
+
+ with pytest.raises(AmbiguousDatasetReferenceError) as excinfo:
+ ValidationPipeline._normalize_column_names(
+ request, context, typed_config=request.config
+ )
+
+ error = excinfo.value
+ assert error.reference_kind == f"Bullet {role}"
+ assert len(error.matches) > 1
+ assert "differ only by case" in str(error)
+ # Callers that predate the named exception catch ValueError; the subclass
+ # keeps them working while making the pipeline re-raise.
+ assert isinstance(error, ValueError)
+
+
+def test_bullet_ignores_foreign_sort_flag_after_master_merge() -> None:
+ form_data = {
+ "viz_type": "bullet",
+ "metric": "Revenue",
+ "groupby": ["Region"],
+ "sort_by_metric": True,
+ "orderby": [["Region", True]],
+ }
+ query = build_query_dicts_from_form_data(form_data, 7, "table")[0]
+ assert query["orderby"] == [["Region", True]]
+
+
+@pytest.mark.parametrize(
+ "rebind, viz_type, target",
+ [
+ (False, "bullet", "Missing"),
+ (True, "bullet", "Region"),
+ (False, "table", "Region"),
+ ],
+)
+def test_bullet_deferred_sort_rejects_unknown_or_unavailable_hierarchy(
+ rebind: bool,
+ viz_type: str,
+ target: str,
+) -> None:
+ """Deferral must not allow unknown roles or inherit from another contract."""
+ config = BulletChartConfig(metric=_simple_metric(), order_by=[{"column": target}])
+ with pytest.raises(ValueError, match="unknown"):
+ BulletChartPlugin().resolve_update_config(
+ config,
+ {"viz_type": viz_type, "groupby": ["Region"]},
+ dataset_rebind=rebind,
+ )
+ with pytest.raises(ValueError, match="unknown"):
+ map_config_to_form_data(config)
+
+
+def test_bullet_deferred_sort_resolves_saved_dimension_before_metric_alias() -> None:
+ """A saved physical dimension takes precedence over a metric input alias."""
+ config = BulletChartConfig(
+ metric={"name": "Revenue", "aggregate": "SUM", "label": "Total"},
+ order_by=[{"column": "Revenue"}, {"column": "Total"}],
+ )
+ resolved = BulletChartPlugin().resolve_update_config(
+ config,
+ {"viz_type": "bullet", "groupby": ["Revenue"]},
+ dataset_rebind=False,
+ )
+ mapped = map_bullet_config(resolved)
+ assert mapped["orderby"] == [["Revenue", False], [mapped["metric"], False]]
+ with pytest.raises(ValueError, match="duplicate outputs"):
+ map_bullet_config(config)
+
+
+@pytest.mark.parametrize("control", ["ranges", "markers", "marker_lines"])
+@pytest.mark.parametrize("value", ["10, nope, , NaN,20", "nope, NaN"])
+def test_saved_bullet_numeric_controls_drop_non_numeric_tokens(
+ control: str, value: str
+) -> None:
+ """Native previews and unrelated updates retain Explore's lenient tokens."""
+ form_data = {
+ "viz_type": "bullet",
+ "metric": "SavedRevenue",
+ "groupby": [],
+ control: value,
+ "show_legend": True,
+ }
+ expected = [10.0, 20.0] if value.startswith("10") else []
+ model = resolve_bullet_render_model([{"SavedRevenue": 5}], form_data)
+ assert getattr(model, control) == (
+ expected or [0.0, 5.5] if control == "ranges" else expected
+ )
+ native = BulletChartConfig.model_validate(form_data)
+ assert getattr(native, control) == expected
+ with pytest.raises(ValidationError):
+ BulletChartConfig.model_validate(
+ {"metric": _simple_metric(), control: ["nope"]}
+ )
+ config = BulletChartConfig(
+ metric={"name": "SavedRevenue", "saved_metric": True}, show_legend=False
+ )
+ chart = SimpleNamespace(
+ id=1,
+ datasource_id=7,
+ slice_name="Saved Bullet",
+ params=__import__("json").dumps(form_data),
+ )
+ request = UpdateChartRequest(identifier=1, config=config)
+ with patch(
+ "superset.mcp_service.chart.chart_utils._find_dataset_by_id_or_uuid",
+ return_value=None,
+ ):
+ payload = _build_update_payload(request, chart, config)
+ assert isinstance(payload, dict)
+ merged = __import__("json").loads(payload["params"])
+ validated = validate_merged_bullet_form_data(merged, config)
+ assert validated is not None
+ assert getattr(validated, control) == expected
+ assert merged[control] == value
+
+
+def test_bullet_schema_error_hint_does_not_require_matching_label_counts() -> None:
+ """General validation guidance must not contradict per-index label fallback."""
+ from superset.mcp_service.chart.plugins.bullet import BulletChartPlugin
+
+ hint = BulletChartPlugin().schema_error_hint()
+ assert hint is not None
+ assert hint.details is not None
+ assert "must align" not in hint.details
+ assert "optional" in hint.details.lower()
+ BulletChartConfig(
+ metric=_simple_metric(),
+ ranges=[10, 20],
+ range_labels=["Low"],
+ markers=[12],
+ marker_labels=["Plan", "Ignored"],
+ )
+
+
+@pytest.mark.parametrize(
+ "categories",
+ [
+ [None, "null"],
+ [True, "true"],
+ [12, "12"],
+ ["North, Blue", "North, Blue"],
+ ],
+)
+def test_bullet_vega_colliding_labels_keep_distinct_rows(categories: list[Any]) -> None:
+ """Display text must not collapse separate query rows onto one band."""
+ preview = _generate_vega_lite_preview_from_data(
+ [
+ {"Region": category, "Revenue": index + 1}
+ for index, category in enumerate(categories)
+ ],
+ {"viz_type": "bullet", "metric": "Revenue", "groupby": ["Region"]},
+ )
+ spec = preview.specification
+ bar = next(layer for layer in spec["layer"] if layer["mark"]["type"] == "bar")
+ row_field = bar["encoding"]["y"]["field"]
+ label_field = bar["encoding"]["tooltip"][0]["field"]
+ values = spec["data"]["values"]
+ assert len({row[row_field] for row in values}) == len(categories)
+ assert row_field != label_field
+ assert values[0][label_field] == values[1][label_field]
+ assert "labelExpr" in bar["encoding"]["y"]["axis"]
+ assert all(layer["encoding"]["y"]["field"] == row_field for layer in spec["layer"])
+
+
+def test_bullet_vega_translates_signed_smart_number_in_axis_and_tooltip() -> None:
+ """Explore pseudo-formats require valid D3 format projections."""
+ preview = _generate_vega_lite_preview_from_data(
+ [{"Revenue": 1234}],
+ {
+ "viz_type": "bullet",
+ "metric": "Revenue",
+ "y_axis_format": "SMART_NUMBER_SIGNED",
+ },
+ )
+ bar = next(
+ layer
+ for layer in preview.specification["layer"]
+ if layer["mark"]["type"] == "bar"
+ )
+ assert bar["encoding"]["x"]["axis"]["format"] == "+~s"
+ assert bar["encoding"]["tooltip"][1]["format"] == "+~s"
+
+
+def test_bullet_time_range_update_replaces_unmarked_native_binding() -> None:
+ existing: dict[str, object] = {
+ "viz_type": "bullet",
+ "metric": "SavedRevenue",
+ "groupby": ["Region"],
+ "adhoc_filters": [
+ {
+ "clause": "WHERE",
+ "expressionType": "SIMPLE",
+ "subject": "OrderDate",
+ "operator": "TEMPORAL_RANGE",
+ "comparator": "Last year",
+ }
+ ],
+ }
+ for time_range in ("Last 30 days", "Last 7 days"):
+ config = BulletChartConfig(metric=_simple_metric(), time_range=time_range)
+ with (
+ patch(
+ "superset.mcp_service.chart.chart_utils._find_dataset_by_id_or_uuid",
+ return_value=_orm_dataset(),
+ ),
+ patch(
+ "superset.mcp_service.chart.chart_utils._is_temporal_for_dashboard_binding",
+ return_value=True,
+ ),
+ ):
+ merged = map_config_to_form_data(config, dataset_id=7)
+ merge_update_form_data(existing, merged, config)
+
+ assert [
+ (item["subject"], item["comparator"]) for item in merged["adhoc_filters"]
+ ] == [("OrderDate", time_range)]
+ assert merged[MCP_DASHBOARD_TIME_FILTER_SUBJECT] == "OrderDate"
+ # The next update sees the recorded provenance and a single binding.
+ existing = {**merged}
+
+
+async def _run_saved_bullet_update(
+ existing: dict[str, Any], config: dict[str, Any], *, dataset_id: int | None = None
+) -> dict[str, Any]:
+ chart = SimpleNamespace(
+ id=9,
+ datasource_id=6 if dataset_id else 7,
+ slice_name="Saved Bullet",
+ params=__import__("json").dumps(existing),
+ viz_type="bullet",
+ uuid="bullet-uuid",
+ )
+ updated_chart = SimpleNamespace(
+ id=9,
+ datasource_id=dataset_id or 7,
+ slice_name="Saved Bullet",
+ viz_type="bullet",
+ uuid="bullet-uuid",
+ )
+ command = MagicMock()
+ command.return_value.run.return_value = updated_chart
+ request = UpdateChartRequest(
+ identifier=9,
+ config={"chart_type": "bullet", **config},
+ dataset_id=dataset_id,
+ generate_preview=False,
+ preview_formats=[],
+ )
+ ctx = MagicMock()
+ ctx.warning = AsyncMock()
+ ctx.error = AsyncMock()
+ with (
+ patch("superset.mcp_service.auth.has_dataset_access", return_value=True),
+ patch(
+ "superset.mcp_service.auth.get_user_from_request",
+ return_value=_tool_user(),
+ ),
+ patch(
+ "superset.mcp_service.chart.tool.update_chart.find_chart_by_identifier",
+ return_value=chart,
+ ),
+ patch(
+ "superset.mcp_service.auth.check_chart_data_access",
+ return_value=SimpleNamespace(is_valid=True, error=None),
+ ),
+ patch(
+ "superset.mcp_service.chart.compile._compile_chart",
+ return_value=CompileResult(success=True),
+ ) as compile_chart,
+ patch(
+ "superset.daos.dataset.DatasetDAO.find_by_id", return_value=_orm_dataset()
+ ),
+ patch(
+ "superset.mcp_service.chart.chart_utils._find_dataset_by_id_or_uuid",
+ return_value=_orm_dataset(),
+ ),
+ patch("superset.commands.chart.update.UpdateChartCommand", command),
+ patch(
+ "superset.mcp_service.chart.tool.update_chart.analyze_chart_capabilities",
+ return_value=None,
+ ),
+ patch(
+ "superset.mcp_service.chart.tool.update_chart.analyze_chart_semantics",
+ return_value=None,
+ ),
+ ):
+ result = await update_chart(request, ctx=ctx)
+
+ assert result.success is True, result
+ persisted = __import__("json").loads(command.call_args.args[1]["params"])
+ assert compile_chart.call_args.args[0] == persisted
+ return persisted
+
+
+@pytest.mark.asyncio
+async def test_saved_bullet_clearing_dimensions_drops_their_sorts_only() -> None:
+ metric_sort = ["SavedRevenue", False]
+ persisted = await _run_saved_bullet_update(
+ {
+ "viz_type": "bullet",
+ "metric": "SavedRevenue",
+ "groupby": ["Region"],
+ "orderby": [["Region", True], metric_sort],
+ },
+ {"metric": _simple_metric("Revenue"), "dimensions": []},
+ )
+
+ assert persisted["groupby"] == []
+ assert persisted["orderby"] == []
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("metric_key", ["metric", "metrics"])
+@pytest.mark.parametrize("adhoc", [False, True])
+@pytest.mark.parametrize("metric_source", ["saved", "new"])
+async def test_saved_bullet_removed_dimension_keeps_colliding_metric_sort(
+ metric_key: str, adhoc: bool, metric_source: str
+) -> None:
+ """Only final metric output labels survive dimension removal."""
+ saved_label = "Revenue" if metric_source == "saved" else "OldRevenue"
+ saved_metric = {"label": saved_label} if adhoc else saved_label
+ metric_sort = ["Revenue", False]
+ persisted = await _run_saved_bullet_update(
+ {
+ "viz_type": "bullet",
+ metric_key: [saved_metric] if metric_key == "metrics" else saved_metric,
+ "groupby": ["revenue", "Country", "Region"],
+ "orderby": [metric_sort, ["Country", True], ["Region", True]],
+ },
+ {
+ "metric": {
+ **_simple_metric(),
+ "label": "NewRevenue" if metric_source == "saved" else "Revenue",
+ },
+ "dimensions": [{"name": "Country"}],
+ },
+ )
+
+ assert persisted["groupby"] == ["Country"]
+ assert persisted["orderby"] == (
+ [metric_sort, ["Country", True]]
+ if metric_source == "new"
+ else [["Country", True]]
+ )
+
+
+def test_bullet_sanitized_rows_keep_exact_source_metric() -> None:
+ plugin = BulletChartPlugin()
+ form_data = {"viz_type": "bullet", "metric": "Revenue", "groupby": ["Region"]}
+
+ rows, error = plugin.sanitize_data_rows(
+ [
+ {"Region": "North", "Revenue": 9007199254740993, "Other": "x"},
+ {"Region": "South", "Revenue": "9007199254740993", "Other": "y"},
+ {"Region": "East", "Revenue": Decimal("1.10"), "Other": "z"},
+ ],
+ form_data,
+ )
+
+ assert error is None
+ assert [row["Revenue"] for row in rows] == [
+ 9007199254740993,
+ "9007199254740993",
+ Decimal("1.10"),
+ ]
+ assert [row["Region"] for row in rows] == ["North", "South", "East"]
+ assert all(row["Other"] is None for row in rows)
+
+
+@pytest.mark.parametrize(
+ ("aggregate", "output"),
+ [
+ (None, "SUM(Revenue)"),
+ ("STDDEV", "STDDEV_SAMP(Revenue)"),
+ ("VAR", "VAR_SAMP(Revenue)"),
+ ],
+)
+def test_bullet_output_roles_match_mapped_aggregate(
+ aggregate: str | None, output: str
+) -> None:
+ """Validation and mapping agree on defaulted and aliased metric outputs."""
+ metric = {"name": "Revenue"}
+ if aggregate is not None:
+ metric["aggregate"] = aggregate
+ config = BulletChartConfig(
+ metric=metric,
+ dimensions=[{"name": "Revenue"}],
+ order_by=[{"column": output, "ascending": False}],
+ )
+ mapped = map_bullet_config(config)
+ assert mapped["metric"]["label"] == output
+ assert mapped["orderby"] == [[mapped["metric"], False]]
+
+
+@pytest.mark.asyncio
+async def test_saved_bullet_removes_only_exact_obsolete_dimension_sort() -> None:
+ """Case-distinct dimensions retain only their exact final output sorts."""
+ persisted = await _run_saved_bullet_update(
+ {
+ "viz_type": "bullet",
+ "metric": "SavedRevenue",
+ "groupby": ["Region", "region"],
+ "orderby": [["Region", True], ["region", False]],
+ },
+ {"metric": _simple_metric(), "dimensions": [{"name": "region"}]},
+ )
+ assert persisted["orderby"] == [["region", False]]
+
+
+@pytest.mark.parametrize("path", ["save", "preview"])
+def test_bullet_presentation_update_preserves_query_metadata(path: str) -> None:
+ """Save and preview retain omitted SQL template inputs on the same dataset."""
+ existing = {
+ "viz_type": "bullet",
+ "metric": "SavedRevenue",
+ "url_params": {"region": "EU"},
+ }
+ chart = SimpleNamespace(
+ id=9,
+ datasource_id=7,
+ slice_name="Saved Bullet",
+ params=__import__("json").dumps(existing),
+ )
+ config = BulletChartConfig(
+ metric={"name": "SavedRevenue", "saved_metric": True}, show_labels=True
+ )
+ request = UpdateChartRequest(identifier=9, config=config, generate_preview=False)
+ if path == "save":
+ payload = _build_update_payload(request, chart, config)
+ assert isinstance(payload, dict)
+ merged = __import__("json").loads(payload["params"])
+ else:
+ merged = _build_preview_form_data(request, chart, config)
+ assert isinstance(merged, dict)
+ assert merged["url_params"] == {"region": "EU"}
+
+
+@pytest.mark.parametrize("field", ["Revenue.total", "Revenue[total]", r"Revenue\total"])
+def test_bullet_vega_metric_references_literal_result_key(field: str) -> None:
+ """Vega field paths escape punctuation without changing literal result keys."""
+ preview = _generate_vega_lite_preview_from_data(
+ [{"Region": "North", field: 10}],
+ {"viz_type": "bullet", "metric": field, "groupby": ["Region"]},
+ )
+ assert isinstance(preview, VegaLitePreview)
+ spec = preview.specification
+ escaped = "".join("\\" + char if char in ".[]\\" else char for char in field)
+ bar = next(layer for layer in spec["layer"] if layer["mark"]["type"] == "bar")
+ assert bar["encoding"]["x"]["field"] == escaped
+ assert bar["encoding"]["tooltip"][1]["field"] == escaped
+ assert spec["data"]["values"][0][field] == 10
+
+
+@pytest.mark.parametrize("role", ["ranges", "markers", "marker_lines"])
+def test_bullet_native_thresholds_use_javascript_number_tokens(role: str) -> None:
+ """Saved threshold strings follow the frontend Number tokenizer grammar."""
+ model = resolve_bullet_render_model(
+ [{"Revenue": 10}],
+ {
+ "viz_type": "bullet",
+ "metric": "Revenue",
+ role: "0x64,0b11,0o10,1_000,+0x10,-0b1,1e2,.5,1.,NaN,inf,"
+ "\ufeff0x10\ufeff,\u008520,\x1c30,١٠",
+ },
+ )
+ assert getattr(model, role) == [100, 3, 8, 100, 0.5, 1, 16]
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("native_metric_sort", [False, True])
+async def test_saved_bullet_replaced_metric_prunes_sort_with_omitted_hierarchy(
+ native_metric_sort: bool,
+) -> None:
+ """Metric replacement prunes stale native sorts after restoring dimensions."""
+ old_metric = map_bullet_config(
+ BulletChartConfig(metric={**_simple_metric(), "label": "OldRevenue"})
+ )["metric"]
+ persisted = await _run_saved_bullet_update(
+ {
+ "viz_type": "bullet",
+ "metric": old_metric,
+ "groupby": ["Region"],
+ "orderby": [
+ [old_metric if native_metric_sort else "OldRevenue", False],
+ ["Region", True],
+ ],
+ },
+ {"metric": _simple_metric()},
+ )
+ assert persisted["orderby"] == [["Region", True]]
+
+
+@pytest.mark.parametrize("value", ["1_000", "123", "١٢٣", "\x1c100"])
+def test_bullet_metric_rejects_decimal_only_strings(value: str) -> None:
+ """Compilation and previews reject spellings that JavaScript Number rejects."""
+ rows = [{"Revenue": value}]
+ result = _compile_bullet_with_result({"queries": [{"data": rows}]})
+ assert result.success is False
+ assert result.error_obj is not None
+ assert result.error_obj.error_type == "MalformedBulletOutput"
+ form_data = map_bullet_config(
+ BulletChartConfig(
+ metric={"name": "amount", "aggregate": "SUM", "label": "Revenue"}
+ )
+ )
+ with pytest.raises(BulletOutputError, match="non-numeric text"):
+ resolve_bullet_render_model(rows, form_data)
+
+
+@pytest.mark.parametrize(
+ "value, expected",
+ [
+ ("1e3", 1000),
+ ("\ufeff+4.25\u00a0", 4.25),
+ ("0x64", 100),
+ ("0b101", 5),
+ ("0o10", 8),
+ ("1.000000000000000001", 1),
+ ],
+)
+def test_bullet_metric_accepts_javascript_numeric_strings(
+ value: str, expected: float
+) -> None:
+ """Finite browser number spellings remain compatible with exact wire strings."""
+ model = resolve_bullet_render_model([{"Revenue": value}], {"metric": "Revenue"})
+ assert model.measures == [expected]
+
+
+@pytest.mark.parametrize("inert_first", [True, False])
+def test_bullet_native_temporal_range_keeps_its_own_subject(inert_first: bool) -> None:
+ """An inert temporal placeholder cannot steal an active range's subject."""
+ filters = [
+ {
+ "expressionType": "SIMPLE",
+ "subject": "OrderDate",
+ "operator": "TEMPORAL_RANGE",
+ "comparator": "No filter",
+ },
+ {
+ "expressionType": "SIMPLE",
+ "subject": "ShipDate",
+ "operator": "TEMPORAL_RANGE",
+ "comparator": "Last 30 days",
+ },
+ ]
+ config = BulletChartConfig.model_validate(
+ {
+ "viz_type": "bullet",
+ "metric": "Revenue",
+ "adhoc_filters": filters if inert_first else filters[::-1],
+ }
+ )
+ assert config.temporal_column == "ShipDate"
+ assert config.time_range == "Last 30 days"
+ mapped = map_bullet_config(config)
+ temporal = [
+ item for item in mapped["adhoc_filters"] if item["operator"] == "TEMPORAL_RANGE"
+ ]
+ assert [(item["subject"], item["comparator"]) for item in temporal] == [
+ ("ShipDate", "Last 30 days")
+ ]
+
+
+@pytest.mark.parametrize(
+ "extra",
+ [
+ {"temporal_column": "OrderDate"},
+ {"time_range": "Last year"},
+ {
+ "adhoc_filters": [
+ {
+ "expressionType": "SIMPLE",
+ "subject": "OrderDate",
+ "operator": "TEMPORAL_RANGE",
+ "comparator": "Last year",
+ },
+ {
+ "expressionType": "SIMPLE",
+ "subject": "ShipDate",
+ "operator": "TEMPORAL_RANGE",
+ "comparator": "Last 30 days",
+ },
+ ]
+ },
+ ],
+)
+def test_bullet_native_temporal_ranges_reject_lossy_typed_adaptation(
+ extra: dict[str, Any],
+) -> None:
+ """The typed temporal pair cannot silently overwrite another predicate."""
+ native = {
+ "viz_type": "bullet",
+ "metric": "Revenue",
+ "adhoc_filters": [
+ {
+ "expressionType": "SIMPLE",
+ "subject": "ShipDate",
+ "operator": "TEMPORAL_RANGE",
+ "comparator": "Last 30 days",
+ }
+ ],
+ }
+ with pytest.raises(ValidationError, match="temporal"):
+ BulletChartConfig.model_validate({**native, **extra})
+
+
+@pytest.mark.parametrize(
+ "format_", [".1000000000e", ".1000000000f", ".1000000000g", ".1000000000s", ".21e"]
+)
+def test_bullet_format_precision_is_bounded_before_formatter_runs(format_: str) -> None:
+ """Small zero-valued results cannot trigger unbounded format allocation."""
+ with patch(
+ "superset.mcp_service.chart.preview_utils._format_bullet_number"
+ ) as formatter:
+ with pytest.raises(BulletOutputError) as failure:
+ resolve_bullet_render_model(
+ [{"Revenue": 0}], {"metric": "Revenue", "y_axis_format": format_}
+ )
+ assert failure.value.error_type == "UnsupportedFormat"
+ formatter.assert_not_called()
+
+
+def test_bullet_format_accepts_bounded_precision() -> None:
+ """The frontend's maximum fixed/exponential precision remains usable."""
+ model = resolve_bullet_render_model(
+ [{"Revenue": 0}], {"metric": "Revenue", "y_axis_format": ".20e"}
+ )
+ assert model.y_axis_format == ".20e"
+
+
+@pytest.mark.asyncio
+async def test_saved_bullet_labels_update_preserves_url_params() -> None:
+ """Query metadata must survive schema validation, compilation and persistence."""
+ persisted = await _run_saved_bullet_update(
+ {
+ "viz_type": "bullet",
+ "metric": "SavedRevenue",
+ "url_params": {"region": "EU"},
+ },
+ {
+ "metric": {"name": "SavedRevenue", "saved_metric": True},
+ "show_labels": True,
+ },
+ )
+ assert persisted["url_params"] == {"region": "EU"}
+ assert persisted["show_labels"] is True
+
+
+@pytest.mark.asyncio
+async def test_saved_bullet_labels_update_preserves_sql_hierarchy() -> None:
+ """Inherited Explore dimensions remain native, not typed physical columns."""
+ dimension = {
+ "expressionType": "SQL",
+ "sqlExpression": "UPPER(Region)",
+ "label": "RegionUpper",
+ }
+ persisted = await _run_saved_bullet_update(
+ {
+ "viz_type": "bullet",
+ "metric": "SavedRevenue",
+ "groupby": [dimension],
+ "orderby": [[dimension, True]],
+ },
+ {
+ "metric": {"name": "SavedRevenue", "saved_metric": True},
+ "show_labels": True,
+ },
+ )
+ assert persisted["groupby"] == [dimension]
+ assert persisted["orderby"] == [[dimension, True]]
+ query = build_query_dicts_from_form_data(persisted, 7, "table")[0]
+ assert query["columns"] == [dimension]
+ assert query["orderby"] == [[dimension, True]]
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("metric_change", [False, True])
+@pytest.mark.parametrize("sort_target", [None, "", "metric", "RegionUpper"])
+@pytest.mark.parametrize("aliases", [{}, {"dimensions": None}, {"groupby": None}])
+async def test_saved_bullet_sql_hierarchy_update_with_explicit_sort(
+ metric_change: bool, sort_target: str | None, aliases: dict[str, Any]
+) -> None:
+ """Sort and metric updates preserve SQL dimensions through the native contract."""
+ dimension = {
+ "expressionType": "SQL",
+ "sqlExpression": "UPPER(Region)",
+ "label": "RegionUpper",
+ }
+ metric: dict[str, Any] = (
+ {"name": "Revenue", "aggregate": "SUM"}
+ if metric_change
+ else {"name": "SavedRevenue", "saved_metric": True}
+ )
+ metric_label = "SUM(Revenue)" if metric_change else "SavedRevenue"
+ config: dict[str, Any] = {"metric": metric, **aliases}
+ if sort_target is not None:
+ config["order_by"] = (
+ [{"column": metric_label if sort_target == "metric" else sort_target}]
+ if sort_target
+ else []
+ )
+ persisted = await _run_saved_bullet_update(
+ {"viz_type": "bullet", "metric": "SavedRevenue", "groupby": [dimension]},
+ config,
+ )
+ assert persisted["groupby"] == [dimension]
+ query = build_query_dicts_from_form_data(persisted, 7, "table")[0]
+ assert query["columns"] == [dimension]
+ if sort_target:
+ target = persisted["metric"] if sort_target == "metric" else dimension
+ assert persisted["orderby"] == query["orderby"] == [[target, False]]
+ elif sort_target == "":
+ assert persisted["orderby"] == []
+ assert not query.get("orderby")
+
+
+@pytest.mark.parametrize("dimension_field", ["dimensions", "groupby"])
+@pytest.mark.parametrize(
+ "dimension",
+ [
+ {
+ "expressionType": "SQL",
+ "sqlExpression": "UPPER(Region)",
+ "label": "RegionUpper",
+ },
+ {"sql_expression": "UPPER(Region)", "label": "RegionUpper"},
+ ],
+)
+def test_bullet_update_rejects_caller_sql_dimension_with_metric_sort(
+ dimension_field: str,
+ dimension: dict[str, str],
+) -> None:
+ """Providing a sort does not make a caller-authored SQL dimension acceptable."""
+ with pytest.raises(ValidationError):
+ UpdateChartRequest(
+ identifier=9,
+ config={
+ "chart_type": "bullet",
+ "metric": {"name": "SavedRevenue", "saved_metric": True},
+ dimension_field: [dimension],
+ "order_by": [{"column": "SavedRevenue"}],
+ },
+ )
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("dataset_id", [None])
+@pytest.mark.parametrize("expression_type", ["SIMPLE", "SQL"])
+async def test_saved_bullet_metric_sort_rebinds_same_label_expression(
+ dataset_id: int | None, expression_type: str
+) -> None:
+ """An inherited output sort must execute the replacement metric expression."""
+ old_metric = {
+ "expressionType": expression_type,
+ "label": "Amount",
+ **(
+ {"column": {"column_name": "OldRevenue"}, "aggregate": "SUM"}
+ if expression_type == "SIMPLE"
+ else {"sqlExpression": "SUM(OldRevenue)"}
+ ),
+ }
+ persisted = await _run_saved_bullet_update(
+ {
+ "viz_type": "bullet",
+ "metric": old_metric,
+ "groupby": ["Region"],
+ "orderby": [[old_metric, False]],
+ },
+ {"metric": {"name": "Revenue", "aggregate": "SUM", "label": "Amount"}},
+ dataset_id=dataset_id,
+ )
+ assert persisted["orderby"] == [[persisted["metric"], False]]
+ query = build_query_dicts_from_form_data(persisted, 7, "table")[0]
+ assert query["orderby"] == [[persisted["metric"], False]]
+
+
+def test_bullet_native_hierarchy_still_validates_against_dataset() -> None:
+ """Excluding inherited roles from typed validation cannot hide missing columns."""
+ from superset.mcp_service.chart.compile import validate_and_compile
+
+ config = BulletChartConfig(metric=_simple_metric(), show_labels=True)
+ form_data = {**map_bullet_config(config), "groupby": ["MissingRegion"]}
+ validated = validate_merged_bullet_form_data(form_data, config)
+ assert validated is not None
+ result = validate_and_compile(
+ validated, form_data, _orm_dataset(), run_compile_check=False
+ )
+ assert not result.success
+ assert result.error_obj is not None
+ assert "MissingRegion" in result.error_obj.message
+
+
+def test_bullet_native_metadata_and_dimensions_do_not_weaken_typed_authoring() -> None:
+ """Native-only state cannot be newly supplied through the typed config surface."""
+ with pytest.raises(ValidationError, match="url_params"):
... 3838 lines suppressed ...