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..d538922e8a25 --- /dev/null +++ b/superset-frontend/plugins/legacy-plugin-chart-paired-t-test/src/transformData.ts @@ -0,0 +1,102 @@ +/** + * 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]); + }); + }); + + // 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 => { + 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..0e2c23558c11 --- /dev/null +++ b/superset-frontend/plugins/legacy-plugin-chart-paired-t-test/test/transformData.test.ts @@ -0,0 +1,112 @@ +/** + * 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 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( + [ + { __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"] + } +}