From f6106cd26f1b2da27e2157dc0034cf9933ef68bc Mon Sep 17 00:00:00 2001 From: Alexandru Soare <37236580+alexandrusoare@users.noreply.github.com> Date: Fri, 13 Mar 2026 15:24:56 +0200 Subject: [PATCH 01/11] fix(timeshiftcolor): Time shift color to match the original color (#38473) --- .../src/operators/utils/timeOffset.ts | 12 ++- .../test/operators/utils/timeOffset.test.ts | 92 ++++++++++++++++++- .../src/Timeseries/transformProps.ts | 17 +++- 3 files changed, 114 insertions(+), 7 deletions(-) diff --git a/superset-frontend/packages/superset-ui-chart-controls/src/operators/utils/timeOffset.ts b/superset-frontend/packages/superset-ui-chart-controls/src/operators/utils/timeOffset.ts index 852b4eb1ab83..9aae5445bc8e 100644 --- a/superset-frontend/packages/superset-ui-chart-controls/src/operators/utils/timeOffset.ts +++ b/superset-frontend/packages/superset-ui-chart-controls/src/operators/utils/timeOffset.ts @@ -28,7 +28,9 @@ export const getTimeOffset = ( // offset is represented as , group by list series.name.includes(`${timeOffset},`) || // offset is represented as __ - series.name.includes(`__${timeOffset}`), + series.name.includes(`__${timeOffset}`) || + // offset is represented as , + series.name.includes(`, ${timeOffset}`), ); export const hasTimeOffset = ( @@ -45,10 +47,14 @@ export const getOriginalSeries = ( ): string => { let result = seriesName; timeCompare.forEach(compare => { - // offset is represented as , group by list + // offset in the middle: , , + result = result.replace(`, ${compare},`, ','); + // offset at start: , result = result.replace(`${compare},`, ''); - // offset is represented as __ + // offset with double underscore: __ result = result.replace(`__${compare}`, ''); + // offset at end: , + result = result.replace(`, ${compare}`, ''); }); return result.trim(); }; diff --git a/superset-frontend/packages/superset-ui-chart-controls/test/operators/utils/timeOffset.test.ts b/superset-frontend/packages/superset-ui-chart-controls/test/operators/utils/timeOffset.test.ts index bee8c5bbbd26..bec42af6c9bd 100644 --- a/superset-frontend/packages/superset-ui-chart-controls/test/operators/utils/timeOffset.test.ts +++ b/superset-frontend/packages/superset-ui-chart-controls/test/operators/utils/timeOffset.test.ts @@ -16,15 +16,101 @@ * specific language governing permissions and limitations * under the License. */ -import { getOriginalSeries } from '@superset-ui/chart-controls'; +import { + getOriginalSeries, + getTimeOffset, + hasTimeOffset, +} from '@superset-ui/chart-controls'; -test('returns the series name when time compare is empty', () => { +test('getOriginalSeries returns the series name when time compare is empty', () => { const seriesName = 'sum'; expect(getOriginalSeries(seriesName, [])).toEqual(seriesName); }); -test('returns the original series name', () => { +test('getOriginalSeries returns the original series name with __ pattern', () => { const seriesName = 'sum__1_month_ago'; const timeCompare = ['1_month_ago']; expect(getOriginalSeries(seriesName, timeCompare)).toEqual('sum'); }); + +test('getOriginalSeries returns the original series name with , pattern', () => { + const seriesName = '1 year ago, groupby_value'; + const timeCompare = ['1 year ago']; + expect(getOriginalSeries(seriesName, timeCompare)).toEqual('groupby_value'); +}); + +test('getOriginalSeries returns the original series name with , pattern', () => { + const seriesName = 'AVG(price_each), 1 year ago'; + const timeCompare = ['1 year ago']; + expect(getOriginalSeries(seriesName, timeCompare)).toEqual('AVG(price_each)'); +}); + +test('getOriginalSeries handles multiple time compares', () => { + const seriesName = 'count, 1 year ago'; + const timeCompare = ['1 month ago', '1 year ago']; + expect(getOriginalSeries(seriesName, timeCompare)).toEqual('count'); +}); + +test('getOriginalSeries strips offset in the middle with dimension', () => { + const seriesName = 'SUM(sales), 28 days ago, Medium'; + const timeCompare = ['28 days ago']; + expect(getOriginalSeries(seriesName, timeCompare)).toEqual( + 'SUM(sales), Medium', + ); +}); + +test('getOriginalSeries strips offset in the middle with multiple dimensions', () => { + const seriesName = 'SUM(sales), 1 year ago, Medium, 11'; + const timeCompare = ['1 year ago']; + expect(getOriginalSeries(seriesName, timeCompare)).toEqual( + 'SUM(sales), Medium, 11', + ); +}); + +test('getTimeOffset returns undefined when no time offset pattern matches', () => { + const series = { name: 'count' }; + const timeCompare = ['1 year ago']; + expect(getTimeOffset(series, timeCompare)).toBeUndefined(); +}); + +test('getTimeOffset detects __ pattern', () => { + const series = { name: 'count__1 year ago' }; + const timeCompare = ['1 year ago']; + expect(getTimeOffset(series, timeCompare)).toEqual('1 year ago'); +}); + +test('getTimeOffset detects , pattern', () => { + const series = { name: '1 year ago, groupby_value' }; + const timeCompare = ['1 year ago']; + expect(getTimeOffset(series, timeCompare)).toEqual('1 year ago'); +}); + +test('getTimeOffset detects , pattern', () => { + const series = { name: 'AVG(price_each), 1 year ago' }; + const timeCompare = ['1 year ago']; + expect(getTimeOffset(series, timeCompare)).toEqual('1 year ago'); +}); + +test('getTimeOffset detects , , pattern (offset in middle)', () => { + const series = { name: 'SUM(sales), 28 days ago, Medium' }; + const timeCompare = ['28 days ago']; + expect(getTimeOffset(series, timeCompare)).toEqual('28 days ago'); +}); + +test('hasTimeOffset returns false for original series', () => { + const series = { name: 'count' }; + const timeCompare = ['1 year ago']; + expect(hasTimeOffset(series, timeCompare)).toBe(false); +}); + +test('hasTimeOffset returns true for derived series with , pattern', () => { + const series = { name: 'AVG(price_each), 1 year ago' }; + const timeCompare = ['1 year ago']; + expect(hasTimeOffset(series, timeCompare)).toBe(true); +}); + +test('hasTimeOffset returns false when series name is not a string', () => { + const series = { name: 123 }; + const timeCompare = ['1 year ago']; + expect(hasTimeOffset(series, timeCompare)).toBe(false); +}); diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts index 6eff01ca80be..f7d90cc3f5af 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts @@ -336,6 +336,7 @@ export default function transformProps( chartProps.rawFormData, seriesName, ); + const lineStyle: LineStyleOption = {}; if (derivedSeries) { // Get the time offset for this series to assign different dash patterns @@ -370,7 +371,21 @@ export default function transformProps( let colorScaleKey = getOriginalSeries(seriesName, array); - // If series name exactly matches a time offset (single metric case), + // When there's a single metric with dimensions, the backend replaces the metric + // with the time offset in derived series (e.g., "28 days ago, Medium" instead of + // "SUM(sales), 28 days ago, Medium"). To match colors, strip the metric label + // from original series so both produce the same key (e.g., "Medium"). + if ( + groupby && + groupby.length > 0 && + array.length > 0 && + metrics?.length === 1 + ) { + const metricLabel = getMetricLabel(metrics[0]); + colorScaleKey = colorScaleKey.replace(`${metricLabel}, `, ''); + } + + // If series name exactly matches a time offset (single metric case, no dimensions), // find the original series for color matching if (derivedSeries && array.includes(seriesName)) { const originalSeries = rawSeries.find( From ca2d26a1e2f22ff815f33dc2f6e83096ec9757a0 Mon Sep 17 00:00:00 2001 From: amaannawab923 Date: Fri, 13 Mar 2026 19:42:14 +0530 Subject: [PATCH 02/11] fix(ag-grid-table): fix AND filter conditions not applied (#38369) --- .../src/AgGridTableChart.tsx | 3 + .../src/buildQuery.ts | 80 +++++- .../src/transformProps.ts | 38 ++- .../plugin-chart-ag-grid-table/src/types.ts | 1 + .../test/buildQuery.test.ts | 254 ++++++++++++++++++ 5 files changed, 369 insertions(+), 7 deletions(-) diff --git a/superset-frontend/plugins/plugin-chart-ag-grid-table/src/AgGridTableChart.tsx b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/AgGridTableChart.tsx index b4eae18de41c..0673bdd483ab 100644 --- a/superset-frontend/plugins/plugin-chart-ag-grid-table/src/AgGridTableChart.tsx +++ b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/AgGridTableChart.tsx @@ -85,6 +85,7 @@ export default function TableChart( width, onChartStateChange, chartState, + metricSqlExpressions, } = props; const [searchOptions, setSearchOptions] = useState([]); @@ -187,6 +188,7 @@ export default function TableChart( lastFilteredColumn: completeFilterState.lastFilteredColumn, lastFilteredInputPosition: completeFilterState.inputPosition, currentPage: 0, // Reset to first page when filtering + metricSqlExpressions, }; updateTableOwnState(setDataMask, modifiedOwnState); @@ -197,6 +199,7 @@ export default function TableChart( serverPaginationData, onChartStateChange, chartState, + metricSqlExpressions, ], ); diff --git a/superset-frontend/plugins/plugin-chart-ag-grid-table/src/buildQuery.ts b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/buildQuery.ts index 8122c86f825c..3d04b6c1d1cb 100644 --- a/superset-frontend/plugins/plugin-chart-ag-grid-table/src/buildQuery.ts +++ b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/buildQuery.ts @@ -482,12 +482,77 @@ const buildQuery: BuildQuery = ( }; } - // Add AG Grid complex WHERE clause from ownState (non-metric filters) + // Map metric/column labels to SQL expressions for WHERE/HAVING resolution + const sqlExpressionMap: Record = {}; + (metrics || []).forEach((m: QueryFormMetric) => { + if (typeof m === 'object' && 'expressionType' in m) { + const label = getMetricLabel(m); + if (m.expressionType === 'SQL' && m.sqlExpression) { + sqlExpressionMap[label] = m.sqlExpression; + } else if ( + m.expressionType === 'SIMPLE' && + m.aggregate && + m.column?.column_name + ) { + sqlExpressionMap[label] = `${m.aggregate}(${m.column.column_name})`; + } + } + }); + // Map dimension columns with custom SQL expressions + (columns || []).forEach((col: QueryFormColumn) => { + if (typeof col === 'object' && 'sqlExpression' in col) { + const label = getColumnLabel(col); + if (col.sqlExpression) { + sqlExpressionMap[label] = col.sqlExpression; + } + } + }); + // Merge datasource-level saved metrics and calculated columns + if (ownState.metricSqlExpressions) { + Object.entries( + ownState.metricSqlExpressions as Record, + ).forEach(([label, expression]) => { + if (!sqlExpressionMap[label]) { + sqlExpressionMap[label] = expression; + } + }); + } + + const resolveLabelsToSQL = (clause: string): string => { + let resolved = clause; + // Sort by label length descending to prevent substring false positives + const sortedEntries = Object.entries(sqlExpressionMap).sort( + ([a], [b]) => b.length - a.length, + ); + sortedEntries.forEach(([label, expression]) => { + if (resolved.includes(label)) { + const escapedLabel = label.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + // Wrap complex expressions in parentheses for valid SQL + const isExpression = + expression.includes('(') || + expression.toUpperCase().includes('CASE') || + expression.includes('\n'); + const wrappedExpression = isExpression + ? `(${expression})` + : expression; + resolved = resolved.replace( + new RegExp(`\\b${escapedLabel}\\b`, 'g'), + wrappedExpression, + ); + } + }); + return resolved; + }; + + // Resolve and apply AG Grid WHERE clause if (ownState.agGridComplexWhere && ownState.agGridComplexWhere.trim()) { + const resolvedWhere = resolveLabelsToSQL(ownState.agGridComplexWhere); + (ownState as Record).agGridComplexWhere = + resolvedWhere; const existingWhere = queryObject.extras?.where; const combinedWhere = existingWhere - ? `${existingWhere} AND ${ownState.agGridComplexWhere}` - : ownState.agGridComplexWhere; + ? `${existingWhere} AND ${resolvedWhere}` + : resolvedWhere; queryObject = { ...queryObject, @@ -498,12 +563,15 @@ const buildQuery: BuildQuery = ( }; } - // Add AG Grid HAVING clause from ownState (metric filters only) + // Resolve and apply AG Grid HAVING clause if (ownState.agGridHavingClause && ownState.agGridHavingClause.trim()) { + const resolvedHaving = resolveLabelsToSQL(ownState.agGridHavingClause); + (ownState as Record).agGridHavingClause = + resolvedHaving; const existingHaving = queryObject.extras?.having; const combinedHaving = existingHaving - ? `${existingHaving} AND ${ownState.agGridHavingClause}` - : ownState.agGridHavingClause; + ? `${existingHaving} AND ${resolvedHaving}` + : resolvedHaving; queryObject = { ...queryObject, diff --git a/superset-frontend/plugins/plugin-chart-ag-grid-table/src/transformProps.ts b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/transformProps.ts index be174cb5a21b..c27bd856d890 100644 --- a/superset-frontend/plugins/plugin-chart-ag-grid-table/src/transformProps.ts +++ b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/transformProps.ts @@ -34,6 +34,8 @@ import { SMART_DATE_ID, TimeFormats, TimeFormatter, + AgGridChartState, + AgGridFilterModel, } from '@superset-ui/core'; import { GenericDataType } from '@apache-superset/core/common'; import { isEmpty, isEqual, merge } from 'lodash'; @@ -456,6 +458,9 @@ const getPageSize = ( return numRecords * numColumns > 5000 ? 200 : 0; }; +// Tracks slice_ids that have already applied their saved chartState filter on mount +const savedFilterAppliedSet = new Set(); + const transformProps = ( chartProps: TableChartProps, ): AgGridTableChartTransformedProps => { @@ -710,6 +715,36 @@ const transformProps = ( : totalQuery?.data[0] : undefined; + // Map saved metric/calculated column labels to their SQL expressions for filter resolution + const metricSqlExpressions: Record = {}; + chartProps.datasource.metrics.forEach(metric => { + if (metric.metric_name && metric.expression) { + metricSqlExpressions[metric.metric_name] = metric.expression; + } + }); + chartProps.datasource.columns.forEach(col => { + if (col.column_name && col.expression) { + metricSqlExpressions[col.column_name] = col.expression; + if (col.verbose_name && col.verbose_name !== col.column_name) { + metricSqlExpressions[col.verbose_name] = col.expression; + } + } + }); + + // Strip saved filter from chartState after initial application to prevent re-injection + let chartState = serverPaginationData?.chartState as + | AgGridChartState + | undefined; + const chartStateHasFilter = !!( + chartState?.filterModel && Object.keys(chartState.filterModel).length > 0 + ); + + if (chartStateHasFilter && savedFilterAppliedSet.has(slice_id)) { + chartState = { ...chartState!, filterModel: {} as AgGridFilterModel }; + } else if (chartStateHasFilter) { + savedFilterAppliedSet.add(slice_id); + } + return { height, width, @@ -742,7 +777,8 @@ const transformProps = ( basicColorColumnFormatters, basicColorFormatters, formData, - chartState: serverPaginationData?.chartState, + metricSqlExpressions, + chartState, onChartStateChange, }; }; diff --git a/superset-frontend/plugins/plugin-chart-ag-grid-table/src/types.ts b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/types.ts index d97f36b267fa..e3d6bf070793 100644 --- a/superset-frontend/plugins/plugin-chart-ag-grid-table/src/types.ts +++ b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/types.ts @@ -128,6 +128,7 @@ export interface AgGridTableChartTransformedProps< basicColorFormatters?: { [Key: string]: BasicColorFormatterType }[]; basicColorColumnFormatters?: { [Key: string]: BasicColorFormatterType }[]; formData: TableChartFormData; + metricSqlExpressions: Record; onChartStateChange?: (chartState: JsonObject) => void; chartState?: AgGridChartState; } diff --git a/superset-frontend/plugins/plugin-chart-ag-grid-table/test/buildQuery.test.ts b/superset-frontend/plugins/plugin-chart-ag-grid-table/test/buildQuery.test.ts index 0778d6fde58b..636fc190fcce 100644 --- a/superset-frontend/plugins/plugin-chart-ag-grid-table/test/buildQuery.test.ts +++ b/superset-frontend/plugins/plugin-chart-ag-grid-table/test/buildQuery.test.ts @@ -1090,4 +1090,258 @@ describe('plugin-chart-ag-grid-table', () => { expect(query.metrics).toEqual([]); }); }); + + describe('buildQuery - label-to-SQL resolution in WHERE/HAVING', () => { + test('should resolve inline SQL metric labels in WHERE clause', () => { + const query = buildQuery( + { + ...basicFormData, + server_pagination: true, + metrics: [ + { + expressionType: 'SQL', + sqlExpression: 'SUM(revenue)', + label: 'Total Revenue', + }, + ], + }, + { + ownState: { + agGridComplexWhere: 'Total Revenue > 1000', + }, + }, + ).queries[0]; + + expect(query.extras?.where).toBe('(SUM(revenue)) > 1000'); + }); + + test('should resolve SIMPLE metric labels in HAVING clause', () => { + const query = buildQuery( + { + ...basicFormData, + server_pagination: true, + metrics: [ + { + expressionType: 'SIMPLE', + aggregate: 'SUM', + column: { column_name: 'revenue' }, + label: 'Total Revenue', + }, + ], + }, + { + ownState: { + agGridHavingClause: 'Total Revenue > 1000', + }, + }, + ).queries[0]; + + expect(query.extras?.having).toBe('(SUM(revenue)) > 1000'); + }); + + test('should resolve adhoc column SQL expressions in WHERE clause', () => { + const adhocColumn = createAdhocColumn('UPPER(city)', 'City Upper'); + + const query = buildQuery( + { + ...basicFormData, + server_pagination: true, + groupby: [adhocColumn], + }, + { + ownState: { + agGridComplexWhere: "City Upper = 'NEW YORK'", + }, + }, + ).queries[0]; + + expect(query.extras?.where).toContain('UPPER(city)'); + }); + + test('should wrap CASE expressions in parentheses', () => { + const query = buildQuery( + { + ...basicFormData, + server_pagination: true, + }, + { + ownState: { + agGridComplexWhere: "degree_level = 'High'", + metricSqlExpressions: { + degree_level: + "CASE WHEN degree = 'PhD' THEN 'High' ELSE 'Low' END", + }, + }, + }, + ).queries[0]; + + expect(query.extras?.where).toBe( + "(CASE WHEN degree = 'PhD' THEN 'High' ELSE 'Low' END) = 'High'", + ); + }); + + test('should wrap aggregate expressions in parentheses', () => { + const query = buildQuery( + { + ...basicFormData, + server_pagination: true, + }, + { + ownState: { + agGridHavingClause: 'total_count > 100', + metricSqlExpressions: { + total_count: 'COUNT(*)', + }, + }, + }, + ).queries[0]; + + expect(query.extras?.having).toBe('(COUNT(*)) > 100'); + }); + + test('should quote simple column names without parentheses', () => { + const query = buildQuery( + { + ...basicFormData, + server_pagination: true, + }, + { + ownState: { + agGridComplexWhere: "status = 'active'", + metricSqlExpressions: { + status: 'user_status', + }, + }, + }, + ).queries[0]; + + expect(query.extras?.where).toBe('"user_status" = \'active\''); + }); + + test('should resolve longer labels before shorter ones', () => { + const query = buildQuery( + { + ...basicFormData, + server_pagination: true, + metrics: [ + { + expressionType: 'SQL', + sqlExpression: 'COUNT(*)', + label: 'count', + }, + { + expressionType: 'SQL', + sqlExpression: 'COUNT(DISTINCT id)', + label: 'count_distinct', + }, + ], + }, + { + ownState: { + agGridHavingClause: 'count_distinct > 5 AND count > 10', + }, + }, + ).queries[0]; + + expect(query.extras?.having).toContain('(COUNT(DISTINCT id)) > 5'); + expect(query.extras?.having).toContain('(COUNT(*)) > 10'); + }); + + test('should prefer query-level expressions over datasource-level', () => { + const query = buildQuery( + { + ...basicFormData, + server_pagination: true, + metrics: [ + { + expressionType: 'SQL', + sqlExpression: 'SUM(amount)', + label: 'total', + }, + ], + }, + { + ownState: { + agGridHavingClause: 'total > 500', + metricSqlExpressions: { + total: 'SUM(old_amount)', + }, + }, + }, + ).queries[0]; + + // Query-level SUM(amount) should win over datasource-level SUM(old_amount) + expect(query.extras?.having).toBe('(SUM(amount)) > 500'); + }); + + test('should not modify clause when no labels match', () => { + const query = buildQuery( + { + ...basicFormData, + server_pagination: true, + }, + { + ownState: { + agGridComplexWhere: 'physical_column > 10', + }, + }, + ).queries[0]; + + expect(query.extras?.where).toBe('physical_column > 10'); + }); + + test('should resolve labels in both WHERE and HAVING simultaneously', () => { + const query = buildQuery( + { + ...basicFormData, + server_pagination: true, + metrics: [ + { + expressionType: 'SQL', + sqlExpression: 'SUM(sales)', + label: 'Total Sales', + }, + ], + }, + { + ownState: { + agGridComplexWhere: "region = 'West'", + agGridHavingClause: 'Total Sales > 1000', + metricSqlExpressions: { + region: "CASE WHEN area = 'W' THEN 'West' ELSE 'East' END", + }, + }, + }, + ).queries[0]; + + expect(query.extras?.where).toContain('CASE WHEN'); + expect(query.extras?.having).toBe('(SUM(sales)) > 1000'); + }); + + test('should not resolve labels when server pagination is disabled', () => { + const query = buildQuery( + { + ...basicFormData, + server_pagination: false, + metrics: [ + { + expressionType: 'SQL', + sqlExpression: 'SUM(revenue)', + label: 'Total Revenue', + }, + ], + }, + { + ownState: { + agGridComplexWhere: 'Total Revenue > 1000', + metricSqlExpressions: { + some_col: 'COUNT(*)', + }, + }, + }, + ).queries[0]; + + expect(query.extras?.where || undefined).toBeUndefined(); + }); + }); }); From ba7271b4d89c6c8837cca39ced93b1bd93b6cc92 Mon Sep 17 00:00:00 2001 From: Rafael Benitez Date: Fri, 13 Mar 2026 12:02:03 -0300 Subject: [PATCH 03/11] fix(world-map): add fallback fill color when colorFn returns null (#38602) Co-authored-by: Claude Opus 4.6 --- .../src/WorldMap.ts | 14 +++-- .../test/WorldMap.test.ts | 53 +++++++++++++++++++ 2 files changed, 63 insertions(+), 4 deletions(-) diff --git a/superset-frontend/plugins/legacy-plugin-chart-world-map/src/WorldMap.ts b/superset-frontend/plugins/legacy-plugin-chart-world-map/src/WorldMap.ts index e6c7bc2679db..9388c379dd19 100644 --- a/superset-frontend/plugins/legacy-plugin-chart-world-map/src/WorldMap.ts +++ b/superset-frontend/plugins/legacy-plugin-chart-world-map/src/WorldMap.ts @@ -150,14 +150,20 @@ function WorldMap(element: HTMLElement, props: WorldMapProps): void { fillColor: colorFn(d.name, sliceId), })); } else { - colorFn = getSequentialSchemeRegistry() - .get(linearColorScheme) - .createLinearScale(d3Extent(filteredData, d => d.m1)); + const rawExtents = d3Extent(filteredData, d => d.m1); + const extents: [number, number] = + rawExtents[0] != null && rawExtents[1] != null + ? [rawExtents[0], rawExtents[1]] + : [0, 1]; + const colorSchemeObj = getSequentialSchemeRegistry().get(linearColorScheme); + colorFn = colorSchemeObj + ? colorSchemeObj.createLinearScale(extents) + : () => theme.colorBorder; processedData = filteredData.map(d => ({ ...d, radius: radiusScale(Math.sqrt(d.m2)), - fillColor: colorFn(d.m1), + fillColor: colorFn(d.m1) ?? theme.colorBorder, })); } diff --git a/superset-frontend/plugins/legacy-plugin-chart-world-map/test/WorldMap.test.ts b/superset-frontend/plugins/legacy-plugin-chart-world-map/test/WorldMap.test.ts index 5a53aab9e821..4476bcefe851 100644 --- a/superset-frontend/plugins/legacy-plugin-chart-world-map/test/WorldMap.test.ts +++ b/superset-frontend/plugins/legacy-plugin-chart-world-map/test/WorldMap.test.ts @@ -489,6 +489,59 @@ test('popupTemplate returns tooltip HTML when country data exists', () => { expect(tooltipHtml).toContain('hoverinfo'); }); +test('assigns fill colors from sequential scheme when colorBy is metric', () => { + WorldMap(container, { + ...baseProps, + colorBy: ColorBy.Metric, + }); + + const data = lastDatamapConfig?.data as Record< + string, + WorldMapDataEntry & { fillColor: string } + >; + expect(data).toHaveProperty('USA'); + expect(data).toHaveProperty('CAN'); + expect(data.USA).toMatchObject({ + country: 'USA', + name: 'United States', + m1: 100, + }); + // fillColor should be a valid color string from the sequential scale + expect(data.USA.fillColor).toMatch(/^(#|rgb)/); + expect(data.CAN.fillColor).toMatch(/^(#|rgb)/); +}); + +test('falls back to theme.colorBorder when metric values are null', () => { + WorldMap(container, { + ...baseProps, + colorBy: ColorBy.Metric, + data: [ + { + country: 'USA', + name: 'United States', + m1: null as unknown as number, + m2: 200, + code: 'US', + latitude: 37.0902, + longitude: -95.7129, + }, + ], + } as any); + + const data = lastDatamapConfig?.data as Record; + expect(data.USA.fillColor).toBe('#e0e0e0'); +}); + +test('does not throw with empty data and metric coloring', () => { + expect(() => { + WorldMap(container, { + ...baseProps, + colorBy: ColorBy.Metric, + data: [], + }); + }).not.toThrow(); +}); + test('popupTemplate handles null/undefined country data gracefully', () => { WorldMap(container, baseProps); From ba7d7dcec0d095896aaa1c2509664e573150272a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 13 Mar 2026 22:06:21 +0700 Subject: [PATCH 04/11] chore(deps): bump react-syntax-highlighter from 16.1.0 to 16.1.1 in /superset-frontend (#38619) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- superset-frontend/package-lock.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/superset-frontend/package-lock.json b/superset-frontend/package-lock.json index cced90352494..061e545651f4 100644 --- a/superset-frontend/package-lock.json +++ b/superset-frontend/package-lock.json @@ -51269,7 +51269,7 @@ "react-js-cron": "^5.2.0", "react-markdown": "^8.0.7", "react-resize-detector": "^7.1.2", - "react-syntax-highlighter": "^16.1.1", + "react-syntax-highlighter": "^16.1.0", "react-ultimate-pagination": "^1.3.2", "regenerator-runtime": "^0.14.1", "rehype-raw": "^7.0.0", From 242636b36b1b78272161e25879d8eff186f2db47 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 13 Mar 2026 22:06:43 +0700 Subject: [PATCH 05/11] chore(deps): bump baseline-browser-mapping from 2.10.0 to 2.10.7 in /docs (#38622) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- docs/package.json | 2 +- docs/yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/package.json b/docs/package.json index 342ab607ed72..50eff6532f50 100644 --- a/docs/package.json +++ b/docs/package.json @@ -69,7 +69,7 @@ "@superset-ui/core": "^0.20.4", "@swc/core": "^1.15.17", "antd": "^6.3.2", - "baseline-browser-mapping": "^2.10.0", + "baseline-browser-mapping": "^2.10.7", "caniuse-lite": "^1.0.30001778", "docusaurus-plugin-openapi-docs": "^4.6.0", "docusaurus-theme-openapi-docs": "^4.6.0", diff --git a/docs/yarn.lock b/docs/yarn.lock index 8e90a7b93f43..edb44272dd22 100644 --- a/docs/yarn.lock +++ b/docs/yarn.lock @@ -5961,10 +5961,10 @@ base64-js@^1.3.1, base64-js@^1.5.1: resolved "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz" integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== -baseline-browser-mapping@^2.10.0, baseline-browser-mapping@^2.9.0, baseline-browser-mapping@^2.9.19: - version "2.10.0" - resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz#5b09935025bf8a80e29130251e337c6a7fc8cbb9" - integrity sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA== +baseline-browser-mapping@^2.10.7, baseline-browser-mapping@^2.9.0, baseline-browser-mapping@^2.9.19: + version "2.10.7" + resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.7.tgz#2c017adffe4f7bbe93c2e55526cc1869d36f588c" + integrity sha512-1ghYO3HnxGec0TCGBXiDLVns4eCSx4zJpxnHrlqFQajmhfKMQBzUGDdkMK7fUW7PTHTeLf+j87aTuKuuwWzMGw== batch@0.6.1: version "0.6.1" From f4a57a13bc9a36cdc46326c42c5b618fa9f2db0d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 13 Mar 2026 22:07:09 +0700 Subject: [PATCH 06/11] chore(deps): bump dompurify from 3.3.2 to 3.3.3 in /superset-frontend (#38592) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Đỗ Trọng Hải <41283691+hainenber@users.noreply.github.com> --- superset-frontend/package-lock.json | 22 +++++++------------ .../packages/superset-ui-core/package.json | 2 +- .../legacy-preset-chart-nvd3/package.json | 2 +- 3 files changed, 10 insertions(+), 16 deletions(-) diff --git a/superset-frontend/package-lock.json b/superset-frontend/package-lock.json index 061e545651f4..869e3f07d4df 100644 --- a/superset-frontend/package-lock.json +++ b/superset-frontend/package-lock.json @@ -51255,7 +51255,7 @@ "d3-time": "^3.1.0", "d3-time-format": "^4.1.0", "dayjs": "^1.11.19", - "dompurify": "^3.3.2", + "dompurify": "^3.3.3", "fetch-retry": "^6.0.0", "handlebars": "^4.7.8", "jed": "^1.1.1", @@ -51358,13 +51358,10 @@ } }, "packages/superset-ui-core/node_modules/dompurify": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.2.tgz", - "integrity": "sha512-6obghkliLdmKa56xdbLOpUZ43pAR6xFy1uOrxBaIDjT+yaRuuybLjGS9eVBoSR/UPU5fq3OXClEHLJNGvbxKpQ==", + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz", + "integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==", "license": "(MPL-2.0 OR Apache-2.0)", - "engines": { - "node": ">=20" - }, "optionalDependencies": { "@types/trusted-types": "^2.0.7" } @@ -52625,7 +52622,7 @@ "dependencies": { "d3": "^3.5.17", "d3-tip": "^0.9.1", - "dompurify": "^3.3.2", + "dompurify": "^3.3.3", "fast-safe-stringify": "^2.1.1", "lodash": "^4.17.23", "nvd3-fork": "^2.0.5", @@ -52641,13 +52638,10 @@ } }, "plugins/legacy-preset-chart-nvd3/node_modules/dompurify": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.2.tgz", - "integrity": "sha512-6obghkliLdmKa56xdbLOpUZ43pAR6xFy1uOrxBaIDjT+yaRuuybLjGS9eVBoSR/UPU5fq3OXClEHLJNGvbxKpQ==", + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz", + "integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==", "license": "(MPL-2.0 OR Apache-2.0)", - "engines": { - "node": ">=20" - }, "optionalDependencies": { "@types/trusted-types": "^2.0.7" } diff --git a/superset-frontend/packages/superset-ui-core/package.json b/superset-frontend/packages/superset-ui-core/package.json index 74bf4ad38076..3d4855ca3d04 100644 --- a/superset-frontend/packages/superset-ui-core/package.json +++ b/superset-frontend/packages/superset-ui-core/package.json @@ -41,7 +41,7 @@ "d3-scale": "^4.0.2", "d3-time": "^3.1.0", "d3-time-format": "^4.1.0", - "dompurify": "^3.3.2", + "dompurify": "^3.3.3", "fetch-retry": "^6.0.0", "handlebars": "^4.7.8", "jed": "^1.1.1", diff --git a/superset-frontend/plugins/legacy-preset-chart-nvd3/package.json b/superset-frontend/plugins/legacy-preset-chart-nvd3/package.json index c9bcd0a4d414..aa4526ebb7a4 100644 --- a/superset-frontend/plugins/legacy-preset-chart-nvd3/package.json +++ b/superset-frontend/plugins/legacy-preset-chart-nvd3/package.json @@ -34,7 +34,7 @@ "fast-safe-stringify": "^2.1.1", "lodash": "^4.17.23", "nvd3-fork": "^2.0.5", - "dompurify": "^3.3.2", + "dompurify": "^3.3.3", "prop-types": "^15.8.1", "urijs": "^1.19.11" }, From b6c3b3ef46d9386f8e318458a243695da95ed3e4 Mon Sep 17 00:00:00 2001 From: Amin Ghadersohi Date: Fri, 13 Mar 2026 16:53:52 +0100 Subject: [PATCH 07/11] fix(mcp): return all statement results for multi-statement SQL queries (#38388) Co-authored-by: Claude Opus 4.6 --- superset/mcp_service/sql_lab/schemas.py | 26 ++++ .../mcp_service/sql_lab/tool/execute_sql.py | 85 ++++++++------ .../sql_lab/tool/test_execute_sql.py | 111 ++++++++++++++++++ 3 files changed, 187 insertions(+), 35 deletions(-) diff --git a/superset/mcp_service/sql_lab/schemas.py b/superset/mcp_service/sql_lab/schemas.py index 436e9330733c..e55ca53130f6 100644 --- a/superset/mcp_service/sql_lab/schemas.py +++ b/superset/mcp_service/sql_lab/schemas.py @@ -84,6 +84,15 @@ class ColumnInfo(BaseModel): is_nullable: bool | None = Field(None, description="Whether column allows NULL") +class StatementData(BaseModel): + """Row data and column metadata for a single SQL statement.""" + + rows: list[dict[str, Any]] = Field( + ..., description="Result rows as list of dictionaries" + ) + columns: list[ColumnInfo] = Field(..., description="Column metadata information") + + class StatementInfo(BaseModel): """Information about a single SQL statement execution.""" @@ -95,6 +104,14 @@ class StatementInfo(BaseModel): execution_time_ms: float | None = Field( None, description="Statement execution time in milliseconds" ) + data: StatementData | None = Field( + None, + description=( + "Row data and column metadata for this statement. " + "Present for data-bearing statements (e.g., SELECT), " + "absent for DML/DDL statements (e.g., SET, UPDATE)." + ), + ) class ExecuteSqlResponse(BaseModel): @@ -119,6 +136,15 @@ class ExecuteSqlResponse(BaseModel): statements: list[StatementInfo] | None = Field( None, description="Per-statement execution info (for multi-statement queries)" ) + multi_statement_warning: str | None = Field( + None, + description=( + "Warning when multiple data-bearing statements were executed. " + "The top-level rows/columns contain only the last " + "data-bearing statement's results. " + "Check each entry in the statements array for per-statement data." + ), + ) class OpenSqlLabRequest(BaseModel): diff --git a/superset/mcp_service/sql_lab/tool/execute_sql.py b/superset/mcp_service/sql_lab/tool/execute_sql.py index 42739ff75fa1..7f3164d88522 100644 --- a/superset/mcp_service/sql_lab/tool/execute_sql.py +++ b/superset/mcp_service/sql_lab/tool/execute_sql.py @@ -43,6 +43,7 @@ ColumnInfo, ExecuteSqlRequest, ExecuteSqlResponse, + StatementData, StatementInfo, ) from superset.mcp_service.utils.schema_utils import parse_request @@ -162,56 +163,69 @@ def _convert_to_response(result: QueryResult) -> ExecuteSqlResponse: error_type=result.status.value, ) - # Build statement info list - statements = [ - StatementInfo( - original_sql=stmt.original_sql, - executed_sql=stmt.executed_sql, - row_count=stmt.row_count, - execution_time_ms=stmt.execution_time_ms, + # Build statement info list, including per-statement row data + # for data-bearing statements (e.g., SELECT). + statements: list[StatementInfo] = [] + data_bearing_count = 0 + + for stmt in result.statements: + stmt_data: StatementData | None = None + if stmt.data is not None: + df = stmt.data + stmt_data = StatementData( + rows=df.to_dict(orient="records"), + columns=[ + ColumnInfo(name=col, type=str(df[col].dtype)) for col in df.columns + ], + ) + data_bearing_count += 1 + + statements.append( + StatementInfo( + original_sql=stmt.original_sql, + executed_sql=stmt.executed_sql, + row_count=stmt.row_count, + execution_time_ms=stmt.execution_time_ms, + data=stmt_data, + ) ) - for stmt in result.statements - ] - # Find the last statement with data (SELECT results). - # For single statements this is the same as first. - # For multi-statement queries (e.g., SET ...; SELECT ...) this skips - # non-data statements and returns the actual query results. + # Top-level rows/columns come from the last data-bearing statement + # for backward compatibility. rows: list[dict[str, Any]] | None = None columns: list[ColumnInfo] | None = None row_count: int | None = None affected_rows: int | None = None - data_stmt = None - for stmt in reversed(result.statements): + last_data_stmt = None + for stmt in reversed(statements): if stmt.data is not None: - data_stmt = stmt + last_data_stmt = stmt break - if data_stmt is not None and data_stmt.data is not None: - # SELECT query - convert DataFrame - import pandas as pd - - df = data_stmt.data - if not isinstance(df, pd.DataFrame): - logger.error( - "Expected DataFrame but got %s for statement data", - type(df).__name__, - ) - return ExecuteSqlResponse( - success=False, - error=f"Internal error: unexpected data type ({type(df).__name__})", - error_type="data_conversion_error", - statements=statements, - ) - rows = df.to_dict(orient="records") - columns = [ColumnInfo(name=col, type=str(df[col].dtype)) for col in df.columns] - row_count = len(df) + if last_data_stmt is not None and last_data_stmt.data is not None: + rows = last_data_stmt.data.rows + columns = last_data_stmt.data.columns + row_count = len(last_data_stmt.data.rows) elif result.statements: # DML-only query last_stmt = result.statements[-1] affected_rows = last_stmt.row_count + # Warn when multiple data-bearing statements exist so the LLM + # knows to inspect the statements array for all results. + multi_statement_warning: str | None = None + if data_bearing_count > 1: + multi_statement_warning = ( + f"This query contained {data_bearing_count} " + "data-bearing statements. " + "The top-level rows/columns contain only the " + "last data-bearing statement's results. " + "Check the 'data' field in each entry of the " + "'statements' array to see results from ALL " + "statements." + ) + return ExecuteSqlResponse( success=True, rows=rows, @@ -224,4 +238,5 @@ def _convert_to_response(result: QueryResult) -> ExecuteSqlResponse: else None ), statements=statements, + multi_statement_warning=multi_statement_warning, ) diff --git a/tests/unit_tests/mcp_service/sql_lab/tool/test_execute_sql.py b/tests/unit_tests/mcp_service/sql_lab/tool/test_execute_sql.py index 3576acd3d37e..764e1bf2cee0 100644 --- a/tests/unit_tests/mcp_service/sql_lab/tool/test_execute_sql.py +++ b/tests/unit_tests/mcp_service/sql_lab/tool/test_execute_sql.py @@ -530,6 +530,21 @@ async def test_execute_sql_multi_statement( assert data["rows"] == [{"b": 2}] assert data["row_count"] == 1 + # Per-statement data should be present for both statements + assert data["statements"][0]["data"] is not None + assert data["statements"][0]["data"]["rows"] == [{"a": 1}] + assert len(data["statements"][0]["data"]["columns"]) == 1 + assert data["statements"][0]["data"]["columns"][0]["name"] == "a" + + assert data["statements"][1]["data"] is not None + assert data["statements"][1]["data"]["rows"] == [{"b": 2}] + assert len(data["statements"][1]["data"]["columns"]) == 1 + assert data["statements"][1]["data"]["columns"][0]["name"] == "b" + + # Warning should be present for multi-data-bearing queries + assert data["multi_statement_warning"] is not None + assert "2 data-bearing statements" in data["multi_statement_warning"] + @patch("superset.security_manager") @patch("superset.db") @pytest.mark.asyncio @@ -609,6 +624,14 @@ async def test_execute_sql_multi_statement_set_then_select( assert "id" in column_names assert "amount" in column_names + # SET statement should have no data, SELECT should have data + assert data["statements"][0]["data"] is None + assert data["statements"][1]["data"] is not None + assert len(data["statements"][1]["data"]["rows"]) == 2 + + # No warning since only one data-bearing statement + assert data["multi_statement_warning"] is None + @patch("superset.security_manager") @patch("superset.db") @pytest.mark.asyncio @@ -664,6 +687,94 @@ async def test_execute_sql_multi_statement_all_dml( # affected_rows should come from the last statement assert data["affected_rows"] == 5 + # DML statements should have no per-statement data + assert data["statements"][0]["data"] is None + assert data["statements"][1]["data"] is None + + # No warning for DML-only queries + assert data["multi_statement_warning"] is None + + @patch("superset.security_manager") + @patch("superset.db") + @pytest.mark.asyncio + async def test_execute_sql_multi_statement_preserves_all_data( + self, mock_db, mock_security_manager, mcp_server + ) -> None: + """Test that multi-statement SQL returns per-statement data for ALL results. + + Regression test: previously, running two SELECT statements would only + return the last statement's rows in the top-level response and + completely lose the first statement's row data. + """ + mock_database = _mock_database() + mock_database.execute.return_value = QueryResult( + status=QueryStatus.SUCCESS, + statements=[ + StatementResult( + original_sql="SELECT COUNT(*) AS order_count FROM orders", + executed_sql="SELECT COUNT(*) AS order_count FROM orders", + data=pd.DataFrame([{"order_count": 42}]), + row_count=1, + execution_time_ms=5.0, + ), + StatementResult( + original_sql="SELECT SUM(revenue) AS total_revenue FROM orders", + executed_sql="SELECT SUM(revenue) AS total_revenue FROM orders", + data=pd.DataFrame([{"total_revenue": 12345.67}]), + row_count=1, + execution_time_ms=7.0, + ), + ], + query_id=None, + total_execution_time_ms=12.0, + is_cached=False, + ) + mock_db.session.query.return_value.filter_by.return_value.first.return_value = ( + mock_database + ) + mock_security_manager.can_access_database.return_value = True + + request = { + "database_id": 1, + "sql": ( + "SELECT COUNT(*) AS order_count FROM orders;" + " SELECT SUM(revenue) AS total_revenue FROM orders" + ), + "limit": 100, + } + + async with Client(mcp_server) as client: + result = await client.call_tool("execute_sql", {"request": request}) + + data = result.structured_content + assert data["success"] is True + + # Top-level rows/columns should be from the LAST data-bearing stmt + assert data["rows"] == [{"total_revenue": 12345.67}] + assert data["row_count"] == 1 + + # Both statements should have per-statement data + assert len(data["statements"]) == 2 + + # First statement's data is accessible + first_stmt = data["statements"][0] + assert first_stmt["data"] is not None + assert first_stmt["data"]["rows"] == [{"order_count": 42}] + assert len(first_stmt["data"]["columns"]) == 1 + assert first_stmt["data"]["columns"][0]["name"] == "order_count" + + # Second statement's data is accessible + second_stmt = data["statements"][1] + assert second_stmt["data"] is not None + assert second_stmt["data"]["rows"] == [{"total_revenue": 12345.67}] + assert len(second_stmt["data"]["columns"]) == 1 + assert second_stmt["data"]["columns"][0]["name"] == "total_revenue" + + # Warning should tell LLM to check statements array + assert data["multi_statement_warning"] is not None + assert "2 data-bearing statements" in data["multi_statement_warning"] + assert "statements" in data["multi_statement_warning"] + @pytest.mark.asyncio async def test_execute_sql_empty_query_validation(self, mcp_server): """Test validation of empty SQL query.""" From 97a66f7a6474f79be36e4cfd16b6f6df938c1e5b Mon Sep 17 00:00:00 2001 From: Amin Ghadersohi Date: Fri, 13 Mar 2026 18:06:11 +0100 Subject: [PATCH 08/11] feat(mcp): add BM25 tool search transform to reduce initial context size (#38562) Co-authored-by: Claude Opus 4.6 --- pyproject.toml | 2 +- requirements/development.txt | 61 ++-- superset/mcp_service/chart/schemas.py | 323 +++++------------- superset/mcp_service/common/cache_schemas.py | 35 +- superset/mcp_service/dashboard/schemas.py | 64 ++-- superset/mcp_service/flask_singleton.py | 70 ++-- superset/mcp_service/mcp_config.py | 34 ++ superset/mcp_service/server.py | 141 +++++++- superset/mcp_service/system/schemas.py | 100 ++---- .../mcp_service/test_mcp_tool_registration.py | 25 +- .../mcp_service/test_tool_search_transform.py | 173 ++++++++++ 11 files changed, 552 insertions(+), 476 deletions(-) create mode 100644 tests/unit_tests/mcp_service/test_tool_search_transform.py diff --git a/pyproject.toml b/pyproject.toml index 70feeeb4d3e2..12d5224cb38d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -144,7 +144,7 @@ solr = ["sqlalchemy-solr >= 0.2.0"] elasticsearch = ["elasticsearch-dbapi>=0.2.12, <0.3.0"] exasol = ["sqlalchemy-exasol >= 2.4.0, <3.0"] excel = ["xlrd>=1.2.0, <1.3"] -fastmcp = ["fastmcp==2.14.3"] +fastmcp = ["fastmcp>=3.1.0,<4.0"] firebird = ["sqlalchemy-firebird>=0.7.0, <0.8"] firebolt = ["firebolt-sqlalchemy>=1.0.0, <2"] gevent = ["gevent>=23.9.1"] diff --git a/requirements/development.txt b/requirements/development.txt index 993bbfdc3b46..296fe15d8207 100644 --- a/requirements/development.txt +++ b/requirements/development.txt @@ -10,6 +10,8 @@ # via # -r requirements/development.in # apache-superset +aiofile==3.9.0 + # via py-key-value-aio alembic==1.15.2 # via # -c requirements/base-constraint.txt @@ -26,8 +28,10 @@ anyio==4.11.0 # via # httpx # mcp + # py-key-value-aio # sse-starlette # starlette + # watchfiles apispec==6.6.1 # via # -c requirements/base-constraint.txt @@ -65,9 +69,7 @@ bcrypt==4.3.0 # -c requirements/base-constraint.txt # paramiko beartype==0.22.5 - # via - # py-key-value-aio - # py-key-value-shared + # via py-key-value-aio billiard==4.2.1 # via # -c requirements/base-constraint.txt @@ -100,6 +102,8 @@ cachetools==6.2.1 # -c requirements/base-constraint.txt # google-auth # py-key-value-aio +caio==0.9.25 + # via aiofile cattrs==25.1.1 # via # -c requirements/base-constraint.txt @@ -138,7 +142,6 @@ click==8.2.1 # click-repl # flask # flask-appbuilder - # typer # uvicorn click-didyoumean==0.3.1 # via @@ -156,8 +159,6 @@ click-repl==0.3.0 # via # -c requirements/base-constraint.txt # celery -cloudpickle==3.1.2 - # via pydocket cmdstanpy==1.1.0 # via prophet colorama==0.4.6 @@ -206,8 +207,6 @@ deprecation==2.1.0 # apache-superset dill==0.4.0 # via pylint -diskcache==5.6.3 - # via py-key-value-aio distlib==0.3.8 # via virtualenv dnspython==2.7.0 @@ -237,9 +236,7 @@ et-xmlfile==2.0.0 # openpyxl exceptiongroup==1.3.0 # via fastmcp -fakeredis==2.32.1 - # via pydocket -fastmcp==2.14.3 +fastmcp==3.1.0 # via apache-superset filelock==3.20.3 # via @@ -474,6 +471,8 @@ jsonpath-ng==1.7.0 # via # -c requirements/base-constraint.txt # apache-superset +jsonref==1.1.0 + # via fastmcp jsonschema==4.23.0 # via # -c requirements/base-constraint.txt @@ -504,8 +503,6 @@ limits==5.1.0 # via # -c requirements/base-constraint.txt # flask-limiter -lupa==2.6 - # via fakeredis mako==1.3.10 # via # -c requirements/base-constraint.txt @@ -603,7 +600,7 @@ openpyxl==3.1.5 # -c requirements/base-constraint.txt # pandas opentelemetry-api==1.39.1 - # via pydocket + # via fastmcp ordered-set==4.1.0 # via # -c requirements/base-constraint.txt @@ -622,6 +619,7 @@ packaging==25.0 # deprecation # docker # duckdb-engine + # fastmcp # google-cloud-bigquery # gunicorn # limits @@ -653,8 +651,6 @@ parsedatetime==2.6 # apache-superset pathable==0.4.3 # via jsonschema-path -pathvalidate==3.3.1 - # via py-key-value-aio pgsanity==0.2.9 # via # -c requirements/base-constraint.txt @@ -691,8 +687,6 @@ prison==0.2.1 # flask-appbuilder progress==1.6 # via apache-superset -prometheus-client==0.23.1 - # via pydocket prompt-toolkit==3.0.51 # via # -c requirements/base-constraint.txt @@ -714,12 +708,8 @@ psutil==6.1.0 # via apache-superset psycopg2-binary==2.9.9 # via apache-superset -py-key-value-aio==0.3.0 - # via - # fastmcp - # pydocket -py-key-value-shared==0.3.0 - # via py-key-value-aio +py-key-value-aio==0.4.4 + # via fastmcp pyarrow==16.1.0 # via # -c requirements/base-constraint.txt @@ -758,8 +748,6 @@ pydantic-settings==2.10.1 # via mcp pydata-google-auth==1.9.0 # via pandas-gbq -pydocket==0.17.1 - # via fastmcp pydruid==0.6.9 # via apache-superset pyfakefs==5.3.5 @@ -844,8 +832,6 @@ python-dotenv==1.1.0 # apache-superset # fastmcp # pydantic-settings -python-json-logger==4.0.0 - # via pydocket python-ldap==3.4.4 # via apache-superset python-multipart==0.0.20 @@ -866,15 +852,13 @@ pyyaml==6.0.2 # -c requirements/base-constraint.txt # apache-superset # apispec + # fastmcp # jsonschema-path # pre-commit redis==5.3.1 # via # -c requirements/base-constraint.txt # apache-superset - # fakeredis - # py-key-value-aio - # pydocket referencing==0.36.2 # via # -c requirements/base-constraint.txt @@ -910,9 +894,7 @@ rich==13.9.4 # cyclopts # fastmcp # flask-limiter - # pydocket # rich-rst - # typer rich-rst==1.3.1 # via cyclopts rpds-py==0.25.0 @@ -944,8 +926,6 @@ setuptools==80.9.0 # pydata-google-auth # zope-event # zope-interface -shellingham==1.5.4 - # via typer shillelagh==1.4.3 # via # -c requirements/base-constraint.txt @@ -973,7 +953,6 @@ sniffio==1.3.1 sortedcontainers==2.4.0 # via # -c requirements/base-constraint.txt - # fakeredis # trio sqlalchemy==1.4.54 # via @@ -1034,8 +1013,6 @@ trio-websocket==0.12.2 # via # -c requirements/base-constraint.txt # selenium -typer==0.20.0 - # via pydocket typing-extensions==4.15.0 # via # -c requirements/base-constraint.txt @@ -1048,16 +1025,14 @@ typing-extensions==4.15.0 # limits # mcp # opentelemetry-api - # py-key-value-shared + # py-key-value-aio # pydantic # pydantic-core - # pydocket # pyopenssl # referencing # selenium # shillelagh # starlette - # typer # typing-inspection typing-inspection==0.4.1 # via @@ -1072,6 +1047,8 @@ tzdata==2025.2 # pandas tzlocal==5.2 # via trino +uncalled-for==0.2.0 + # via fastmcp url-normalize==2.2.1 # via # -c requirements/base-constraint.txt @@ -1101,6 +1078,8 @@ watchdog==6.0.0 # -c requirements/base-constraint.txt # apache-superset # apache-superset-extensions-cli +watchfiles==1.1.1 + # via fastmcp wcwidth==0.2.13 # via # -c requirements/base-constraint.txt diff --git a/superset/mcp_service/chart/schemas.py b/superset/mcp_service/chart/schemas.py index 4cc2bffa07f6..373f667e5861 100644 --- a/superset/mcp_service/chart/schemas.py +++ b/superset/mcp_service/chart/schemas.py @@ -384,15 +384,12 @@ class ChartList(BaseModel): class ColumnRef(BaseModel): name: str = Field( ..., - description="Column name", min_length=1, max_length=255, pattern=r"^[a-zA-Z0-9_][a-zA-Z0-9_\s\-\.]*$", ) - label: str | None = Field( - None, description="Display label for the column", max_length=500 - ) - dtype: str | None = Field(None, description="Data type hint") + label: str | None = Field(None, max_length=500) + dtype: str | None = None aggregate: ( Literal[ "SUM", @@ -407,11 +404,7 @@ class ColumnRef(BaseModel): "PERCENTILE", ] | None - ) = Field( - None, - description="SQL aggregation function. Only these validated functions are " - "supported to prevent SQL errors.", - ) + ) = Field(None, description="SQL aggregate function") @field_validator("name") @classmethod @@ -431,25 +424,22 @@ def sanitize_label(cls, v: str | None) -> str | None: class AxisConfig(BaseModel): - title: str | None = Field(None, description="Axis title", max_length=200) - scale: Literal["linear", "log"] | None = Field( - "linear", description="Axis scale type" - ) - format: str | None = Field( - None, description="Format string (e.g. '$,.2f')", max_length=50 - ) + title: str | None = Field(None, max_length=200) + scale: Literal["linear", "log"] | None = "linear" + format: str | None = Field(None, description="e.g. '$,.2f'", max_length=50) class LegendConfig(BaseModel): - show: bool = Field(True, description="Whether to show legend") - position: Literal["top", "bottom", "left", "right"] | None = Field( - "right", description="Legend position" - ) + show: bool = True + position: Literal["top", "bottom", "left", "right"] | None = "right" class FilterConfig(BaseModel): column: str = Field( - ..., description="Column to filter on", min_length=1, max_length=255 + ..., + min_length=1, + max_length=255, + pattern=r"^[a-zA-Z0-9_][a-zA-Z0-9_\s\-\.]*$", ) op: Literal[ "=", @@ -465,17 +455,11 @@ class FilterConfig(BaseModel): "NOT IN", ] = Field( ..., - description=( - "Filter operator. Use LIKE/ILIKE for pattern matching with % wildcards " - "(e.g., '%mario%'). Use IN/NOT IN with a list of values." - ), + description="LIKE/ILIKE use % wildcards. IN/NOT IN take a list.", ) value: str | int | float | bool | list[str | int | float | bool] = Field( ..., - description=( - "Filter value. For IN/NOT IN operators, provide a list of values. " - "For LIKE/ILIKE, use % as wildcard (e.g., '%mario%')." - ), + description="For IN/NOT IN, provide a list.", ) @field_validator("column") @@ -516,26 +500,13 @@ def validate_value_type_matches_operator(self) -> FilterConfig: class PieChartConfig(BaseModel): model_config = ConfigDict(extra="forbid") - chart_type: Literal["pie"] = Field( - ..., - description=( - "Chart type discriminator - MUST be 'pie' for pie/donut charts. " - "This field is REQUIRED and tells Superset which chart " - "configuration schema to use." - ), - ) - dimension: ColumnRef = Field( - ..., description="Category column that defines the pie slices" - ) + chart_type: Literal["pie"] = "pie" + dimension: ColumnRef = Field(..., description="Category column for slices") metric: ColumnRef = Field( - ..., - description=( - "Value metric that determines slice sizes. " - "Must include an aggregate function (e.g., SUM, COUNT)." - ), + ..., description="Value metric (needs aggregate e.g. SUM, COUNT)" ) - donut: bool = Field(False, description="Render as a donut chart with a center hole") - show_labels: bool = Field(True, description="Display labels on slices") + donut: bool = False + show_labels: bool = True label_type: Literal[ "key", "value", @@ -544,63 +515,32 @@ class PieChartConfig(BaseModel): "key_percent", "key_value_percent", "value_percent", - ] = Field("key_value_percent", description="Type of labels to show on slices") - sort_by_metric: bool = Field(True, description="Sort slices by metric value") - show_legend: bool = Field(True, description="Whether to show legend") - filters: List[FilterConfig] | None = Field(None, description="Filters to apply") - row_limit: int = Field( - 100, - description="Maximum number of slices to display", - ge=1, - le=10000, - ) - number_format: str = Field( - "SMART_NUMBER", - description="Number format string", - max_length=50, - ) - show_total: bool = Field(False, description="Display aggregate count in center") - labels_outside: bool = Field(True, description="Place labels outside the pie") - outer_radius: int = Field( - 70, - description="Outer edge radius as a percentage (1-100)", - ge=1, - le=100, - ) + ] = "key_value_percent" + sort_by_metric: bool = True + show_legend: bool = True + filters: List[FilterConfig] | None = None + row_limit: int = Field(100, description="Max slices", ge=1, le=10000) + number_format: str = Field("SMART_NUMBER", max_length=50) + show_total: bool = Field(False, description="Show total in center") + labels_outside: bool = True + outer_radius: int = Field(70, description="Outer radius % (1-100)", ge=1, le=100) inner_radius: int = Field( - 30, - description="Inner radius as a percentage for donut (1-100)", - ge=1, - le=100, + 30, description="Donut inner radius % (1-100)", ge=1, le=100 ) class PivotTableChartConfig(BaseModel): model_config = ConfigDict(extra="forbid") - chart_type: Literal["pivot_table"] = Field( - ..., - description=( - "Chart type discriminator - MUST be 'pivot_table' for interactive " - "pivot tables. This field is REQUIRED." - ), - ) - rows: List[ColumnRef] = Field( - ..., - min_length=1, - description="Row grouping columns (at least one required)", - ) + chart_type: Literal["pivot_table"] = "pivot_table" + rows: List[ColumnRef] = Field(..., min_length=1, description="Row grouping columns") columns: List[ColumnRef] | None = Field( - None, - description="Column grouping columns (optional, for cross-tabulation)", + None, description="Column groups for cross-tabulation" ) metrics: List[ColumnRef] = Field( ..., min_length=1, - description=( - "Metrics to aggregate. Each must have an aggregate function " - "(e.g., SUM, COUNT, AVG)." - ), + description="Metrics (need aggregate e.g. SUM, COUNT, AVG)", ) aggregate_function: Literal[ "Sum", @@ -614,108 +554,56 @@ class PivotTableChartConfig(BaseModel): "Count Unique Values", "First", "Last", - ] = Field("Sum", description="Default aggregation function for the pivot table") - show_row_totals: bool = Field(True, description="Show row totals") - show_column_totals: bool = Field(True, description="Show column totals") - transpose: bool = Field(False, description="Swap rows and columns") - combine_metric: bool = Field( - False, - description="Display metrics side by side within columns", - ) - filters: List[FilterConfig] | None = Field(None, description="Filters to apply") - row_limit: int = Field( - 10000, - description="Maximum number of cells", - ge=1, - le=50000, - ) - value_format: str = Field( - "SMART_NUMBER", - description="Value format string", - max_length=50, - ) + ] = "Sum" + show_row_totals: bool = True + show_column_totals: bool = True + transpose: bool = False + combine_metric: bool = Field(False, description="Metrics side by side in columns") + filters: List[FilterConfig] | None = None + row_limit: int = Field(10000, description="Max cells", ge=1, le=50000) + value_format: str = Field("SMART_NUMBER", max_length=50) class MixedTimeseriesChartConfig(BaseModel): model_config = ConfigDict(extra="forbid") - chart_type: Literal["mixed_timeseries"] = Field( - ..., - description=( - "Chart type discriminator - MUST be 'mixed_timeseries' for charts " - "that combine two different series types (e.g., line + bar). " - "This field is REQUIRED." - ), - ) - x: ColumnRef = Field(..., description="X-axis temporal column (shared)") - time_grain: TimeGrain | None = Field( - None, - description=( - "Time granularity for the x-axis. " - "Common values: PT1H (hourly), P1D (daily), P1W (weekly), " - "P1M (monthly), P1Y (yearly)." - ), - ) + chart_type: Literal["mixed_timeseries"] = "mixed_timeseries" + x: ColumnRef = Field(..., description="Shared temporal X-axis column") + time_grain: TimeGrain | None = Field(None, description="PT1H, P1D, P1W, P1M, P1Y") # Primary series (Query A) - y: List[ColumnRef] = Field( - ..., - min_length=1, - description="Primary Y-axis metrics (Query A)", - ) - primary_kind: Literal["line", "bar", "area", "scatter"] = Field( - "line", description="Primary series chart type" - ) - group_by: ColumnRef | None = Field( - None, description="Group by column for primary series" - ) + y: List[ColumnRef] = Field(..., min_length=1, description="Primary Y-axis metrics") + primary_kind: Literal["line", "bar", "area", "scatter"] = "line" + group_by: ColumnRef | None = Field(None, description="Primary series group by") # Secondary series (Query B) y_secondary: List[ColumnRef] = Field( - ..., - min_length=1, - description="Secondary Y-axis metrics (Query B)", - ) - secondary_kind: Literal["line", "bar", "area", "scatter"] = Field( - "bar", description="Secondary series chart type" + ..., min_length=1, description="Secondary Y-axis metrics" ) + secondary_kind: Literal["line", "bar", "area", "scatter"] = "bar" group_by_secondary: ColumnRef | None = Field( - None, description="Group by column for secondary series" + None, description="Secondary series group by" ) # Display options - show_legend: bool = Field(True, description="Whether to show legend") - x_axis: AxisConfig | None = Field(None, description="X-axis configuration") - y_axis: AxisConfig | None = Field(None, description="Primary Y-axis configuration") - y_axis_secondary: AxisConfig | None = Field( - None, description="Secondary Y-axis configuration" - ) - filters: List[FilterConfig] | None = Field(None, description="Filters to apply") + show_legend: bool = True + x_axis: AxisConfig | None = None + y_axis: AxisConfig | None = None + y_axis_secondary: AxisConfig | None = None + filters: List[FilterConfig] | None = None class TableChartConfig(BaseModel): model_config = ConfigDict(extra="forbid") - chart_type: Literal["table"] = Field( - ..., description="Chart type (REQUIRED: must be 'table')" - ) + chart_type: Literal["table"] = "table" viz_type: Literal["table", "ag-grid-table"] = Field( - "table", - description=( - "Visualization type: 'table' for standard table, 'ag-grid-table' for " - "AG Grid Interactive Table with advanced features like column resizing, " - "sorting, filtering, and server-side pagination" - ), + "table", description="'ag-grid-table' for interactive features" ) columns: List[ColumnRef] = Field( ..., min_length=1, - description=( - "Columns to display. Must have at least one column. Each column must have " - "a unique label " - "(either explicitly set via 'label' field or auto-generated " - "from name/aggregate)" - ), + description="Columns with unique labels", ) - filters: List[FilterConfig] | None = Field(None, description="Filters to apply") - sort_by: List[str] | None = Field(None, description="Columns to sort by") + filters: List[FilterConfig] | None = None + sort_by: List[str] | None = None @model_validator(mode="after") def validate_unique_column_labels(self) -> "TableChartConfig": @@ -748,56 +636,26 @@ def validate_unique_column_labels(self) -> "TableChartConfig": class XYChartConfig(BaseModel): model_config = ConfigDict(extra="forbid") - chart_type: Literal["xy"] = Field( - ..., - description=( - "Chart type discriminator - MUST be 'xy' for XY charts " - "(line, bar, area, scatter). " - "This field is REQUIRED and tells Superset which chart " - "configuration schema to use." - ), - ) + chart_type: Literal["xy"] = "xy" x: ColumnRef = Field(..., description="X-axis column") y: List[ColumnRef] = Field( - ..., - min_length=1, - description="Y-axis columns (metrics). Must have at least one Y-axis column. " - "Each column must have a unique label " - "that doesn't conflict with x-axis or group_by labels", - ) - kind: Literal["line", "bar", "area", "scatter"] = Field( - "line", description="Chart visualization type" + ..., min_length=1, description="Y-axis metrics (unique labels)" ) + kind: Literal["line", "bar", "area", "scatter"] = "line" time_grain: TimeGrain | None = Field( - None, - description=( - "Time granularity for the x-axis when it's a temporal column. " - "Common values: PT1S (second), PT1M (minute), PT1H (hour), " - "P1D (day), P1W (week), P1M (month), P3M (quarter), P1Y (year). " - "If not specified, Superset will use its default behavior." - ), + None, description="PT1S, PT1M, PT1H, P1D, P1W, P1M, P3M, P1Y" ) orientation: Literal["vertical", "horizontal"] | None = Field( - None, - description=( - "Bar chart orientation. Only applies when kind='bar'. " - "'vertical' (default): bars extend upward. " - "'horizontal': bars extend rightward, useful for long category names." - ), - ) - stacked: bool = Field( - False, - description="Stack bars/areas on top of each other instead of side-by-side", + None, description="Bar orientation (only for kind='bar')" ) + stacked: bool = False group_by: ColumnRef | None = Field( - None, - description="Column to group by (creates series/breakdown). " - "Use this field for series grouping — do NOT use 'series'.", + None, description="Series breakdown column (not 'series')" ) - x_axis: AxisConfig | None = Field(None, description="X-axis configuration") - y_axis: AxisConfig | None = Field(None, description="Y-axis configuration") - legend: LegendConfig | None = Field(None, description="Legend configuration") - filters: List[FilterConfig] | None = Field(None, description="Filters to apply") + x_axis: AxisConfig | None = None + y_axis: AxisConfig | None = None + legend: LegendConfig | None = None + filters: List[FilterConfig] | None = None @model_validator(mode="after") def validate_unique_column_labels(self) -> "XYChartConfig": @@ -949,21 +807,12 @@ class GenerateChartRequest(QueryCacheControl): dataset_id: int | str = Field(..., description="Dataset identifier (ID, UUID)") config: ChartConfig = Field(..., description="Chart configuration") chart_name: str | None = Field( - None, - description="Custom chart name (optional, auto-generates if not provided)", - max_length=255, - ) - save_chart: bool = Field( - default=False, - description="Whether to permanently save the chart in Superset", - ) - generate_preview: bool = Field( - default=True, - description="Whether to generate a preview image", + None, description="Auto-generates if omitted", max_length=255 ) + save_chart: bool = Field(default=False, description="Save permanently in Superset") + generate_preview: bool = True preview_formats: List[Literal["url", "ascii", "vega_lite", "table"]] = Field( default_factory=lambda: ["url"], - description="List of preview formats to generate", ) @field_validator("chart_name") @@ -1002,20 +851,14 @@ class GenerateExploreLinkRequest(FormDataCacheControl): class UpdateChartRequest(QueryCacheControl): - identifier: int | str = Field(..., description="Chart identifier (ID, UUID)") - config: ChartConfig = Field(..., description="New chart configuration") + identifier: int | str = Field(..., description="Chart ID or UUID") + config: ChartConfig chart_name: str | None = Field( - None, - description="New chart name (optional, will auto-generate if not provided)", - max_length=255, - ) - generate_preview: bool = Field( - default=True, - description="Whether to generate a preview after updating", + None, description="Auto-generates if omitted", max_length=255 ) + generate_preview: bool = True preview_formats: List[Literal["url", "ascii", "vega_lite", "table"]] = Field( default_factory=lambda: ["url"], - description="List of preview formats to generate", ) @field_validator("chart_name") @@ -1027,15 +870,11 @@ def sanitize_chart_name(cls, v: str | None) -> str | None: class UpdateChartPreviewRequest(FormDataCacheControl): form_data_key: str = Field(..., description="Existing form_data_key to update") - dataset_id: int | str = Field(..., description="Dataset identifier (ID, UUID)") - config: ChartConfig = Field(..., description="New chart configuration") - generate_preview: bool = Field( - default=True, - description="Whether to generate a preview after updating", - ) + dataset_id: int | str = Field(..., description="Dataset ID or UUID") + config: ChartConfig + generate_preview: bool = True preview_formats: List[Literal["url", "ascii", "vega_lite", "table"]] = Field( default_factory=lambda: ["url"], - description="List of preview formats to generate", ) diff --git a/superset/mcp_service/common/cache_schemas.py b/superset/mcp_service/common/cache_schemas.py index 59b3112b29f7..bf51bbf49b02 100644 --- a/superset/mcp_service/common/cache_schemas.py +++ b/superset/mcp_service/common/cache_schemas.py @@ -37,22 +37,10 @@ class CacheControlMixin(BaseModel): - Dashboard Cache: Caches rendered dashboard components """ - use_cache: bool = Field( - default=True, - description=( - "Whether to use Superset's cache layers. When True, will serve from " - "cache if available (query results, metadata, form data). When False, " - "will bypass cache and fetch fresh data." - ), - ) + use_cache: bool = Field(default=True, description="Use cache if available") force_refresh: bool = Field( - default=False, - description=( - "Whether to force refresh cached data. When True, will invalidate " - "existing cache entries and fetch fresh data, then update the cache. " - "Overrides use_cache=True if both are specified." - ), + default=False, description="Invalidate cache and fetch fresh data" ) @@ -65,12 +53,7 @@ class QueryCacheControl(CacheControlMixin): """ cache_timeout: int | None = Field( - default=None, - description=( - "Override the default cache timeout in seconds for this query. " - "If not specified, uses dataset-level or global cache settings. " - "Set to 0 to disable caching for this specific query." - ), + default=None, description="Cache timeout override in seconds (0 to disable)" ) @@ -83,11 +66,7 @@ class MetadataCacheControl(CacheControlMixin): """ refresh_metadata: bool = Field( - default=False, - description=( - "Whether to refresh metadata cache for datasets, tables, and columns. " - "Useful when database schema has changed and you need fresh metadata." - ), + default=False, description="Refresh metadata cache for schema changes" ) @@ -100,11 +79,7 @@ class FormDataCacheControl(CacheControlMixin): """ cache_form_data: bool = Field( - default=True, - description=( - "Whether to cache the form data configuration for future use. " - "When False, generates temporary configurations that are not cached." - ), + default=True, description="Cache form data for future use" ) diff --git a/superset/mcp_service/dashboard/schemas.py b/superset/mcp_service/dashboard/schemas.py index 48aa72a952c9..ba56cf774b00 100644 --- a/superset/mcp_service/dashboard/schemas.py +++ b/superset/mcp_service/dashboard/schemas.py @@ -287,45 +287,31 @@ class GetDashboardInfoRequest(MetadataCacheControl): class DashboardInfo(BaseModel): - id: int | None = Field(None, description="Dashboard ID") - dashboard_title: str | None = Field(None, description="Dashboard title") - slug: str | None = Field(None, description="Dashboard slug") - description: str | None = Field(None, description="Dashboard description") - css: str | None = Field(None, description="Custom CSS for the dashboard") - certified_by: str | None = Field(None, description="Who certified the dashboard") - certification_details: str | None = Field(None, description="Certification details") - json_metadata: str | None = Field( - None, description="Dashboard metadata (JSON string)" - ) - position_json: str | None = Field(None, description="Chart positions (JSON string)") - published: bool | None = Field( - None, description="Whether the dashboard is published" - ) - is_managed_externally: bool | None = Field( - None, description="Whether managed externally" - ) - external_url: str | None = Field(None, description="External URL") - created_on: str | datetime | None = Field(None, description="Creation timestamp") - changed_on: str | datetime | None = Field( - None, description="Last modification timestamp" - ) - created_by: str | None = Field(None, description="Dashboard creator (username)") - changed_by: str | None = Field(None, description="Last modifier (username)") - uuid: str | None = Field(None, description="Dashboard UUID (converted to string)") - url: str | None = Field(None, description="Dashboard URL") - created_on_humanized: str | None = Field( - None, description="Humanized creation time" - ) - changed_on_humanized: str | None = Field( - None, description="Humanized modification time" - ) - chart_count: int = Field(0, description="Number of charts in the dashboard") - owners: List[UserInfo] = Field(default_factory=list, description="Dashboard owners") - tags: List[TagInfo] = Field(default_factory=list, description="Dashboard tags") - roles: List[RoleInfo] = Field(default_factory=list, description="Dashboard roles") - charts: List[ChartInfo] = Field( - default_factory=list, description="Dashboard charts" - ) + id: int | None = None + dashboard_title: str | None = None + slug: str | None = None + description: str | None = None + css: str | None = None + certified_by: str | None = None + certification_details: str | None = None + json_metadata: str | None = None + position_json: str | None = None + published: bool | None = None + is_managed_externally: bool | None = None + external_url: str | None = None + created_on: str | datetime | None = None + changed_on: str | datetime | None = None + created_by: str | None = None + changed_by: str | None = None + uuid: str | None = None + url: str | None = None + created_on_humanized: str | None = None + changed_on_humanized: str | None = None + chart_count: int = 0 + owners: List[UserInfo] = Field(default_factory=list) + tags: List[TagInfo] = Field(default_factory=list) + roles: List[RoleInfo] = Field(default_factory=list) + charts: List[ChartInfo] = Field(default_factory=list) # Fields for permalink/filter state support permalink_key: str | None = Field( diff --git a/superset/mcp_service/flask_singleton.py b/superset/mcp_service/flask_singleton.py index 3c35d31a6a4c..d3a7124ec137 100644 --- a/superset/mcp_service/flask_singleton.py +++ b/superset/mcp_service/flask_singleton.py @@ -25,7 +25,6 @@ """ import logging -import os from flask import current_app, Flask, has_app_context @@ -52,62 +51,45 @@ logger.info("Reusing existing Flask app from app context for MCP service") # Use _get_current_object() to get the actual Flask app, not the LocalProxy app = current_app._get_current_object() + elif appbuilder_initialized: + # appbuilder is initialized but we have no app context. Calling + # create_app() here would invoke appbuilder.init_app() a second + # time with a *different* Flask app, overwriting shared internal + # state (views, security manager, etc.). Fail loudly instead of + # silently corrupting the singleton. + raise RuntimeError( + "appbuilder is already initialized but no Flask app context is " + "available. Cannot call create_app() as it would re-initialize " + "appbuilder with a different Flask app instance." + ) else: - # Either appbuilder is not initialized (standalone MCP server), - # or appbuilder is initialized but we're not in an app context - # (edge case - should rarely happen). In both cases, create a minimal app. + # Standalone MCP server — Superset models are deeply coupled to + # appbuilder, security_manager, event_logger, encrypted_field_factory, + # etc. so we use create_app() for full initialization rather than + # trying to init a minimal subset (which leads to cascading failures). # - # We avoid calling create_app() which would run full FAB initialization - # and could corrupt the shared appbuilder singleton if main app starts. - from superset.app import SupersetApp + # create_app() is safe here because in standalone mode the main + # Superset web server is not running in-process. + from superset.app import create_app from superset.mcp_service.mcp_config import get_mcp_config - if appbuilder_initialized: - logger.warning( - "Appbuilder initialized but not in app context - " - "creating separate MCP Flask app" - ) - else: - logger.info("Creating minimal Flask app for standalone MCP service") - - # Disable debug mode to avoid side-effects like file watchers - _mcp_app = SupersetApp(__name__) + logger.info("Creating fully initialized Flask app for standalone MCP service") + _mcp_app = create_app() _mcp_app.debug = False - # Load configuration - config_module = os.environ.get("SUPERSET_CONFIG", "superset.config") - _mcp_app.config.from_object(config_module) - - # Apply MCP-specific configuration + # Apply MCP-specific configuration on top mcp_config = get_mcp_config(_mcp_app.config) _mcp_app.config.update(mcp_config) - # Initialize only the minimal dependencies needed for MCP service with _mcp_app.app_context(): - try: - from superset.extensions import db - - db.init_app(_mcp_app) - - # Initialize only MCP-specific dependencies - # MCP tools import directly from superset.daos/models, so we only need - # the MCP decorator injection, not the full superset_core abstraction - from superset.core.mcp.core_mcp_injection import ( - initialize_core_mcp_dependencies, - ) - - initialize_core_mcp_dependencies() + from superset.core.mcp.core_mcp_injection import ( + initialize_core_mcp_dependencies, + ) - logger.info( - "Minimal MCP dependencies initialized for standalone MCP service" - ) - except Exception as e: - logger.warning( - "Failed to initialize dependencies for MCP service: %s", e - ) + initialize_core_mcp_dependencies() app = _mcp_app - logger.info("Minimal Flask app instance created successfully for MCP service") + logger.info("Flask app fully initialized for standalone MCP service") except Exception as e: logger.error("Failed to create Flask app: %s", e) diff --git a/superset/mcp_service/mcp_config.py b/superset/mcp_service/mcp_config.py index 75221f854eff..e1e65c11f0ba 100644 --- a/superset/mcp_service/mcp_config.py +++ b/superset/mcp_service/mcp_config.py @@ -227,10 +227,44 @@ "get_chart_preview", # Returns URLs, not data "generate_explore_link", # Returns URLs "open_sql_lab_with_context", # Returns URLs + "search_tools", # Returns tool schemas for discovery (intentionally large) ], } +# ============================================================================= +# MCP Tool Search Transform Configuration +# ============================================================================= +# +# Overview: +# --------- +# When enabled, replaces the full tool catalog with a search interface. +# LLMs see only 2 synthetic tools (search_tools + call_tool) plus any +# pinned tools, and discover other tools on-demand via natural language search. +# This reduces initial context by ~70% (from ~40k tokens to ~5-8k tokens). +# +# Strategies: +# ----------- +# - "bm25": Natural language search using BM25 ranking (recommended) +# - "regex": Pattern-based search using regular expressions +# +# Rollback: +# --------- +# Set enabled=False in superset_config.py for instant rollback. +# ============================================================================= +MCP_TOOL_SEARCH_CONFIG: Dict[str, Any] = { + "enabled": True, # Enabled by default — reduces initial context by ~70% + "strategy": "bm25", # "bm25" (natural language) or "regex" (pattern matching) + "max_results": 5, # Max tools returned per search + "always_visible": [ # Tools always shown in list_tools (pinned) + "health_check", + "get_instance_info", + ], + "search_tool_name": "search_tools", # Name of the search tool + "call_tool_name": "call_tool", # Name of the call proxy tool +} + + def create_default_mcp_auth_factory(app: Flask) -> Optional[Any]: """Default MCP auth factory using app.config values.""" if not app.config.get("MCP_AUTH_ENABLED", False): diff --git a/superset/mcp_service/server.py b/superset/mcp_service/server.py index 5435a66e68b0..732e8f4622f1 100644 --- a/superset/mcp_service/server.py +++ b/superset/mcp_service/server.py @@ -24,12 +24,17 @@ import logging import os +from collections.abc import Sequence from typing import Any import uvicorn from superset.mcp_service.app import create_mcp_app, init_fastmcp_server -from superset.mcp_service.mcp_config import get_mcp_factory_config, MCP_STORE_CONFIG +from superset.mcp_service.mcp_config import ( + get_mcp_factory_config, + MCP_STORE_CONFIG, + MCP_TOOL_SEARCH_CONFIG, +) from superset.mcp_service.middleware import ( create_response_size_guard_middleware, GlobalErrorHandlerMiddleware, @@ -111,8 +116,7 @@ def create_event_store(config: dict[str, Any] | None = None) -> Any | None: if config is None: config = MCP_STORE_CONFIG - redis_url = config.get("CACHE_REDIS_URL") - if not redis_url: + if not config.get("CACHE_REDIS_URL"): logging.info("EventStore: Using in-memory storage (single-pod mode)") return None @@ -151,6 +155,117 @@ def create_event_store(config: dict[str, Any] | None = None) -> Any | None: return None +def _strip_titles(obj: Any, in_properties_map: bool = False) -> Any: + """Recursively strip schema metadata ``title`` keys. + + Keeps real field names inside ``properties`` (e.g. a property literally + named ``title``), while removing auto-generated schema title metadata. + """ + if isinstance(obj, dict): + result: dict[str, Any] = {} + for key, value in obj.items(): + if key == "title" and not in_properties_map: + continue + result[key] = _strip_titles(value, in_properties_map=(key == "properties")) + return result + if isinstance(obj, list): + return [_strip_titles(item, in_properties_map=False) for item in obj] + return obj + + +def _serialize_tools_without_output_schema( + tools: Sequence[Any], +) -> list[dict[str, Any]]: + """Serialize tools to JSON, stripping outputSchema and titles to reduce tokens. + + LLMs only need inputSchema to call tools. outputSchema accounts for + 50-80% of the per-tool schema size, and auto-generated 'title' fields + add ~12% bloat. Stripping both cuts search result tokens significantly. + """ + results = [] + for tool in tools: + data = tool.to_mcp_tool().model_dump(mode="json", exclude_none=True) + data.pop("outputSchema", None) + if input_schema := data.get("inputSchema"): + data["inputSchema"] = _strip_titles(input_schema) + results.append(data) + return results + + +def _fix_call_tool_arguments(tool: Any) -> Any: + """Fix anyOf schema in call_tool ``arguments`` for MCP bridge compatibility. + + FastMCP's BaseSearchTransform defines ``arguments`` as + ``dict[str, Any] | None`` which emits an ``anyOf`` JSON Schema. + Some MCP bridges (mcp-remote, Claude Desktop) don't handle ``anyOf`` + and strip it, leaving the field without a ``type`` — causing all + call_tool invocations to fail with "Input should be a valid dictionary". + + Replaces the ``anyOf`` with a flat ``type: object``. + """ + if "arguments" in (props := (tool.parameters or {}).get("properties", {})): + props["arguments"] = { + "additionalProperties": True, + "default": None, + "description": "Arguments to pass to the tool", + "type": "object", + } + return tool + + +def _apply_tool_search_transform(mcp_instance: Any, config: dict[str, Any]) -> None: + """Apply tool search transform to reduce initial context size. + + When enabled, replaces the full tool catalog with a search interface. + LLMs see only synthetic search/call tools plus pinned tools, and + discover other tools on-demand via natural language search. + + Uses subclassing (not monkey-patching) to override ``_make_call_tool`` + and fix the ``arguments`` schema for MCP bridge compatibility. + + NOTE: ``_make_call_tool`` is a private API in FastMCP 3.x + (fastmcp>=3.1.0,<4.0). If FastMCP changes or removes this method + in a future major version, these subclasses will need to be updated. + """ + strategy = config.get("strategy", "bm25") + kwargs: dict[str, Any] = { + "max_results": config.get("max_results", 5), + "always_visible": config.get("always_visible", []), + "search_tool_name": config.get("search_tool_name", "search_tools"), + "call_tool_name": config.get("call_tool_name", "call_tool"), + "search_result_serializer": _serialize_tools_without_output_schema, + } + + if strategy == "regex": + from fastmcp.server.transforms.search import RegexSearchTransform + + class _FixedRegexSearchTransform(RegexSearchTransform): + """Regex search with fixed call_tool arguments schema.""" + + def _make_call_tool(self) -> Any: + return _fix_call_tool_arguments(super()._make_call_tool()) + + transform = _FixedRegexSearchTransform(**kwargs) + else: + from fastmcp.server.transforms.search import BM25SearchTransform + + class _FixedBM25SearchTransform(BM25SearchTransform): + """BM25 search with fixed call_tool arguments schema.""" + + def _make_call_tool(self) -> Any: + return _fix_call_tool_arguments(super()._make_call_tool()) + + transform = _FixedBM25SearchTransform(**kwargs) + + mcp_instance.add_transform(transform) + logger.info( + "Tool search transform enabled (strategy=%s, max_results=%d, pinned=%s)", + strategy, + kwargs["max_results"], + kwargs["always_visible"], + ) + + def _create_auth_provider(flask_app: Any) -> Any | None: """Create an auth provider from Flask app config. @@ -218,6 +333,11 @@ def run_server( logging.info("Creating MCP app from factory configuration...") factory_config = get_mcp_factory_config() mcp_instance = create_mcp_app(**factory_config) + + # Apply tool search transform if configured + tool_search_config = MCP_TOOL_SEARCH_CONFIG + if tool_search_config.get("enabled", False): + _apply_tool_search_transform(mcp_instance, tool_search_config) else: # Use default initialization with auth from Flask config logging.info("Creating MCP app with default configuration...") @@ -233,8 +353,7 @@ def run_server( middleware_list = [] # Add caching middleware (innermost – runs closest to the tool) - caching_middleware = create_response_caching_middleware() - if caching_middleware: + if caching_middleware := create_response_caching_middleware(): middleware_list.append(caching_middleware) # Add response size guard (protects LLM clients from huge responses) @@ -252,6 +371,18 @@ def run_server( middleware=middleware_list or None, ) + # Apply tool search transform if configured + tool_search_config = flask_app.config.get( + "MCP_TOOL_SEARCH_CONFIG", MCP_TOOL_SEARCH_CONFIG + ) + if tool_search_config.get("enabled", False): + _apply_tool_search_transform(mcp_instance, tool_search_config) + # Ensure the configured search tool name is excluded from the + # response size guard (search results are intentionally large) + if size_guard_middleware: + search_name = tool_search_config.get("search_tool_name", "search_tools") + size_guard_middleware.excluded_tools.add(search_name) + # Create EventStore for session management (Redis for multi-pod, None for in-memory) event_store = create_event_store(event_store_config) diff --git a/superset/mcp_service/system/schemas.py b/superset/mcp_service/system/schemas.py index 9810cc4d3b3e..ae0fab91e622 100644 --- a/superset/mcp_service/system/schemas.py +++ b/superset/mcp_service/system/schemas.py @@ -58,54 +58,40 @@ class GetSupersetInstanceInfoRequest(BaseModel): class InstanceSummary(BaseModel): - total_dashboards: int = Field(..., description="Total number of dashboards") - total_charts: int = Field(..., description="Total number of charts") - total_datasets: int = Field(..., description="Total number of datasets") - total_databases: int = Field(..., description="Total number of databases") - total_users: int = Field(..., description="Total number of users") - total_roles: int = Field(..., description="Total number of roles") - total_tags: int = Field(..., description="Total number of tags") - avg_charts_per_dashboard: float = Field( - ..., description="Average number of charts per dashboard" - ) + total_dashboards: int + total_charts: int + total_datasets: int + total_databases: int + total_users: int + total_roles: int + total_tags: int + avg_charts_per_dashboard: float class RecentActivity(BaseModel): - dashboards_created_last_30_days: int = Field( - ..., description="Dashboards created in the last 30 days" - ) - charts_created_last_30_days: int = Field( - ..., description="Charts created in the last 30 days" - ) - datasets_created_last_30_days: int = Field( - ..., description="Datasets created in the last 30 days" - ) - dashboards_modified_last_7_days: int = Field( - ..., description="Dashboards modified in the last 7 days" - ) - charts_modified_last_7_days: int = Field( - ..., description="Charts modified in the last 7 days" - ) - datasets_modified_last_7_days: int = Field( - ..., description="Datasets modified in the last 7 days" - ) + dashboards_created_last_30_days: int + charts_created_last_30_days: int + datasets_created_last_30_days: int + dashboards_modified_last_7_days: int + charts_modified_last_7_days: int + datasets_modified_last_7_days: int class DashboardBreakdown(BaseModel): - published: int = Field(..., description="Number of published dashboards") - unpublished: int = Field(..., description="Number of unpublished dashboards") - certified: int = Field(..., description="Number of certified dashboards") - with_charts: int = Field(..., description="Number of dashboards with charts") - without_charts: int = Field(..., description="Number of dashboards without charts") + published: int + unpublished: int + certified: int + with_charts: int + without_charts: int class DatabaseBreakdown(BaseModel): - by_type: Dict[str, int] = Field(..., description="Breakdown of databases by type") + by_type: Dict[str, int] class PopularContent(BaseModel): - top_tags: List[str] = Field(..., description="Most popular tags") - top_creators: List[str] = Field(..., description="Most active creators") + top_tags: List[str] = Field(default_factory=list) + top_creators: List[str] = Field(default_factory=list) class FeatureAvailability(BaseModel): @@ -125,33 +111,19 @@ class FeatureAvailability(BaseModel): class InstanceInfo(BaseModel): - instance_summary: InstanceSummary = Field( - ..., description="Instance summary information" - ) - recent_activity: RecentActivity = Field( - ..., description="Recent activity information" - ) - dashboard_breakdown: DashboardBreakdown = Field( - ..., description="Dashboard breakdown information" - ) - database_breakdown: DatabaseBreakdown = Field( - ..., description="Database breakdown by type" - ) - popular_content: PopularContent = Field( - ..., description="Popular content information" - ) + instance_summary: InstanceSummary + recent_activity: RecentActivity + dashboard_breakdown: DashboardBreakdown + database_breakdown: DatabaseBreakdown + popular_content: PopularContent current_user: UserInfo | None = Field( None, - description="The authenticated user making the request. " - "Use current_user.id with created_by_fk filter to find your own assets.", - ) - feature_availability: FeatureAvailability = Field( - ..., description=( - "Dynamic feature availability for the current user and deployment" + "Use current_user.id with created_by_fk filter to find your own assets." ), ) - timestamp: datetime = Field(..., description="Response timestamp") + feature_availability: FeatureAvailability + timestamp: datetime class UserInfo(BaseModel): @@ -207,10 +179,10 @@ class RoleInfo(BaseModel): class PaginationInfo(BaseModel): - page: int = Field(..., description="Current page number") - page_size: int = Field(..., description="Number of items per page") - total_count: int = Field(..., description="Total number of items") - total_pages: int = Field(..., description="Total number of pages") - has_next: bool = Field(..., description="Whether there is a next page") - has_previous: bool = Field(..., description="Whether there is a previous page") + page: int + page_size: int + total_count: int + total_pages: int + has_next: bool + has_previous: bool model_config = ConfigDict(ser_json_timedelta="iso8601") diff --git a/tests/unit_tests/mcp_service/test_mcp_tool_registration.py b/tests/unit_tests/mcp_service/test_mcp_tool_registration.py index be470bc4fd02..9307a6716593 100644 --- a/tests/unit_tests/mcp_service/test_mcp_tool_registration.py +++ b/tests/unit_tests/mcp_service/test_mcp_tool_registration.py @@ -17,25 +17,32 @@ """Test MCP app imports and tool/prompt registration.""" +import asyncio + + +def _run(coro): + """Run an async coroutine synchronously.""" + return asyncio.run(coro) + def test_mcp_app_imports_successfully(): """Test that the MCP app can be imported without errors.""" from superset.mcp_service.app import mcp assert mcp is not None - assert hasattr(mcp, "_tool_manager") - tools = mcp._tool_manager._tools - assert len(tools) > 0 - assert "health_check" in tools - assert "list_charts" in tools + tools = _run(mcp.list_tools()) + tool_names = [t.name for t in tools] + assert len(tool_names) > 0 + assert "health_check" in tool_names + assert "list_charts" in tool_names def test_mcp_prompts_registered(): """Test that MCP prompts are registered.""" from superset.mcp_service.app import mcp - prompts = mcp._prompt_manager._prompts + prompts = _run(mcp.list_prompts()) assert len(prompts) > 0 @@ -48,12 +55,10 @@ def test_mcp_resources_registered(): """ from superset.mcp_service.app import mcp - resource_manager = mcp._resource_manager - resources = resource_manager._resources + resources = _run(mcp.list_resources()) assert len(resources) > 0, "No MCP resources registered" - # Verify the two documented resources are registered - resource_uris = set(resources.keys()) + resource_uris = {str(r.uri) for r in resources} assert "chart://configs" in resource_uris, ( "chart://configs resource not registered - " "check superset/mcp_service/chart/__init__.py exists" diff --git a/tests/unit_tests/mcp_service/test_tool_search_transform.py b/tests/unit_tests/mcp_service/test_tool_search_transform.py new file mode 100644 index 000000000000..7adfeacad47e --- /dev/null +++ b/tests/unit_tests/mcp_service/test_tool_search_transform.py @@ -0,0 +1,173 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""Tests for MCP tool search transform configuration and application.""" + +from types import SimpleNamespace +from unittest.mock import MagicMock + +from fastmcp.server.transforms.search import BM25SearchTransform, RegexSearchTransform + +from superset.mcp_service.mcp_config import MCP_TOOL_SEARCH_CONFIG +from superset.mcp_service.server import ( + _apply_tool_search_transform, + _fix_call_tool_arguments, + _serialize_tools_without_output_schema, +) + + +def test_tool_search_config_defaults(): + """Default config has expected keys and values.""" + assert MCP_TOOL_SEARCH_CONFIG["enabled"] is True + assert MCP_TOOL_SEARCH_CONFIG["strategy"] == "bm25" + assert MCP_TOOL_SEARCH_CONFIG["max_results"] == 5 + assert "health_check" in MCP_TOOL_SEARCH_CONFIG["always_visible"] + assert "get_instance_info" in MCP_TOOL_SEARCH_CONFIG["always_visible"] + assert MCP_TOOL_SEARCH_CONFIG["search_tool_name"] == "search_tools" + assert MCP_TOOL_SEARCH_CONFIG["call_tool_name"] == "call_tool" + + +def test_apply_bm25_transform(): + """BM25 subclass is created and added when strategy is 'bm25'.""" + mock_mcp = MagicMock() + config = { + "strategy": "bm25", + "max_results": 5, + "always_visible": ["health_check"], + "search_tool_name": "search_tools", + "call_tool_name": "call_tool", + } + + _apply_tool_search_transform(mock_mcp, config) + + mock_mcp.add_transform.assert_called_once() + transform = mock_mcp.add_transform.call_args[0][0] + assert isinstance(transform, BM25SearchTransform) + + +def test_apply_regex_transform(): + """Regex subclass is created and added when strategy is 'regex'.""" + mock_mcp = MagicMock() + config = { + "strategy": "regex", + "max_results": 10, + "always_visible": ["health_check", "get_instance_info"], + "search_tool_name": "find_tools", + "call_tool_name": "invoke_tool", + } + + _apply_tool_search_transform(mock_mcp, config) + + mock_mcp.add_transform.assert_called_once() + transform = mock_mcp.add_transform.call_args[0][0] + assert isinstance(transform, RegexSearchTransform) + + +def test_apply_transform_uses_defaults_for_missing_keys(): + """Missing config keys fall back to sensible defaults (BM25).""" + mock_mcp = MagicMock() + config = {} # All keys missing — should use defaults + + _apply_tool_search_transform(mock_mcp, config) + + mock_mcp.add_transform.assert_called_once() + transform = mock_mcp.add_transform.call_args[0][0] + assert isinstance(transform, BM25SearchTransform) + + +def test_fix_call_tool_arguments_replaces_anyof(): + """_fix_call_tool_arguments replaces anyOf with flat type: object.""" + tool = SimpleNamespace( + parameters={ + "type": "object", + "properties": { + "name": {"type": "string"}, + "arguments": { + "anyOf": [ + {"type": "object", "additionalProperties": True}, + {"type": "null"}, + ], + "default": None, + }, + }, + } + ) + + result = _fix_call_tool_arguments(tool) + + assert result.parameters["properties"]["arguments"] == { + "additionalProperties": True, + "default": None, + "description": "Arguments to pass to the tool", + "type": "object", + } + # Other properties untouched + assert result.parameters["properties"]["name"] == {"type": "string"} + + +def test_fix_call_tool_arguments_no_arguments_field(): + """_fix_call_tool_arguments is a no-op when arguments field is absent.""" + tool = SimpleNamespace( + parameters={ + "type": "object", + "properties": {"name": {"type": "string"}}, + } + ) + + result = _fix_call_tool_arguments(tool) + + assert "arguments" not in result.parameters["properties"] + + +def test_serialize_tools_strips_output_schema(): + """Custom serializer removes outputSchema from tool definitions.""" + mock_tool = MagicMock() + mock_mcp_tool = MagicMock() + mock_mcp_tool.model_dump.return_value = { + "name": "test_tool", + "description": "A test tool", + "inputSchema": {"type": "object", "properties": {"x": {"type": "integer"}}}, + "outputSchema": { + "type": "object", + "properties": {"result": {"type": "string"}}, + }, + } + mock_tool.to_mcp_tool.return_value = mock_mcp_tool + + result = _serialize_tools_without_output_schema([mock_tool]) + + assert len(result) == 1 + assert result[0]["name"] == "test_tool" + assert "inputSchema" in result[0] + assert "outputSchema" not in result[0] + + +def test_serialize_tools_handles_no_output_schema(): + """Custom serializer works when tool has no outputSchema.""" + mock_tool = MagicMock() + mock_mcp_tool = MagicMock() + mock_mcp_tool.model_dump.return_value = { + "name": "simple_tool", + "inputSchema": {"type": "object"}, + } + mock_tool.to_mcp_tool.return_value = mock_mcp_tool + + result = _serialize_tools_without_output_schema([mock_tool]) + + assert len(result) == 1 + assert result[0]["name"] == "simple_tool" + assert "outputSchema" not in result[0] From e8061a9c2b458f142804a2993396f10ff8cdf8cc Mon Sep 17 00:00:00 2001 From: Luiz Otavio <45200344+luizotavio32@users.noreply.github.com> Date: Fri, 13 Mar 2026 14:24:14 -0300 Subject: [PATCH 09/11] style(metadata-bar): use bold font weight for metadata bar title (#38608) --- .../src/components/MetadataBar/ContentConfig.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/superset-frontend/packages/superset-ui-core/src/components/MetadataBar/ContentConfig.tsx b/superset-frontend/packages/superset-ui-core/src/components/MetadataBar/ContentConfig.tsx index 225549989b5d..4a9af897d738 100644 --- a/superset-frontend/packages/superset-ui-core/src/components/MetadataBar/ContentConfig.tsx +++ b/superset-frontend/packages/superset-ui-core/src/components/MetadataBar/ContentConfig.tsx @@ -23,7 +23,7 @@ import { Icons } from '@superset-ui/core/components/Icons'; import { ContentType, MetadataType } from '.'; const Header = styled.div` - font-weight: ${({ theme }) => theme.fontWeightStrong}; + font-weight: ${({ theme }) => theme.fontWeightBold}; `; const Info = ({ From 6e7d6a85b451b5bc569d10f8581c16a92f752ba2 Mon Sep 17 00:00:00 2001 From: amaannawab923 Date: Fri, 13 Mar 2026 23:29:52 +0530 Subject: [PATCH 10/11] fix(ag-grid-table): fix failing buildQuery test expectation (#38636) --- .../plugins/plugin-chart-ag-grid-table/test/buildQuery.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/superset-frontend/plugins/plugin-chart-ag-grid-table/test/buildQuery.test.ts b/superset-frontend/plugins/plugin-chart-ag-grid-table/test/buildQuery.test.ts index 636fc190fcce..acec22c69656 100644 --- a/superset-frontend/plugins/plugin-chart-ag-grid-table/test/buildQuery.test.ts +++ b/superset-frontend/plugins/plugin-chart-ag-grid-table/test/buildQuery.test.ts @@ -1215,7 +1215,7 @@ describe('plugin-chart-ag-grid-table', () => { }, ).queries[0]; - expect(query.extras?.where).toBe('"user_status" = \'active\''); + expect(query.extras?.where).toBe("user_status = 'active'"); }); test('should resolve longer labels before shorter ones', () => { From ed622e254a49b383541d3c056c7c67efd735a1dc Mon Sep 17 00:00:00 2001 From: Mehmet Salih Yavuz Date: Fri, 13 Mar 2026 21:51:53 +0300 Subject: [PATCH 11/11] feat(matrixify): Revamp control panel (#38519) --- .../src/sections/matrixify.tsx | 42 +++---- .../src/shared-controls/matrixifyControls.tsx | 114 +++++++++--------- .../Matrixify/MatrixifyGridRenderer.test.tsx | 56 +++++---- .../Matrixify/MatrixifyGridRenderer.tsx | 6 +- .../src/chart/types/matrixify.test.ts | 57 +++++---- .../src/chart/types/matrixify.ts | 80 +++++++----- .../ChartContextMenu/ChartContextMenu.tsx | 6 +- .../components/Chart/ChartRenderer.test.tsx | 22 ++-- .../src/components/Chart/ChartRenderer.tsx | 6 +- .../Chart/DrillBy/DrillBySubmenu.test.tsx | 4 +- .../Chart/DrillBy/DrillBySubmenu.tsx | 6 +- .../ControlPanelsContainer.test.tsx | 9 +- .../components/ControlPanelsContainer.tsx | 7 +- .../ExploreChartHeader.test.tsx | 2 +- .../components/ExploreChartPanel/index.tsx | 10 +- .../controls/MatrixifyDimensionControl.tsx | 74 +++++++++--- .../components/controls/SwitchControl.tsx | 65 ++++++++++ .../controls/VerticalRadioControl.tsx | 104 ++++++++++++++++ .../src/explore/components/controls/index.ts | 4 + .../src/explore/controlPanels/sections.tsx | 86 ++++++++----- .../pages/ChartList/ChartList.testHelpers.tsx | 2 +- 21 files changed, 516 insertions(+), 246 deletions(-) create mode 100644 superset-frontend/src/explore/components/controls/SwitchControl.tsx create mode 100644 superset-frontend/src/explore/components/controls/VerticalRadioControl.tsx diff --git a/superset-frontend/packages/superset-ui-chart-controls/src/sections/matrixify.tsx b/superset-frontend/packages/superset-ui-chart-controls/src/sections/matrixify.tsx index dde1f0431db3..928ec6331b84 100644 --- a/superset-frontend/packages/superset-ui-chart-controls/src/sections/matrixify.tsx +++ b/superset-frontend/packages/superset-ui-chart-controls/src/sections/matrixify.tsx @@ -25,30 +25,17 @@ export const matrixifyEnableSection: ControlPanelSectionConfig = { controlSetRows: [ [ { - name: 'matrixify_enable_horizontal_layout', + name: 'matrixify_enable', config: { - type: 'CheckboxControl', - label: t('Enable horizontal layout (columns)'), - description: t( - 'Create matrix columns by placing charts side-by-side', - ), - default: false, - renderTrigger: true, - }, - }, - ], - [ - { - name: 'matrixify_enable_vertical_layout', - config: { - type: 'CheckboxControl', - label: t('Enable vertical layout (rows)'), - description: t('Create matrix rows by stacking charts vertically'), + type: 'SwitchControl', + label: t('Enable matrixify'), default: false, renderTrigger: true, }, }, ], + ['matrixify_mode_columns'], + ['matrixify_mode_rows'], ], tabOverride: 'matrixify', }; @@ -57,8 +44,11 @@ export const matrixifySection: ControlPanelSectionConfig = { label: t('Cell layout & styling'), expanded: false, visibility: ({ controls }) => - controls?.matrixify_enable_vertical_layout?.value === true || - controls?.matrixify_enable_horizontal_layout?.value === true, + controls?.matrixify_enable?.value === true && + (controls?.matrixify_mode_rows?.value === 'metrics' || + controls?.matrixify_mode_rows?.value === 'dimensions' || + controls?.matrixify_mode_columns?.value === 'metrics' || + controls?.matrixify_mode_columns?.value === 'dimensions'), controlSetRows: [ [ { @@ -119,13 +109,13 @@ export const matrixifySection: ControlPanelSectionConfig = { }; export const matrixifyRowSection: ControlPanelSectionConfig = { - label: t('Vertical layout (rows)'), expanded: false, visibility: ({ controls }) => - controls?.matrixify_enable_vertical_layout?.value === true, + controls?.matrixify_enable?.value === true && + (controls?.matrixify_mode_rows?.value === 'metrics' || + controls?.matrixify_mode_rows?.value === 'dimensions'), controlSetRows: [ ['matrixify_show_row_labels'], - ['matrixify_mode_rows'], ['matrixify_rows'], ['matrixify_dimension_rows'], ['matrixify_dimension_selection_mode_rows'], @@ -137,13 +127,13 @@ export const matrixifyRowSection: ControlPanelSectionConfig = { }; export const matrixifyColumnSection: ControlPanelSectionConfig = { - label: t('Horizontal layout (columns)'), expanded: false, visibility: ({ controls }) => - controls?.matrixify_enable_horizontal_layout?.value === true, + controls?.matrixify_enable?.value === true && + (controls?.matrixify_mode_columns?.value === 'metrics' || + controls?.matrixify_mode_columns?.value === 'dimensions'), controlSetRows: [ ['matrixify_show_column_headers'], - ['matrixify_mode_columns'], ['matrixify_columns'], ['matrixify_dimension_columns'], ['matrixify_dimension_selection_mode_columns'], diff --git a/superset-frontend/packages/superset-ui-chart-controls/src/shared-controls/matrixifyControls.tsx b/superset-frontend/packages/superset-ui-chart-controls/src/shared-controls/matrixifyControls.tsx index be93b6fec1c6..48dcaa3dafcd 100644 --- a/superset-frontend/packages/superset-ui-chart-controls/src/shared-controls/matrixifyControls.tsx +++ b/superset-frontend/packages/superset-ui-chart-controls/src/shared-controls/matrixifyControls.tsx @@ -34,19 +34,18 @@ const isMatrixifyVisible = ( controls: any, axis: 'rows' | 'columns', mode?: 'metrics' | 'dimensions', - selectionMode?: 'members' | 'topn', + selectionMode?: 'members' | 'topn' | 'all', ) => { - const layoutControl = `matrixify_enable_${axis === 'rows' ? 'vertical' : 'horizontal'}_layout`; const modeControl = `matrixify_mode_${axis}`; const selectionModeControl = `matrixify_dimension_selection_mode_${axis}`; - const isLayoutEnabled = controls?.[layoutControl]?.value === true; + const modeValue = controls?.[modeControl]?.value; + const isLayoutEnabled = modeValue === 'metrics' || modeValue === 'dimensions'; if (!isLayoutEnabled) return false; if (mode) { - const isModeMatch = controls?.[modeControl]?.value === mode; - if (!isModeMatch) return false; + if (modeValue !== mode) return false; if (selectionMode && mode === 'dimensions') { return controls?.[selectionModeControl]?.value === selectionMode; @@ -66,22 +65,20 @@ const matrixifyControls: Record> = {}; matrixifyControls[`matrixify_mode_${axis}`] = { type: 'RadioButtonControl', - label: t(`Metrics / Dimensions`), - default: axis === 'columns' ? 'metrics' : 'dimensions', + default: 'disabled', renderTrigger: true, tabOverride: 'matrixify', - visibility: ({ controls }) => isMatrixifyVisible(controls, axis), mapStateToProps: ({ controls }) => { const otherAxisControlName = `matrixify_mode_${otherAxis}`; const otherAxisValue = - controls?.[otherAxisControlName]?.value ?? - (otherAxis === 'columns' ? 'metrics' : 'dimensions'); + controls?.[otherAxisControlName]?.value ?? 'disabled'; const isMetricsDisabled = otherAxisValue === 'metrics'; return { options: [ + { value: 'disabled', label: t('Disabled') }, { value: 'metrics', label: t('Metrics'), @@ -92,7 +89,7 @@ const matrixifyControls: Record> = {}; ) : undefined, }, - { value: 'dimensions', label: t('Dimension members') }, + { value: 'dimensions', label: t('Dimensions') }, ], }; }, @@ -125,6 +122,7 @@ const matrixifyControls: Record> = {}; `matrixify_topn_metric_${axis}`, `matrixify_topn_order_${axis}`, `matrixify_dimension_selection_mode_${axis}`, + `matrixify_all_sort_by_${axis}`, ]; return fieldsToCheck.some( @@ -161,7 +159,10 @@ const matrixifyControls: Record> = {}; selectionMode, topNMetric: getValue(`matrixify_topn_metric_${axis}`), topNValue: getValue(`matrixify_topn_value_${axis}`), - topNOrder: getValue(`matrixify_topn_order_${axis}`), + topNOrder: getValue(`matrixify_topn_order_${axis}`, true) + ? 'DESC' + : 'ASC', + allSortBy: getValue(`matrixify_all_sort_by_${axis}`, 'a_to_z'), formData: form_data, validators, }; @@ -187,19 +188,24 @@ const matrixifyControls: Record> = {}; visibility: () => false, }; - // Add selection mode control (Dimension Members vs TopN) + // Add selection mode control (Dimension Members / Top N / All) matrixifyControls[`matrixify_dimension_selection_mode_${axis}`] = { - type: 'RadioButtonControl', + type: 'VerticalRadioControl', label: t(`Selection method`), default: 'members', - options: [ - ['members', t('Dimension members')], - ['topn', t('Top n')], - ], renderTrigger: true, tabOverride: 'matrixify', visibility: ({ controls }) => isMatrixifyVisible(controls, axis, 'dimensions'), + options: [ + { value: 'members', label: t('Dimension members') }, + { value: 'topn', label: t('Top n') }, + { + value: 'all', + label: t('All dimensions'), + tooltip: t('Uses the first 25 values if the dimension has more.'), + }, + ], }; // TopN controls @@ -236,15 +242,15 @@ const matrixifyControls: Record> = {}; description: t(`Metric to use for ordering Top N values`), tabOverride: 'matrixify', visibility: ({ controls }) => - isMatrixifyVisible(controls, axis, 'dimensions', 'topn'), + isMatrixifyVisible(controls, axis, 'dimensions', 'topn') || + (isMatrixifyVisible(controls, axis, 'dimensions', 'all') && + controls?.[`matrixify_all_sort_by_${axis}`]?.value === 'metric'), mapStateToProps: (state, controlState) => { const { controls, datasource } = state; - const isVisible = isMatrixifyVisible( - controls, - axis, - 'dimensions', - 'topn', - ); + const isVisible = + isMatrixifyVisible(controls, axis, 'dimensions', 'topn') || + (isMatrixifyVisible(controls, axis, 'dimensions', 'all') && + controls?.[`matrixify_all_sort_by_${axis}`]?.value === 'metric'); const originalProps = dndAdhocMetricControl.mapStateToProps?.(state, controlState) || {}; @@ -261,17 +267,31 @@ const matrixifyControls: Record> = {}; }; matrixifyControls[`matrixify_topn_order_${axis}`] = { - type: 'RadioButtonControl', - label: t(`Sort order`), - default: 'desc', - options: [ - ['asc', t('Ascending')], - ['desc', t('Descending')], - ], + type: 'CheckboxControl', + label: t('Sort descending'), + default: true, renderTrigger: true, tabOverride: 'matrixify', visibility: ({ controls }) => - isMatrixifyVisible(controls, axis, 'dimensions', 'topn'), + isMatrixifyVisible(controls, axis, 'dimensions', 'topn') || + (isMatrixifyVisible(controls, axis, 'dimensions', 'all') && + controls?.[`matrixify_all_sort_by_${axis}`]?.value === 'metric'), + }; + + matrixifyControls[`matrixify_all_sort_by_${axis}`] = { + type: 'SelectControl', + label: t('Sort by'), + default: 'a_to_z', + clearable: false, + renderTrigger: true, + tabOverride: 'matrixify', + visibility: ({ controls }) => + isMatrixifyVisible(controls, axis, 'dimensions', 'all'), + choices: [ + ['a_to_z', t('A-Z')], + ['z_to_a', t('Z-A')], + ['metric', t('Metric')], + ], }; }); @@ -317,24 +337,6 @@ matrixifyControls.matrixify_charts_per_row = { !controls?.matrixify_fit_columns_dynamically?.value, }; -matrixifyControls.matrixify_enable_vertical_layout = { - type: 'CheckboxControl', - label: t('Enable vertical layout (rows)'), - description: t('Create matrix rows by stacking charts vertically'), - default: false, - renderTrigger: true, - tabOverride: 'matrixify', -}; - -matrixifyControls.matrixify_enable_horizontal_layout = { - type: 'CheckboxControl', - label: t('Enable horizontal layout (columns)'), - description: t('Create matrix columns by placing charts side-by-side'), - default: false, - renderTrigger: true, - tabOverride: 'matrixify', -}; - // Cell title control for Matrixify matrixifyControls.matrixify_cell_title_template = { type: 'TextControl', @@ -345,8 +347,8 @@ matrixifyControls.matrixify_cell_title_template = { default: '', renderTrigger: true, visibility: ({ controls }) => - controls?.matrixify_enable_vertical_layout?.value === true || - controls?.matrixify_enable_horizontal_layout?.value === true, + isMatrixifyVisible(controls, 'rows') || + isMatrixifyVisible(controls, 'columns'), }; // Matrix display controls @@ -357,8 +359,7 @@ matrixifyControls.matrixify_show_row_labels = { default: true, renderTrigger: true, tabOverride: 'matrixify', - visibility: ({ controls }) => - controls?.matrixify_enable_vertical_layout?.value === true, + visibility: ({ controls }) => isMatrixifyVisible(controls, 'rows'), }; matrixifyControls.matrixify_show_column_headers = { @@ -368,8 +369,7 @@ matrixifyControls.matrixify_show_column_headers = { default: true, renderTrigger: true, tabOverride: 'matrixify', - visibility: ({ controls }) => - controls?.matrixify_enable_horizontal_layout?.value === true, + visibility: ({ controls }) => isMatrixifyVisible(controls, 'columns'), }; export { matrixifyControls }; diff --git a/superset-frontend/packages/superset-ui-core/src/chart/components/Matrixify/MatrixifyGridRenderer.test.tsx b/superset-frontend/packages/superset-ui-core/src/chart/components/Matrixify/MatrixifyGridRenderer.test.tsx index 7c1fc8d7bc71..588585372da6 100644 --- a/superset-frontend/packages/superset-ui-core/src/chart/components/Matrixify/MatrixifyGridRenderer.test.tsx +++ b/superset-frontend/packages/superset-ui-core/src/chart/components/Matrixify/MatrixifyGridRenderer.test.tsx @@ -22,6 +22,7 @@ import '@testing-library/jest-dom'; import { ThemeProvider } from '@apache-superset/core/theme'; import { supersetTheme } from '@apache-superset/core/theme'; import MatrixifyGridRenderer from './MatrixifyGridRenderer'; +import type { MatrixifyMode } from '../../types/matrixify'; import { generateMatrixifyGrid } from './MatrixifyGridGenerator'; // Mock the MatrixifyGridGenerator @@ -74,8 +75,9 @@ test('should create single group when fitting columns dynamically', () => { const formData = { viz_type: 'test_chart', - matrixify_enable_vertical_layout: true, - matrixify_enable_horizontal_layout: true, + matrixify_enable: true, + matrixify_mode_rows: 'metrics' as MatrixifyMode, + matrixify_mode_columns: 'metrics' as MatrixifyMode, matrixify_fit_columns_dynamically: true, matrixify_charts_per_row: 3, matrixify_show_row_labels: true, @@ -124,8 +126,9 @@ test('should create multiple groups when not fitting columns dynamically', () => const formData = { viz_type: 'test_chart', - matrixify_enable_vertical_layout: true, - matrixify_enable_horizontal_layout: true, + matrixify_enable: true, + matrixify_mode_rows: 'metrics' as MatrixifyMode, + matrixify_mode_columns: 'metrics' as MatrixifyMode, matrixify_fit_columns_dynamically: false, matrixify_charts_per_row: 3, matrixify_show_row_labels: true, @@ -160,8 +163,9 @@ test('should handle exact division of columns', () => { const formData = { viz_type: 'test_chart', - matrixify_enable_vertical_layout: true, - matrixify_enable_horizontal_layout: true, + matrixify_enable: true, + matrixify_mode_rows: 'metrics' as MatrixifyMode, + matrixify_mode_columns: 'metrics' as MatrixifyMode, matrixify_fit_columns_dynamically: false, matrixify_charts_per_row: 2, matrixify_show_row_labels: true, @@ -189,8 +193,9 @@ test('should handle case where charts_per_row exceeds total columns', () => { const formData = { viz_type: 'test_chart', - matrixify_enable_vertical_layout: true, - matrixify_enable_horizontal_layout: true, + matrixify_enable: true, + matrixify_mode_rows: 'metrics' as MatrixifyMode, + matrixify_mode_columns: 'metrics' as MatrixifyMode, matrixify_fit_columns_dynamically: false, matrixify_charts_per_row: 5, matrixify_show_row_labels: true, @@ -220,8 +225,9 @@ test('should show headers for each group when wrapping occurs', () => { const formData = { viz_type: 'test_chart', - matrixify_enable_vertical_layout: true, - matrixify_enable_horizontal_layout: true, + matrixify_enable: true, + matrixify_mode_rows: 'metrics' as MatrixifyMode, + matrixify_mode_columns: 'metrics' as MatrixifyMode, matrixify_fit_columns_dynamically: false, matrixify_charts_per_row: 2, matrixify_show_row_labels: true, @@ -255,8 +261,9 @@ test('should show headers only on first row when not wrapping', () => { const formData = { viz_type: 'test_chart', - matrixify_enable_vertical_layout: true, - matrixify_enable_horizontal_layout: true, + matrixify_enable: true, + matrixify_mode_rows: 'metrics' as MatrixifyMode, + matrixify_mode_columns: 'metrics' as MatrixifyMode, matrixify_fit_columns_dynamically: true, // No wrapping matrixify_show_row_labels: true, matrixify_show_column_headers: true, @@ -285,8 +292,9 @@ test('should hide headers when disabled', () => { const formData = { viz_type: 'test_chart', - matrixify_enable_vertical_layout: true, - matrixify_enable_horizontal_layout: true, + matrixify_enable: true, + matrixify_mode_rows: 'metrics' as MatrixifyMode, + matrixify_mode_columns: 'metrics' as MatrixifyMode, matrixify_show_row_labels: false, matrixify_show_column_headers: false, }; @@ -313,8 +321,9 @@ test('should place cells correctly in wrapped layout', () => { const formData = { viz_type: 'test_chart', - matrixify_enable_vertical_layout: true, - matrixify_enable_horizontal_layout: true, + matrixify_enable: true, + matrixify_mode_rows: 'metrics' as MatrixifyMode, + matrixify_mode_columns: 'metrics' as MatrixifyMode, matrixify_fit_columns_dynamically: false, matrixify_charts_per_row: 2, matrixify_show_row_labels: true, @@ -344,8 +353,9 @@ test('should handle null grid gracefully', () => { const formData = { viz_type: 'test_chart', - matrixify_enable_vertical_layout: true, - matrixify_enable_horizontal_layout: true, + matrixify_enable: true, + matrixify_mode_rows: 'metrics' as MatrixifyMode, + matrixify_mode_columns: 'metrics' as MatrixifyMode, }; const { container } = renderWithTheme( @@ -366,8 +376,9 @@ test('should handle empty grid gracefully', () => { const formData = { viz_type: 'test_chart', - matrixify_enable_vertical_layout: true, - matrixify_enable_horizontal_layout: true, + matrixify_enable: true, + matrixify_mode_rows: 'metrics' as MatrixifyMode, + matrixify_mode_columns: 'metrics' as MatrixifyMode, }; const { container } = renderWithTheme( @@ -391,8 +402,9 @@ test('should use default values for missing configuration', () => { const formData = { viz_type: 'test_chart', - matrixify_enable_vertical_layout: true, - matrixify_enable_horizontal_layout: true, + matrixify_enable: true, + matrixify_mode_rows: 'metrics' as MatrixifyMode, + matrixify_mode_columns: 'metrics' as MatrixifyMode, // Missing optional configurations }; diff --git a/superset-frontend/packages/superset-ui-core/src/chart/components/Matrixify/MatrixifyGridRenderer.tsx b/superset-frontend/packages/superset-ui-core/src/chart/components/Matrixify/MatrixifyGridRenderer.tsx index 90402f8e7822..fc4b64224595 100644 --- a/superset-frontend/packages/superset-ui-core/src/chart/components/Matrixify/MatrixifyGridRenderer.tsx +++ b/superset-frontend/packages/superset-ui-core/src/chart/components/Matrixify/MatrixifyGridRenderer.tsx @@ -130,10 +130,12 @@ function MatrixifyGridRenderer({ // Determine layout parameters - only show headers/labels if layout is enabled const showRowLabels = - formData.matrixify_enable_vertical_layout === true && + formData.matrixify_mode_rows !== undefined && + formData.matrixify_mode_rows !== 'disabled' && (formData.matrixify_show_row_labels ?? true); const showColumnHeaders = - formData.matrixify_enable_horizontal_layout === true && + formData.matrixify_mode_columns !== undefined && + formData.matrixify_mode_columns !== 'disabled' && (formData.matrixify_show_column_headers ?? true); const rowHeight = formData.matrixify_row_height || DEFAULT_ROW_HEIGHT; const fitColumnsDynamically = diff --git a/superset-frontend/packages/superset-ui-core/src/chart/types/matrixify.test.ts b/superset-frontend/packages/superset-ui-core/src/chart/types/matrixify.test.ts index 6fe052a5a740..b0eaf837fcdc 100644 --- a/superset-frontend/packages/superset-ui-core/src/chart/types/matrixify.test.ts +++ b/superset-frontend/packages/superset-ui-core/src/chart/types/matrixify.test.ts @@ -37,12 +37,11 @@ test('isMatrixifyEnabled should return false when no matrixify configuration exi expect(isMatrixifyEnabled(formData)).toBe(false); }); -test('isMatrixifyEnabled should return false when layout controls are false', () => { +test('isMatrixifyEnabled should return false when layout controls are disabled', () => { const formData = { viz_type: 'table', - matrixify_enable_vertical_layout: false, - matrixify_enable_horizontal_layout: false, - matrixify_mode_rows: 'metrics', + matrixify_mode_rows: 'disabled', + matrixify_mode_columns: 'disabled', matrixify_rows: [createMetric('Revenue')], } as MatrixifyFormData; @@ -52,7 +51,7 @@ test('isMatrixifyEnabled should return false when layout controls are false', () test('isMatrixifyEnabled should return true for valid metrics mode configuration', () => { const formData = { viz_type: 'table', - matrixify_enable_vertical_layout: true, + matrixify_enable: true, matrixify_mode_rows: 'metrics', matrixify_mode_columns: 'metrics', matrixify_rows: [createMetric('Revenue')], @@ -65,7 +64,7 @@ test('isMatrixifyEnabled should return true for valid metrics mode configuration test('isMatrixifyEnabled should return true for valid dimensions mode configuration', () => { const formData = { viz_type: 'table', - matrixify_enable_vertical_layout: true, + matrixify_enable: true, matrixify_mode_rows: 'dimensions', matrixify_mode_columns: 'dimensions', matrixify_dimension_rows: { dimension: 'country', values: ['USA'] }, @@ -78,7 +77,7 @@ test('isMatrixifyEnabled should return true for valid dimensions mode configurat test('isMatrixifyEnabled should return true for mixed mode configuration', () => { const formData = { viz_type: 'table', - matrixify_enable_vertical_layout: true, + matrixify_enable: true, matrixify_mode_rows: 'metrics', matrixify_mode_columns: 'dimensions', matrixify_rows: [createMetric('Revenue')], @@ -91,7 +90,7 @@ test('isMatrixifyEnabled should return true for mixed mode configuration', () => test('isMatrixifyEnabled should return true for topn dimension selection mode', () => { const formData = { viz_type: 'table', - matrixify_enable_vertical_layout: true, + matrixify_enable: true, matrixify_mode_rows: 'dimensions', matrixify_mode_columns: 'dimensions', matrixify_dimension_rows: { @@ -110,7 +109,7 @@ test('isMatrixifyEnabled should return true for topn dimension selection mode', test('isMatrixifyEnabled should return false when both axes have empty metrics arrays', () => { const formData = { viz_type: 'table', - matrixify_enable_vertical_layout: true, + matrixify_enable: true, matrixify_mode_rows: 'metrics', matrixify_mode_columns: 'metrics', matrixify_rows: [], @@ -123,7 +122,7 @@ test('isMatrixifyEnabled should return false when both axes have empty metrics a test('isMatrixifyEnabled should return false when both dimensions have empty values and no topn mode', () => { const formData = { viz_type: 'table', - matrixify_enable_vertical_layout: true, + matrixify_enable: true, matrixify_mode_rows: 'dimensions', matrixify_mode_columns: 'dimensions', matrixify_dimension_rows: { dimension: 'country', values: [] }, @@ -141,7 +140,6 @@ test('getMatrixifyConfig should return null when no matrixify configuration exis test('getMatrixifyConfig should return valid config for metrics mode', () => { const formData = { viz_type: 'table', - matrixify_enable_vertical_layout: true, matrixify_mode_rows: 'metrics', matrixify_mode_columns: 'metrics', matrixify_rows: [createMetric('Revenue')], @@ -159,7 +157,6 @@ test('getMatrixifyConfig should return valid config for metrics mode', () => { test('getMatrixifyConfig should return valid config for dimensions mode', () => { const formData = { viz_type: 'table', - matrixify_enable_vertical_layout: true, matrixify_mode_rows: 'dimensions', matrixify_mode_columns: 'dimensions', matrixify_dimension_rows: { dimension: 'country', values: ['USA'] }, @@ -183,7 +180,6 @@ test('getMatrixifyConfig should return valid config for dimensions mode', () => test('getMatrixifyConfig should handle topn selection mode', () => { const formData = { viz_type: 'table', - matrixify_enable_vertical_layout: true, matrixify_mode_rows: 'dimensions', matrixify_mode_columns: 'dimensions', matrixify_dimension_rows: { @@ -204,8 +200,8 @@ test('getMatrixifyConfig should handle topn selection mode', () => { test('getMatrixifyValidationErrors should return empty array when matrixify is not enabled', () => { const formData = { viz_type: 'table', - matrixify_enable_vertical_layout: false, - matrixify_enable_horizontal_layout: false, + matrixify_mode_rows: 'disabled', + matrixify_mode_columns: 'disabled', } as MatrixifyFormData; expect(getMatrixifyValidationErrors(formData)).toEqual([]); @@ -214,7 +210,6 @@ test('getMatrixifyValidationErrors should return empty array when matrixify is n test('getMatrixifyValidationErrors should return empty array when properly configured', () => { const formData = { viz_type: 'table', - matrixify_enable_vertical_layout: true, matrixify_mode_rows: 'metrics', matrixify_mode_columns: 'metrics', matrixify_rows: [createMetric('Revenue')], @@ -227,17 +222,16 @@ test('getMatrixifyValidationErrors should return empty array when properly confi test('getMatrixifyValidationErrors should return error when enabled but no configuration exists', () => { const formData = { viz_type: 'table', - matrixify_enable_vertical_layout: true, + matrixify_mode_rows: 'metrics', } as MatrixifyFormData; const errors = getMatrixifyValidationErrors(formData); - expect(errors).toContain('Please configure at least one row or column axis'); + expect(errors.length).toBeGreaterThan(0); }); test('getMatrixifyValidationErrors should return error when metrics mode has no metrics', () => { const formData = { viz_type: 'table', - matrixify_enable_vertical_layout: true, matrixify_mode_rows: 'metrics', matrixify_rows: [], matrixify_columns: [], @@ -260,19 +254,28 @@ test('should handle empty form data object', () => { expect(isMatrixifyEnabled(formData)).toBe(false); }); -test('isMatrixifyEnabled should return false when layout enabled but no axis modes configured', () => { +test('isMatrixifyEnabled should return false when no axis modes configured', () => { const formData = { viz_type: 'table', - matrixify_enable_vertical_layout: true, + matrixify_enable: true, // No matrixify_mode_rows or matrixify_mode_columns set } as MatrixifyFormData; expect(isMatrixifyEnabled(formData)).toBe(false); }); +test('isMatrixifyEnabled should return false when switch is off even with valid axis config', () => { + const formData = { + viz_type: 'table', + matrixify_enable: false, + matrixify_mode_rows: 'metrics', + matrixify_rows: [createMetric('Revenue')], + } as MatrixifyFormData; + expect(isMatrixifyEnabled(formData)).toBe(false); +}); + test('getMatrixifyValidationErrors should return dimension error for rows when dimension has no data', () => { const formData = { viz_type: 'table', - matrixify_enable_vertical_layout: true, matrixify_mode_rows: 'dimensions', // No matrixify_dimension_rows set matrixify_mode_columns: 'metrics', @@ -286,7 +289,6 @@ test('getMatrixifyValidationErrors should return dimension error for rows when d test('getMatrixifyValidationErrors should return metric error for columns when metrics array is empty', () => { const formData = { viz_type: 'table', - matrixify_enable_vertical_layout: true, matrixify_mode_rows: 'metrics', matrixify_rows: [createMetric('Revenue')], matrixify_mode_columns: 'metrics', @@ -300,7 +302,6 @@ test('getMatrixifyValidationErrors should return metric error for columns when m test('getMatrixifyValidationErrors should return dimension error for columns when no dimension data', () => { const formData = { viz_type: 'table', - matrixify_enable_vertical_layout: true, matrixify_mode_rows: 'metrics', matrixify_rows: [createMetric('Revenue')], matrixify_mode_columns: 'dimensions', @@ -311,10 +312,9 @@ test('getMatrixifyValidationErrors should return dimension error for columns whe expect(errors).toContain('Please select a dimension and values for columns'); }); -test('getMatrixifyValidationErrors skips row check when matrixify_mode_rows is not set (line 240 false, line 279 || false)', () => { +test('getMatrixifyValidationErrors skips row check when matrixify_mode_rows is not set', () => { const formData = { viz_type: 'table', - matrixify_enable_vertical_layout: true, // No matrixify_mode_rows — hasRowMode = false matrixify_mode_columns: 'metrics', matrixify_columns: [createMetric('Q1')], @@ -324,10 +324,9 @@ test('getMatrixifyValidationErrors skips row check when matrixify_mode_rows is n expect(errors).toEqual([]); }); -test('getMatrixifyValidationErrors evaluates full && expression when dimension is set but values are empty (lines 244, 264, 283, 291 true branches)', () => { +test('getMatrixifyValidationErrors evaluates full && expression when dimension is set but values are empty', () => { const formData = { viz_type: 'table', - matrixify_enable_vertical_layout: true, matrixify_mode_rows: 'dimensions', matrixify_dimension_rows: { dimension: 'country', values: [] }, matrixify_mode_columns: 'dimensions', @@ -345,7 +344,7 @@ test('getMatrixifyValidationErrors evaluates full && expression when dimension i test('should handle partial configuration with one axis only', () => { const formData = { viz_type: 'table', - matrixify_enable_vertical_layout: true, + matrixify_enable: true, matrixify_mode_rows: 'metrics', matrixify_rows: [createMetric('Revenue')], // No columns configuration diff --git a/superset-frontend/packages/superset-ui-core/src/chart/types/matrixify.ts b/superset-frontend/packages/superset-ui-core/src/chart/types/matrixify.ts index 8c01651ca600..e43dbe31406b 100644 --- a/superset-frontend/packages/superset-ui-core/src/chart/types/matrixify.ts +++ b/superset-frontend/packages/superset-ui-core/src/chart/types/matrixify.ts @@ -23,6 +23,7 @@ import { AdhocMetric } from '../../query'; * Constants for Matrixify filter generation * These match the literal types used in Filter.ts */ + export const MatrixifyFilterConstants = { // Filter expression types ExpressionType: { @@ -46,12 +47,12 @@ export const MatrixifyFilterConstants = { /** * Mode for selecting matrix axis values */ -export type MatrixifyMode = 'metrics' | 'dimensions'; +export type MatrixifyMode = 'disabled' | 'metrics' | 'dimensions'; /** * Selection method for dimension values */ -export type MatrixifySelectionMode = 'members' | 'topn'; +export type MatrixifySelectionMode = 'members' | 'topn' | 'all'; /** * Sort order for top N selection @@ -96,18 +97,18 @@ export interface MatrixifyAxisConfig { * Complete Matrixify configuration in form data */ export interface MatrixifyFormData { - // Layout enable controls - matrixify_enable_vertical_layout?: boolean; - matrixify_enable_horizontal_layout?: boolean; + // Global enable switch + matrixify_enable?: boolean; - // Row axis configuration + // Row axis configuration (mode 'disabled' means axis is off) matrixify_mode_rows?: MatrixifyMode; matrixify_rows?: AdhocMetric[]; matrixify_dimension_selection_mode_rows?: MatrixifySelectionMode; matrixify_dimension_rows?: MatrixifyDimensionValue; matrixify_topn_value_rows?: number; matrixify_topn_metric_rows?: AdhocMetric; - matrixify_topn_order_rows?: MatrixifySortOrder; + matrixify_topn_order_rows?: boolean; + matrixify_all_sort_by_rows?: 'a_to_z' | 'z_to_a' | 'metric'; // Column axis configuration matrixify_mode_columns?: MatrixifyMode; @@ -116,7 +117,8 @@ export interface MatrixifyFormData { matrixify_dimension_columns?: MatrixifyDimensionValue; matrixify_topn_value_columns?: number; matrixify_topn_metric_columns?: AdhocMetric; - matrixify_topn_order_columns?: MatrixifySortOrder; + matrixify_topn_order_columns?: boolean; + matrixify_all_sort_by_columns?: 'a_to_z' | 'z_to_a' | 'metric'; // Grid layout configuration matrixify_row_height?: number; @@ -139,75 +141,85 @@ export interface MatrixifyConfig { columns: MatrixifyAxisConfig; } +/** + * Check if a given axis mode is active (not disabled) + */ +function isAxisEnabled(mode?: MatrixifyMode): boolean { + return mode === 'metrics' || mode === 'dimensions'; +} + /** * Helper function to extract Matrixify configuration from form data */ export function getMatrixifyConfig( formData: MatrixifyFormData & any, ): MatrixifyConfig | null { - const hasRowConfig = formData.matrixify_mode_rows; - const hasColumnConfig = formData.matrixify_mode_columns; + const rowEnabled = isAxisEnabled(formData.matrixify_mode_rows); + const colEnabled = isAxisEnabled(formData.matrixify_mode_columns); - if (!hasRowConfig && !hasColumnConfig) { + if (!rowEnabled && !colEnabled) { return null; } return { rows: { - mode: formData.matrixify_mode_rows || 'metrics', + mode: formData.matrixify_mode_rows || 'disabled', metrics: formData.matrixify_rows, selectionMode: formData.matrixify_dimension_selection_mode_rows, dimension: formData.matrixify_dimension_rows, topnValue: formData.matrixify_topn_value_rows, topnMetric: formData.matrixify_topn_metric_rows, - topnOrder: formData.matrixify_topn_order_rows, + topnOrder: formData.matrixify_topn_order_rows === false ? 'asc' : 'desc', }, columns: { - mode: formData.matrixify_mode_columns || 'metrics', + mode: formData.matrixify_mode_columns || 'disabled', metrics: formData.matrixify_columns, selectionMode: formData.matrixify_dimension_selection_mode_columns, dimension: formData.matrixify_dimension_columns, topnValue: formData.matrixify_topn_value_columns, topnMetric: formData.matrixify_topn_metric_columns, - topnOrder: formData.matrixify_topn_order_columns, + topnOrder: + formData.matrixify_topn_order_columns === false ? 'asc' : 'desc', }, }; } -/** - * Check if Matrixify is enabled and properly configured - */ export function isMatrixifyEnabled(formData: MatrixifyFormData): boolean { - // Check if either vertical or horizontal layout is enabled - const hasVerticalLayout = formData.matrixify_enable_vertical_layout === true; - const hasHorizontalLayout = - formData.matrixify_enable_horizontal_layout === true; + if (formData.matrixify_enable !== true) { + return false; + } - if (!hasVerticalLayout && !hasHorizontalLayout) { + const rowEnabled = isAxisEnabled(formData.matrixify_mode_rows); + const colEnabled = isAxisEnabled(formData.matrixify_mode_columns); + + if (!rowEnabled && !colEnabled) { return false; } - // Then validate that we have proper configuration const config = getMatrixifyConfig(formData); if (!config) { return false; } const hasRowData = - config.rows.mode === 'metrics' + rowEnabled && + (config.rows.mode === 'metrics' ? config.rows.metrics && config.rows.metrics.length > 0 : config.rows.dimension?.dimension && (config.rows.selectionMode === 'topn' || + config.rows.selectionMode === 'all' || (config.rows.dimension.values && - config.rows.dimension.values.length > 0)); + config.rows.dimension.values.length > 0))); const hasColumnData = - config.columns.mode === 'metrics' + colEnabled && + (config.columns.mode === 'metrics' ? config.columns.metrics && config.columns.metrics.length > 0 : config.columns.dimension?.dimension && (config.columns.selectionMode === 'topn' || + config.columns.selectionMode === 'all' || (config.columns.dimension.values && - config.columns.dimension.values.length > 0)); + config.columns.dimension.values.length > 0))); return Boolean(hasRowData || hasColumnData); } @@ -220,12 +232,10 @@ export function getMatrixifyValidationErrors( ): string[] { const errors: string[] = []; - // Only validate if matrixify is enabled - const hasVerticalLayout = formData.matrixify_enable_vertical_layout === true; - const hasHorizontalLayout = - formData.matrixify_enable_horizontal_layout === true; + const rowEnabled = isAxisEnabled(formData.matrixify_mode_rows); + const colEnabled = isAxisEnabled(formData.matrixify_mode_columns); - if (!hasVerticalLayout && !hasHorizontalLayout) { + if (!rowEnabled && !colEnabled) { return errors; } @@ -243,6 +253,7 @@ export function getMatrixifyValidationErrors( ? config.rows.metrics && config.rows.metrics.length > 0 : config.rows.dimension?.dimension && (config.rows.selectionMode === 'topn' || + config.rows.selectionMode === 'all' || (config.rows.dimension.values && config.rows.dimension.values.length > 0)); @@ -263,6 +274,7 @@ export function getMatrixifyValidationErrors( ? config.columns.metrics && config.columns.metrics.length > 0 : config.columns.dimension?.dimension && (config.columns.selectionMode === 'topn' || + config.columns.selectionMode === 'all' || (config.columns.dimension.values && config.columns.dimension.values.length > 0)); @@ -281,6 +293,7 @@ export function getMatrixifyValidationErrors( ? config.rows.metrics && config.rows.metrics.length > 0 : config.rows.dimension?.dimension && (config.rows.selectionMode === 'topn' || + config.rows.selectionMode === 'all' || (config.rows.dimension.values && config.rows.dimension.values.length > 0)); @@ -289,6 +302,7 @@ export function getMatrixifyValidationErrors( ? config.columns.metrics && config.columns.metrics.length > 0 : config.columns.dimension?.dimension && (config.columns.selectionMode === 'topn' || + config.columns.selectionMode === 'all' || (config.columns.dimension.values && config.columns.dimension.values.length > 0)); diff --git a/superset-frontend/src/components/Chart/ChartContextMenu/ChartContextMenu.tsx b/superset-frontend/src/components/Chart/ChartContextMenu/ChartContextMenu.tsx index ecb52a7d87d4..cdab2f54af57 100644 --- a/superset-frontend/src/components/Chart/ChartContextMenu/ChartContextMenu.tsx +++ b/superset-frontend/src/components/Chart/ChartContextMenu/ChartContextMenu.tsx @@ -187,8 +187,10 @@ const ChartContextMenu = ( canDrillBy && isDisplayed(ContextMenuItem.DrillBy) && !( - formData.matrixify_enable_vertical_layout === true || - formData.matrixify_enable_horizontal_layout === true + (formData.matrixify_mode_rows !== undefined && + formData.matrixify_mode_rows !== 'disabled') || + (formData.matrixify_mode_columns !== undefined && + formData.matrixify_mode_columns !== 'disabled') ); // Disable drill by when matrixify is enabled const datasetResource = useDatasetDrillInfo( diff --git a/superset-frontend/src/components/Chart/ChartRenderer.test.tsx b/superset-frontend/src/components/Chart/ChartRenderer.test.tsx index 1c35af4efc80..9fd400fd5624 100644 --- a/superset-frontend/src/components/Chart/ChartRenderer.test.tsx +++ b/superset-frontend/src/components/Chart/ChartRenderer.test.tsx @@ -163,7 +163,7 @@ test('should detect changes in matrixify properties', () => { ...requiredProps.formData, datasource: '', viz_type: VizType.Table, - matrixify_enable_vertical_layout: true, + matrixify_mode_rows: 'metrics', matrixify_dimension_x: { dimension: 'country', values: ['USA'] }, matrixify_dimension_y: { dimension: 'category', values: ['Tech'] }, matrixify_charts_per_row: 3, @@ -177,9 +177,9 @@ test('should detect changes in matrixify properties', () => { // Since we can't directly test shouldComponentUpdate, we verify the component // correctly identifies matrixify-related properties by checking the implementation - expect( - (initialProps.formData as JsonObject).matrixify_enable_vertical_layout, - ).toBe(true); + expect((initialProps.formData as JsonObject).matrixify_mode_rows).toBe( + 'metrics', + ); expect((initialProps.formData as JsonObject).matrixify_dimension_x).toEqual({ dimension: 'country', values: ['USA'], @@ -212,7 +212,7 @@ test('should identify matrixify property changes correctly', () => { formData: { datasource: '', viz_type: VizType.Table, - matrixify_enable_vertical_layout: true, + matrixify_mode_rows: 'metrics', matrixify_dimension_x: { dimension: 'country', values: ['USA'] }, matrixify_charts_per_row: 3, }, @@ -234,7 +234,7 @@ test('should identify matrixify property changes correctly', () => { formData: { datasource: '', viz_type: VizType.Table, - matrixify_enable_vertical_layout: true, + matrixify_mode_rows: 'metrics', matrixify_dimension_x: { dimension: 'country', values: ['USA', 'Canada'], // Changed @@ -277,7 +277,7 @@ test('should handle matrixify-related form data changes', () => { formData: { datasource: '', viz_type: VizType.Table, - matrixify_enable_vertical_layout: true, // This is a significant change + matrixify_mode_rows: 'metrics', // This is a significant change regular_control: 'value1', }, }; @@ -296,7 +296,7 @@ test('should detect matrixify property addition', () => { formData: { datasource: '', viz_type: VizType.Table, - matrixify_enable_vertical_layout: true, + matrixify_mode_rows: 'metrics', // No matrixify_dimension_x initially }, queriesResponse: [{ data: 'current' } as unknown as JsonObject], @@ -317,7 +317,7 @@ test('should detect matrixify property addition', () => { formData: { datasource: '', viz_type: VizType.Table, - matrixify_enable_vertical_layout: true, + matrixify_mode_rows: 'metrics', matrixify_dimension_x: { dimension: 'country', values: ['USA'] }, // Added }, }; @@ -336,7 +336,7 @@ test('should detect nested matrixify property changes', () => { formData: { datasource: '', viz_type: VizType.Table, - matrixify_enable_vertical_layout: true, + matrixify_mode_rows: 'metrics', matrixify_dimension_x: { dimension: 'country', values: ['USA'], @@ -361,7 +361,7 @@ test('should detect nested matrixify property changes', () => { formData: { datasource: '', viz_type: VizType.Table, - matrixify_enable_vertical_layout: true, + matrixify_mode_rows: 'metrics', matrixify_dimension_x: { dimension: 'country', values: ['USA'], diff --git a/superset-frontend/src/components/Chart/ChartRenderer.tsx b/superset-frontend/src/components/Chart/ChartRenderer.tsx index 33528f78a383..b58b3966b36f 100644 --- a/superset-frontend/src/components/Chart/ChartRenderer.tsx +++ b/superset-frontend/src/components/Chart/ChartRenderer.tsx @@ -275,8 +275,10 @@ class ChartRenderer extends Component { const nextFormData = nextProps.formData as JsonObject; const currentFormData = this.props.formData as JsonObject; const isMatrixifyEnabled = - nextFormData.matrixify_enable_vertical_layout === true || - nextFormData.matrixify_enable_horizontal_layout === true; + (nextFormData.matrixify_mode_rows !== undefined && + nextFormData.matrixify_mode_rows !== 'disabled') || + (nextFormData.matrixify_mode_columns !== undefined && + nextFormData.matrixify_mode_columns !== 'disabled'); if (!isMatrixifyEnabled) return false; // Check all matrixify-related properties diff --git a/superset-frontend/src/components/Chart/DrillBy/DrillBySubmenu.test.tsx b/superset-frontend/src/components/Chart/DrillBy/DrillBySubmenu.test.tsx index 3b60e756911a..1a7f23a9e4a8 100644 --- a/superset-frontend/src/components/Chart/DrillBy/DrillBySubmenu.test.tsx +++ b/superset-frontend/src/components/Chart/DrillBy/DrillBySubmenu.test.tsx @@ -272,9 +272,9 @@ test('When menu item is clicked, call onSelection with clicked column and drill ); }); -test('matrixify_enable_vertical_layout should not render component', () => { +test('matrixify_mode_rows enabled should not render component', () => { const { container } = renderSubmenu({ - formData: { ...defaultFormData, matrixify_enable_vertical_layout: true }, + formData: { ...defaultFormData, matrixify_mode_rows: 'metrics' }, }); expect(container).toBeEmptyDOMElement(); }); diff --git a/superset-frontend/src/components/Chart/DrillBy/DrillBySubmenu.tsx b/superset-frontend/src/components/Chart/DrillBy/DrillBySubmenu.tsx index 8bb347e7dfab..40e18a264be5 100644 --- a/superset-frontend/src/components/Chart/DrillBy/DrillBySubmenu.tsx +++ b/superset-frontend/src/components/Chart/DrillBy/DrillBySubmenu.tsx @@ -179,8 +179,10 @@ export const DrillBySubmenu = ({ } if ( - formData.matrixify_enable_vertical_layout === true || - formData.matrixify_enable_horizontal_layout === true + (formData.matrixify_mode_rows !== undefined && + formData.matrixify_mode_rows !== 'disabled') || + (formData.matrixify_mode_columns !== undefined && + formData.matrixify_mode_columns !== 'disabled') ) { return null; } diff --git a/superset-frontend/src/explore/components/ControlPanelsContainer.test.tsx b/superset-frontend/src/explore/components/ControlPanelsContainer.test.tsx index fe67e52eb6fc..466c56ee1ac7 100644 --- a/superset-frontend/src/explore/components/ControlPanelsContainer.test.tsx +++ b/superset-frontend/src/explore/components/ControlPanelsContainer.test.tsx @@ -307,7 +307,8 @@ describe('ControlPanelsContainer', () => { props.form_data = { ...props.form_data, viz_type: 'line', - matrixify_enable_vertical_layout: true, + matrixify_enable: true, + matrixify_mode_rows: 'metrics', }; const { rerender } = render(, { @@ -327,7 +328,8 @@ describe('ControlPanelsContainer', () => { form_data: { ...props.form_data, viz_type: 'line', - matrixify_enable_vertical_layout: true, + matrixify_enable: true, + matrixify_mode_rows: 'metrics', matrixify_dimension_columns: { dimension: 'country', values: ['USA', 'Canada'], @@ -381,7 +383,8 @@ describe('ControlPanelsContainer', () => { form_data: { ...props.form_data, viz_type: 'line', - matrixify_enable_horizontal_layout: true, + matrixify_enable: true, + matrixify_mode_columns: 'metrics', }, }; diff --git a/superset-frontend/src/explore/components/ControlPanelsContainer.tsx b/superset-frontend/src/explore/components/ControlPanelsContainer.tsx index fba0cc3c0fd5..0a1c79ab9fe4 100644 --- a/superset-frontend/src/explore/components/ControlPanelsContainer.tsx +++ b/superset-frontend/src/explore/components/ControlPanelsContainer.tsx @@ -816,8 +816,11 @@ export const ControlPanelsContainer = (props: ControlPanelsContainerProps) => { // Check if matrixify is enabled in form_data const matrixifyIsEnabled = - form_data.matrixify_enable_vertical_layout || - form_data.matrixify_enable_horizontal_layout; + form_data.matrixify_enable === true && + ((form_data.matrixify_mode_rows !== undefined && + form_data.matrixify_mode_rows !== 'disabled') || + (form_data.matrixify_mode_columns !== undefined && + form_data.matrixify_mode_columns !== 'disabled')); // Auto-switch to Matrixify tab when it's enabled useEffect(() => { diff --git a/superset-frontend/src/explore/components/ExploreChartHeader/ExploreChartHeader.test.tsx b/superset-frontend/src/explore/components/ExploreChartHeader/ExploreChartHeader.test.tsx index 3417dab64763..c060c193c3c6 100644 --- a/superset-frontend/src/explore/components/ExploreChartHeader/ExploreChartHeader.test.tsx +++ b/superset-frontend/src/explore/components/ExploreChartHeader/ExploreChartHeader.test.tsx @@ -549,7 +549,7 @@ describe('ExploreChartHeader', () => { const props = createProps({ formData: { ...createProps().chart.latestQueryFormData, - matrixify_enable_vertical_layout: true, + matrixify_enable: true, matrixify_mode_rows: 'metrics', matrixify_rows: [{ label: 'COUNT(*)', expressionType: 'SIMPLE' }], }, diff --git a/superset-frontend/src/explore/components/ExploreChartPanel/index.tsx b/superset-frontend/src/explore/components/ExploreChartPanel/index.tsx index b7bd508f959d..3160189c3fbf 100644 --- a/superset-frontend/src/explore/components/ExploreChartPanel/index.tsx +++ b/superset-frontend/src/explore/components/ExploreChartPanel/index.tsx @@ -455,8 +455,10 @@ const ExploreChartPanel = ({ })} {...(chart.chartStatus && { chartStatus: chart.chartStatus })} hideRowCount={ - formData?.matrixify_enable_vertical_layout === true || - formData?.matrixify_enable_horizontal_layout === true + (formData?.matrixify_mode_rows !== undefined && + formData?.matrixify_mode_rows !== 'disabled') || + (formData?.matrixify_mode_columns !== undefined && + formData?.matrixify_mode_columns !== 'disabled') } formData={formData} /> @@ -488,8 +490,8 @@ const ExploreChartPanel = ({ chart.chartUpdateEndTime, refreshCachedQuery, formData?.row_limit, - formData?.matrixify_enable_vertical_layout, - formData?.matrixify_enable_horizontal_layout, + formData?.matrixify_mode_rows, + formData?.matrixify_mode_columns, renderChart, theme.sizeUnit, ], diff --git a/superset-frontend/src/explore/components/controls/MatrixifyDimensionControl.tsx b/superset-frontend/src/explore/components/controls/MatrixifyDimensionControl.tsx index 3832b84d8712..d1417b0f0200 100644 --- a/superset-frontend/src/explore/components/controls/MatrixifyDimensionControl.tsx +++ b/superset-frontend/src/explore/components/controls/MatrixifyDimensionControl.tsx @@ -32,6 +32,7 @@ export interface MatrixifyDimensionControlValue { dimension: string; values: any[]; topNValues?: TopNValue[]; // Store topN values with their metric values + totalValueCount?: number; // Total number of distinct values for this dimension } interface MatrixifyDimensionControlProps { @@ -42,10 +43,11 @@ interface MatrixifyDimensionControlProps { description?: string; hovered?: boolean; renderTrigger?: boolean; - selectionMode?: 'members' | 'topn'; + selectionMode?: 'members' | 'topn' | 'all'; topNMetric?: string; topNValue?: number; topNOrder?: 'ASC' | 'DESC'; + allSortBy?: 'a_to_z' | 'z_to_a' | 'metric'; formData?: any; // For access to filters and time range validationErrors?: string[]; } @@ -64,6 +66,7 @@ export default function MatrixifyDimensionControl( topNMetric, topNValue, topNOrder = 'DESC', + allSortBy = 'a_to_z', formData, validationErrors, } = props; @@ -109,15 +112,19 @@ export default function MatrixifyDimensionControl( } }, [datasource]); - // Load dimension values when dimension changes + // Load dimension values when dimension changes (members mode, or all mode with A-Z/Z-A sort) + const isAllWithMetric = selectionMode === 'all' && allSortBy === 'metric'; useEffect(() => { if ( !value?.dimension || !datasource || !datasource.id || - selectionMode !== 'members' + (selectionMode !== 'members' && selectionMode !== 'all') || + isAllWithMetric ) { - setValueOptions([]); + if (selectionMode !== 'members' && !isAllWithMetric) { + setValueOptions([]); + } return undefined; } @@ -142,13 +149,43 @@ export default function MatrixifyDimensionControl( signal, endpoint, }); - const values = json.result || []; + let values = json.result || []; + + // Sort alphabetically for 'all' mode + if (selectionMode === 'all') { + const descending = allSortBy === 'z_to_a'; + values = [...values].sort((a: any, b: any) => { + const strA = String(a).toLowerCase(); + const strB = String(b).toLowerCase(); + if (strA < strB) return descending ? 1 : -1; + if (strA > strB) return descending ? -1 : 1; + return 0; + }); + } + setValueOptions( values.map((v: any) => ({ label: optionLabel(v), value: v, })), ); + + if (!signal.aborted) { + const MAX_ALL_DIMENSION_VALUES = 25; + const allValues = + selectionMode === 'all' + ? values.slice(0, MAX_ALL_DIMENSION_VALUES) + : value.values || []; + const updatedValue: MatrixifyDimensionControlValue = { + dimension: value.dimension, + values: allValues, + totalValueCount: values.length, + }; + if (value.topNValues) { + updatedValue.topNValues = value.topNValues; + } + onChange(updatedValue); + } } catch (error) { setValueOptions([]); } finally { @@ -161,7 +198,7 @@ export default function MatrixifyDimensionControl( return () => { controller.abort(); }; - }, [value?.dimension, datasource, selectionMode]); + }, [value?.dimension, datasource, selectionMode, allSortBy]); // Convert topNValue to number for consistent comparison const topNValueNum = useMemo(() => { @@ -176,17 +213,27 @@ export default function MatrixifyDimensionControl( return typeof topNValue === 'number' ? topNValue : null; }, [topNValue]); - // Load TopN values when in TopN mode + // Load TopN values when in TopN mode, or All + Metric sort useEffect(() => { - if (!value?.dimension || !datasource || selectionMode !== 'topn') { + const isTopN = selectionMode === 'topn'; + const isAllMetric = selectionMode === 'all' && allSortBy === 'metric'; + + if (!value?.dimension || !datasource || (!isTopN && !isAllMetric)) { return undefined; } - // If we don't have the required topN parameters, just return without loading - if (!topNMetric || !topNValueNum || topNValueNum <= 0) { + if (!topNMetric) { return undefined; } + // For topn mode, also require a valid limit + if (isTopN && (!topNValueNum || topNValueNum <= 0)) { + return undefined; + } + + const MAX_ALL_DIMENSION_VALUES = 25; + const limit = isAllMetric ? MAX_ALL_DIMENSION_VALUES : topNValueNum!; + const controller = new AbortController(); const { signal } = controller; @@ -199,14 +246,13 @@ export default function MatrixifyDimensionControl( datasource: datasourceId, column: value.dimension, metric: topNMetric, - limit: topNValueNum, + limit, sortAscending: topNOrder === 'ASC', filters: formData?.adhoc_filters || [], timeRange: formData?.time_range, }); if (!signal.aborted) { - // Always update with the new topN values const dimensionValues = extractDimensionValues(values); onChange({ dimension: value.dimension, @@ -217,7 +263,6 @@ export default function MatrixifyDimensionControl( } catch (error: any) { if (!signal.aborted) { setTopNError(error.message || t('Failed to load top values')); - // Clear values on error onChange({ dimension: value.dimension, values: [], @@ -236,8 +281,9 @@ export default function MatrixifyDimensionControl( value?.dimension, datasource, selectionMode, + allSortBy, topNMetric, - topNValueNum, // Use the converted/validated number + topNValueNum, topNOrder, formData?.adhoc_filters, formData?.time_range, diff --git a/superset-frontend/src/explore/components/controls/SwitchControl.tsx b/superset-frontend/src/explore/components/controls/SwitchControl.tsx new file mode 100644 index 000000000000..7823dac011d7 --- /dev/null +++ b/superset-frontend/src/explore/components/controls/SwitchControl.tsx @@ -0,0 +1,65 @@ +/** + * 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. + */ +import { type ReactNode } from 'react'; +import { css } from '@apache-superset/core/theme'; +import { Switch } from '@superset-ui/core/components'; +import ControlHeader from '../ControlHeader'; + +interface SwitchControlProps { + value?: boolean; + label?: ReactNode; + description?: ReactNode; + hovered?: boolean; + onChange?: (value: boolean) => void; + validationErrors?: string[]; +} + +export default function SwitchControl({ + value = false, + onChange, + ...props +}: SwitchControlProps) { + const handleChange = (checked: boolean) => { + onChange?.(checked); + }; + + const switchNode = ( + + ); + + if (props.label) { + return ( +
+ handleChange(!value)} + /> +
+ ); + } + return switchNode; +} diff --git a/superset-frontend/src/explore/components/controls/VerticalRadioControl.tsx b/superset-frontend/src/explore/components/controls/VerticalRadioControl.tsx new file mode 100644 index 000000000000..ee382d7faf93 --- /dev/null +++ b/superset-frontend/src/explore/components/controls/VerticalRadioControl.tsx @@ -0,0 +1,104 @@ +/** + * 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. + */ +import { type ReactNode } from 'react'; +import { css, useTheme } from '@apache-superset/core/theme'; +import { JsonValue } from '@superset-ui/core'; +import { Radio } from '@superset-ui/core/components/Radio'; +import { Space } from '@superset-ui/core/components/Space'; +import { Tooltip } from '@superset-ui/core/components/Tooltip'; +import { Icons } from '@superset-ui/core/components/Icons'; +import ControlHeader from '../ControlHeader'; + +interface RadioOption { + value: JsonValue; + label: ReactNode; + disabled?: boolean; + tooltip?: string; +} + +type RadioOptionTuple = [JsonValue, ReactNode]; + +interface VerticalRadioControlProps { + value?: JsonValue; + label?: ReactNode; + description?: ReactNode; + hovered?: boolean; + options: (RadioOption | RadioOptionTuple)[]; + onChange: (value: JsonValue) => void; + validationErrors?: string[]; +} + +function normalizeOption(option: RadioOption | RadioOptionTuple): RadioOption { + if (Array.isArray(option)) { + return { value: option[0], label: option[1] }; + } + return option; +} + +export default function VerticalRadioControl({ + value: initialValue, + options, + onChange, + ...props +}: VerticalRadioControlProps) { + const theme = useTheme(); + const normalizedOptions = options.map(normalizeOption); + const currentValue = initialValue ?? normalizedOptions[0]?.value; + + return ( +
+ + onChange(e.target.value)} + > + + {normalizedOptions.map( + ({ value: val, label, disabled = false, tooltip }) => ( + + {label} + {tooltip && ( + + + + )} + + ), + )} + + +
+ ); +} diff --git a/superset-frontend/src/explore/components/controls/index.ts b/superset-frontend/src/explore/components/controls/index.ts index 326da43ce840..1550df945e74 100644 --- a/superset-frontend/src/explore/components/controls/index.ts +++ b/superset-frontend/src/explore/components/controls/index.ts @@ -60,6 +60,8 @@ import TimeRangeControl from './TimeRangeControl'; import ColorBreakpointsControl from './ColorBreakpointsControl'; import MatrixifyDimensionControl from './MatrixifyDimensionControl'; import JSEditorControl from './JSEditorControl'; +import SwitchControl from './SwitchControl'; +import VerticalRadioControl from './VerticalRadioControl'; const extensionsRegistry = getExtensionsRegistry(); const DateFilterControlExtension = extensionsRegistry.get( @@ -109,6 +111,8 @@ const controlMap = { NumberControl, TimeRangeControl, MatrixifyDimensionControl, + SwitchControl, + VerticalRadioControl, ...sharedControlComponents, }; export default controlMap; diff --git a/superset-frontend/src/explore/controlPanels/sections.tsx b/superset-frontend/src/explore/controlPanels/sections.tsx index 816d3e186044..bac8cddaf754 100644 --- a/superset-frontend/src/explore/controlPanels/sections.tsx +++ b/superset-frontend/src/explore/controlPanels/sections.tsx @@ -277,57 +277,77 @@ export const NVD3TimeSeries: ControlPanelSectionConfig[] = [ function buildMatrixifySection( axis: 'columns' | 'rows', ): ControlPanelSectionConfig { - const baseControls = [ - [`matrixify_mode_${axis}`], - [`matrixify_${axis}`], - [`matrixify_dimension_selection_mode_${axis}`], - [`matrixify_dimension_${axis}`], - [`matrixify_topn_dimension_${axis}`], - [`matrixify_topn_value_${axis}`], - [`matrixify_topn_metric_${axis}`], - [`matrixify_topn_order_${axis}`], - ]; - - // Add enable checkbox at the beginning of each section - const enableControl = + const customizationControls = axis === 'rows' - ? 'matrixify_enable_vertical_layout' - : 'matrixify_enable_horizontal_layout'; - - baseControls.unshift([enableControl]); - - // Add specific controls for each axis - if (axis === 'rows') { - // Add show row labels after enable - baseControls.splice(1, 0, ['matrixify_show_row_labels']); - } else if (axis === 'columns') { - // Add show column headers after enable - baseControls.splice(1, 0, ['matrixify_show_column_headers']); - } + ? ['matrixify_show_row_labels', 'matrixify_row_height'] + : ['matrixify_show_column_headers', 'matrixify_fit_columns_dynamically']; return { label: axis === 'columns' - ? t('Horizontal layout (columns)') - : t('Vertical layout (rows)'), + ? t('Columns (horizontal layout)') + : t('Rows (vertical layout)'), expanded: true, tabOverride: 'matrixify', - controlSetRows: baseControls, + visibility: ({ controls }) => controls?.matrixify_enable?.value === true, + controlSetRows: [ + [`matrixify_mode_${axis}`], + [`matrixify_${axis}`], + [`matrixify_dimension_selection_mode_${axis}`], + [`matrixify_dimension_${axis}`], + [`matrixify_topn_dimension_${axis}`], + [`matrixify_topn_value_${axis}`], + [`matrixify_all_sort_by_${axis}`], + [`matrixify_topn_metric_${axis}`], + [`matrixify_topn_order_${axis}`], + [ + + {t('Customization and styling')} + , + ], + customizationControls, + ], }; } export const matrixifyRows = buildMatrixifySection('rows'); export const matrixifyColumns = buildMatrixifySection('columns'); +export const matrixifyEnableSection: ControlPanelSectionConfig = { + label: t('Matrixify'), + expanded: true, + tabOverride: 'matrixify', + controlSetRows: [ + [ + { + name: 'matrixify_enable', + config: { + type: 'SwitchControl', + label: t('Enable matrixify'), + default: false, + renderTrigger: true, + }, + }, + ], + ], +}; + export const matrixifyCells: ControlPanelSectionConfig = { label: t('Cell layout & styling'), expanded: true, tabOverride: 'matrixify', - visibility: ({ controls }) => - controls?.matrixify_enable_vertical_layout?.value === true || - controls?.matrixify_enable_horizontal_layout?.value === true, + visibility: ({ controls }) => { + if (controls?.matrixify_enable?.value !== true) return false; + const rowMode = controls?.matrixify_mode_rows?.value; + const colMode = controls?.matrixify_mode_columns?.value; + return ( + rowMode === 'metrics' || + rowMode === 'dimensions' || + colMode === 'metrics' || + colMode === 'dimensions' + ); + }, controlSetRows: [ - ['matrixify_row_height', 'matrixify_fit_columns_dynamically'], ['matrixify_charts_per_row'], ['matrixify_cell_title_template'], ], diff --git a/superset-frontend/src/pages/ChartList/ChartList.testHelpers.tsx b/superset-frontend/src/pages/ChartList/ChartList.testHelpers.tsx index edd32b909f66..1a27d5affebf 100644 --- a/superset-frontend/src/pages/ChartList/ChartList.testHelpers.tsx +++ b/superset-frontend/src/pages/ChartList/ChartList.testHelpers.tsx @@ -65,7 +65,7 @@ export const mockCharts = [ // Add form_data with matrixify enabled form_data: { viz_type: 'table', - matrixify_enable_vertical_layout: true, + matrixify_enable: true, matrixify_mode_rows: 'metrics', matrixify_rows: [{ label: 'COUNT(*)', expressionType: 'SIMPLE' }], },