From a143e57a1b3c4d69ed74833f6ab2596d6536117b Mon Sep 17 00:00:00 2001 From: Claude Code Date: Thu, 2 Jul 2026 22:12:43 -0700 Subject: [PATCH 1/2] feat(paired-t-test): migrate paired_ttest chart to v1 chart data API Adds a buildQuery mirroring the legacy PairedTTestViz.query_obj (a timeseries query grouped by the group columns, sort metric appended with ordering under order_desc) and ports the pandas pivot_table reshape from the backend get_data into a transformData helper: one series per (metric, group tuple) with missing timestamps padded with null. Legacy pre-shaped payloads pass through untouched. No DB migration needed: viz_type is unchanged and legacy granularity_sqla/time_range form data flows through extractExtras. Co-Authored-By: Claude Fable 5 --- .../src/buildQuery.ts | 59 +++++++++++ .../src/index.ts | 4 +- .../src/transformData.ts | 87 +++++++++++++++ .../src/transformProps.ts | 27 +++-- .../test/buildQuery.test.ts | 56 ++++++++++ .../test/transformData.test.ts | 100 ++++++++++++++++++ .../test/tsconfig.json | 8 ++ 7 files changed, 333 insertions(+), 8 deletions(-) create mode 100644 superset-frontend/plugins/legacy-plugin-chart-paired-t-test/src/buildQuery.ts create mode 100644 superset-frontend/plugins/legacy-plugin-chart-paired-t-test/src/transformData.ts create mode 100644 superset-frontend/plugins/legacy-plugin-chart-paired-t-test/test/buildQuery.test.ts create mode 100644 superset-frontend/plugins/legacy-plugin-chart-paired-t-test/test/transformData.test.ts create mode 100644 superset-frontend/plugins/legacy-plugin-chart-paired-t-test/test/tsconfig.json diff --git a/superset-frontend/plugins/legacy-plugin-chart-paired-t-test/src/buildQuery.ts b/superset-frontend/plugins/legacy-plugin-chart-paired-t-test/src/buildQuery.ts new file mode 100644 index 000000000000..20f83fe65845 --- /dev/null +++ b/superset-frontend/plugins/legacy-plugin-chart-paired-t-test/src/buildQuery.ts @@ -0,0 +1,59 @@ +/** + * 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 { + buildQueryContext, + ensureIsArray, + getMetricLabel, + QueryFormData, + QueryFormMetric, + QueryFormOrderBy, +} from '@superset-ui/core'; + +/** + * Mirrors the legacy PairedTTestViz.query_obj: a timeseries query grouped + * by the group columns, selecting all metrics, with the sort metric + * appended to the select list and ordering applied when order_desc is set. + */ +export default function buildQuery(formData: QueryFormData) { + const { timeseries_limit_metric, order_desc } = formData; + return buildQueryContext(formData, baseQueryObject => { + let metrics: QueryFormMetric[] = ensureIsArray(baseQueryObject.metrics); + const orderby: QueryFormOrderBy[] = []; + const sortByMetric = ensureIsArray( + timeseries_limit_metric as QueryFormMetric | QueryFormMetric[], + )[0]; + if (sortByMetric) { + const sortByLabel = getMetricLabel(sortByMetric); + if (!metrics.some(metric => getMetricLabel(metric) === sortByLabel)) { + metrics = [...metrics, sortByMetric]; + } + if (order_desc) { + orderby.push([sortByMetric, !order_desc]); + } + } + return [ + { + ...baseQueryObject, + metrics, + is_timeseries: true, + orderby: orderby.length > 0 ? orderby : undefined, + }, + ]; + }); +} diff --git a/superset-frontend/plugins/legacy-plugin-chart-paired-t-test/src/index.ts b/superset-frontend/plugins/legacy-plugin-chart-paired-t-test/src/index.ts index a2e28a061147..29ad81a22ee5 100644 --- a/superset-frontend/plugins/legacy-plugin-chart-paired-t-test/src/index.ts +++ b/superset-frontend/plugins/legacy-plugin-chart-paired-t-test/src/index.ts @@ -32,16 +32,16 @@ const metadata = new ChartMetadata({ ), exampleGallery: [{ url: example, urlDark: exampleDark }], name: t('Paired t-test Table'), - tags: [t('Legacy'), t('Statistical'), t('Tabular')], + tags: [t('Statistical'), t('Tabular')], thumbnail, thumbnailDark, - useLegacyApi: true, }); export default class PairedTTestChartPlugin extends ChartPlugin { constructor() { super({ loadChart: () => import('./PairedTTest'), + loadBuildQuery: () => import('./buildQuery'), metadata, transformProps, controlPanel, diff --git a/superset-frontend/plugins/legacy-plugin-chart-paired-t-test/src/transformData.ts b/superset-frontend/plugins/legacy-plugin-chart-paired-t-test/src/transformData.ts new file mode 100644 index 000000000000..e35d0f4bb3bf --- /dev/null +++ b/superset-frontend/plugins/legacy-plugin-chart-paired-t-test/src/transformData.ts @@ -0,0 +1,87 @@ +/** + * 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 { DTTM_ALIAS } from '@superset-ui/core'; + +export interface TTestSeries { + group: unknown[] | string; + values: { x: unknown; y: unknown }[]; +} + +export type TTestData = Record; + +/** + * Ports the legacy PairedTTestViz.get_data reshape: pivot the timeseries + * records on the timestamp with one series per (metric, group tuple), + * padding missing timestamps with null the way pandas pivot_table did. + */ +export default function transformData( + records: Record[], + groupbyLabels: string[], + metricLabels: string[], +): TTestData { + const timestamps = Array.from( + new Set(records.map(record => record[DTTM_ALIAS])), + ).sort((a, b) => { + if (a === b) return 0; + return (a as number) < (b as number) ? -1 : 1; + }); + + const hasGroup = groupbyLabels.length > 0; + const groupKeyOf = (record: Record) => + JSON.stringify(groupbyLabels.map(label => record[label])); + + // groupKey -> tuple of group values, sorted like pandas pivot columns + const groupTuples = new Map(); + // metric -> groupKey -> timestamp -> value + const values = new Map>>(); + metricLabels.forEach(metric => values.set(metric, new Map())); + + records.forEach(record => { + const groupKey = groupKeyOf(record); + if (!groupTuples.has(groupKey)) { + groupTuples.set( + groupKey, + groupbyLabels.map(label => record[label]), + ); + } + metricLabels.forEach(metric => { + const byGroup = values.get(metric)!; + if (!byGroup.has(groupKey)) { + byGroup.set(groupKey, new Map()); + } + byGroup.get(groupKey)!.set(record[DTTM_ALIAS], record[metric]); + }); + }); + + const sortedGroupKeys = Array.from(groupTuples.keys()).sort(); + + const data: TTestData = {}; + metricLabels.forEach(metric => { + const byGroup = values.get(metric)!; + const seriesKeys = hasGroup ? sortedGroupKeys : [groupKeyOf({})]; + data[metric] = seriesKeys.map(groupKey => ({ + group: hasGroup ? groupTuples.get(groupKey)! : 'All', + values: timestamps.map(timestamp => ({ + x: timestamp, + y: byGroup.get(groupKey)?.get(timestamp) ?? null, + })), + })); + }); + return data; +} diff --git a/superset-frontend/plugins/legacy-plugin-chart-paired-t-test/src/transformProps.ts b/superset-frontend/plugins/legacy-plugin-chart-paired-t-test/src/transformProps.ts index 4decf04962cf..db95184a03cd 100644 --- a/superset-frontend/plugins/legacy-plugin-chart-paired-t-test/src/transformProps.ts +++ b/superset-frontend/plugins/legacy-plugin-chart-paired-t-test/src/transformProps.ts @@ -16,7 +16,13 @@ * specific language governing permissions and limitations * under the License. */ -import { ChartProps } from '@superset-ui/core'; +import { + ChartProps, + ensureIsArray, + getColumnLabel, + getMetricLabel, +} from '@superset-ui/core'; +import transformData from './transformData'; export default function transformProps(chartProps: ChartProps) { const { formData, queriesData } = chartProps; @@ -28,15 +34,24 @@ export default function transformProps(chartProps: ChartProps) { significanceLevel, } = formData; + const metricLabels = ensureIsArray(metrics).map(getMetricLabel); + const rawData = queriesData[0].data; + // The legacy explore_json endpoint pivoted the timeseries server-side; + // v1 responses arrive as flat records and are reshaped here. + const data = Array.isArray(rawData) + ? transformData( + rawData, + ensureIsArray(groupby).map(getColumnLabel), + metricLabels, + ) + : rawData; + return { alpha: significanceLevel, - data: queriesData[0].data, + data, groups: groupby, liftValPrec: parseInt(liftvaluePrecision, 10), - metrics: (metrics as (string | { label: string })[]).map( - (metric: string | { label: string }) => - typeof metric === 'string' ? metric : metric.label, - ), + metrics: metricLabels, pValPrec: parseInt(pvaluePrecision, 10), }; } diff --git a/superset-frontend/plugins/legacy-plugin-chart-paired-t-test/test/buildQuery.test.ts b/superset-frontend/plugins/legacy-plugin-chart-paired-t-test/test/buildQuery.test.ts new file mode 100644 index 000000000000..ecebf94b8b4f --- /dev/null +++ b/superset-frontend/plugins/legacy-plugin-chart-paired-t-test/test/buildQuery.test.ts @@ -0,0 +1,56 @@ +/** + * 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 { QueryFormData } from '@superset-ui/core'; +import buildQuery from '../src/buildQuery'; + +const formData: QueryFormData = { + datasource: '5__table', + granularity_sqla: 'ds', + time_grain_sqla: 'P1D', + time_range: 'No filter', + viz_type: 'paired_ttest', + groupby: ['gender'], + metrics: ['sum__num'], +}; + +test('builds a timeseries query grouped by the group columns', () => { + const [query] = buildQuery(formData).queries; + expect(query.columns).toEqual(['gender']); + expect(query.metrics).toEqual(['sum__num']); + expect(query.is_timeseries).toBe(true); + expect(query.granularity).toEqual('ds'); +}); + +test('appends the sort metric and orders when order_desc is set', () => { + const [query] = buildQuery({ + ...formData, + timeseries_limit_metric: 'count', + order_desc: true, + }).queries; + expect(query.metrics).toEqual(['sum__num', 'count']); + expect(query.orderby).toEqual([['count', false]]); +}); + +test('ignores residual order_by_cols from other viz types', () => { + const [query] = buildQuery({ + ...formData, + order_by_cols: ['["count", false]'], + }).queries; + expect(query.orderby).toBeUndefined(); +}); diff --git a/superset-frontend/plugins/legacy-plugin-chart-paired-t-test/test/transformData.test.ts b/superset-frontend/plugins/legacy-plugin-chart-paired-t-test/test/transformData.test.ts new file mode 100644 index 000000000000..49c99d6b2521 --- /dev/null +++ b/superset-frontend/plugins/legacy-plugin-chart-paired-t-test/test/transformData.test.ts @@ -0,0 +1,100 @@ +/** + * 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 transformData from '../src/transformData'; + +const t1 = 1704067200000; +const t2 = 1704153600000; + +test('nests records per metric and group with null padding', () => { + const data = transformData( + [ + { __timestamp: t1, gender: 'boy', sum__num: 10 }, + { __timestamp: t2, gender: 'boy', sum__num: 20 }, + { __timestamp: t1, gender: 'girl', sum__num: 30 }, + // girl is missing at t2 -> padded with null + ], + ['gender'], + ['sum__num'], + ); + expect(Object.keys(data)).toEqual(['sum__num']); + expect(data.sum__num).toEqual([ + { + group: ['boy'], + values: [ + { x: t1, y: 10 }, + { x: t2, y: 20 }, + ], + }, + { + group: ['girl'], + values: [ + { x: t1, y: 30 }, + { x: t2, y: null }, + ], + }, + ]); +}); + +test('uses the All group when no groupby is set', () => { + const data = transformData( + [ + { __timestamp: t1, sum__num: 1 }, + { __timestamp: t2, sum__num: 2 }, + ], + [], + ['sum__num'], + ); + expect(data.sum__num).toEqual([ + { + group: 'All', + values: [ + { x: t1, y: 1 }, + { x: t2, y: 2 }, + ], + }, + ]); +}); + +test('handles multiple metrics and multi-column groups', () => { + const data = transformData( + [ + { __timestamp: t1, gender: 'boy', state: 'CA', sum__num: 1, count: 5 }, + { __timestamp: t1, gender: 'girl', state: 'NY', sum__num: 2, count: 6 }, + ], + ['gender', 'state'], + ['sum__num', 'count'], + ); + expect(Object.keys(data).sort()).toEqual(['count', 'sum__num']); + expect(data.count).toEqual([ + { group: ['boy', 'CA'], values: [{ x: t1, y: 5 }] }, + { group: ['girl', 'NY'], values: [{ x: t1, y: 6 }] }, + ]); +}); + +test('sorts timestamps ascending like the pandas pivot index', () => { + const data = transformData( + [ + { __timestamp: t2, sum__num: 2 }, + { __timestamp: t1, sum__num: 1 }, + ], + [], + ['sum__num'], + ); + expect(data.sum__num[0].values.map(v => v.x)).toEqual([t1, t2]); +}); diff --git a/superset-frontend/plugins/legacy-plugin-chart-paired-t-test/test/tsconfig.json b/superset-frontend/plugins/legacy-plugin-chart-paired-t-test/test/tsconfig.json new file mode 100644 index 000000000000..4c9211140ce6 --- /dev/null +++ b/superset-frontend/plugins/legacy-plugin-chart-paired-t-test/test/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../../tsconfig.options.json", + "include": ["**/*"], + "compilerOptions": { + "esModuleInterop": true, + "types": ["jest", "node"] + } +} From 45838518826f693d0522485f5babc5e2b2705bf0 Mon Sep 17 00:00:00 2001 From: Claude Code Date: Thu, 2 Jul 2026 22:30:46 -0700 Subject: [PATCH 2/2] fix(paired-t-test): sort group tuples element-wise like pandas JSON-key string sorting misordered numeric groups (10 before 2), which changes row order and the default control group. Tuples now compare element-wise with numeric awareness, matching the pandas pivot column ordering. Co-Authored-By: Claude Fable 5 --- .../src/transformData.ts | 17 ++++++++++++++++- .../test/transformData.test.ts | 12 ++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/superset-frontend/plugins/legacy-plugin-chart-paired-t-test/src/transformData.ts b/superset-frontend/plugins/legacy-plugin-chart-paired-t-test/src/transformData.ts index e35d0f4bb3bf..d538922e8a25 100644 --- a/superset-frontend/plugins/legacy-plugin-chart-paired-t-test/src/transformData.ts +++ b/superset-frontend/plugins/legacy-plugin-chart-paired-t-test/src/transformData.ts @@ -69,7 +69,22 @@ export default function transformData( }); }); - const sortedGroupKeys = Array.from(groupTuples.keys()).sort(); + // element-wise tuple comparison so numeric groups sort numerically, + // matching the pandas pivot column ordering + const compareTuples = (a: unknown[], b: unknown[]) => { + for (let i = 0; i < Math.min(a.length, b.length); i += 1) { + if (a[i] !== b[i]) { + if (typeof a[i] === 'number' && typeof b[i] === 'number') { + return (a[i] as number) - (b[i] as number); + } + return String(a[i]) < String(b[i]) ? -1 : 1; + } + } + return a.length - b.length; + }; + const sortedGroupKeys = Array.from(groupTuples.entries()) + .sort(([, a], [, b]) => compareTuples(a, b)) + .map(([key]) => key); const data: TTestData = {}; metricLabels.forEach(metric => { diff --git a/superset-frontend/plugins/legacy-plugin-chart-paired-t-test/test/transformData.test.ts b/superset-frontend/plugins/legacy-plugin-chart-paired-t-test/test/transformData.test.ts index 49c99d6b2521..0e2c23558c11 100644 --- a/superset-frontend/plugins/legacy-plugin-chart-paired-t-test/test/transformData.test.ts +++ b/superset-frontend/plugins/legacy-plugin-chart-paired-t-test/test/transformData.test.ts @@ -87,6 +87,18 @@ test('handles multiple metrics and multi-column groups', () => { ]); }); +test('sorts numeric groups numerically like pandas pivot columns', () => { + const data = transformData( + [ + { __timestamp: t1, decade: 10, sum__num: 1 }, + { __timestamp: t1, decade: 2, sum__num: 2 }, + ], + ['decade'], + ['sum__num'], + ); + expect(data.sum__num.map(series => series.group)).toEqual([[2], [10]]); +}); + test('sorts timestamps ascending like the pandas pivot index', () => { const data = transformData( [