feat: Add Aggregation Method for Big Number with Trendline (#32767)

This commit is contained in:
Levis Mbote
2025-03-29 06:34:23 +03:00
committed by GitHub
parent c2afae51cb
commit f2c0686346
12 changed files with 473 additions and 26 deletions

View File

@@ -0,0 +1,58 @@
/**
* 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 limitationsxw
* under the License.
*/
import {
getMetricLabel,
ensureIsArray,
PostProcessingAggregation,
QueryFormData,
Aggregates,
} from '@superset-ui/core';
import { PostProcessingFactory } from './types';
export const aggregationOperator: PostProcessingFactory<
PostProcessingAggregation
> = (formData: QueryFormData, queryObject) => {
const { aggregation = 'LAST_VALUE' } = formData;
if (aggregation === 'LAST_VALUE') {
return undefined;
}
const metrics = ensureIsArray(queryObject.metrics);
if (metrics.length === 0) {
return undefined;
}
const aggregates: Aggregates = {};
metrics.forEach(metric => {
const metricLabel = getMetricLabel(metric);
aggregates[metricLabel] = {
operator: aggregation,
column: metricLabel,
};
});
return {
operation: 'aggregate',
options: {
groupby: [],
aggregates,
},
};
};

View File

@@ -21,6 +21,7 @@ export { rollingWindowOperator } from './rollingWindowOperator';
export { timeCompareOperator } from './timeCompareOperator';
export { timeComparePivotOperator } from './timeComparePivotOperator';
export { sortOperator } from './sortOperator';
export { aggregationOperator } from './aggregateOperator';
export { histogramOperator } from './histogramOperator';
export { pivotOperator } from './pivotOperator';
export { resampleOperator } from './resampleOperator';

View File

@@ -61,6 +61,32 @@ const xAxisSortVisibility = ({ controls }: { controls: ControlStateMapping }) =>
ensureIsArray(controls?.groupby?.value).length === 0 &&
ensureIsArray(controls?.metrics?.value).length === 1;
// TODO: Expand this aggregation options list to include all backend-supported aggregations.
// TODO: Migrate existing chart types (Pivot Table, etc.) to use this shared control.
export const aggregationControl = {
name: 'aggregation',
config: {
type: 'SelectControl',
label: t('Aggregation Method'),
default: 'LAST_VALUE',
clearable: false,
renderTrigger: false,
choices: [
['LAST_VALUE', t('Last Value')],
['sum', t('Total (Sum)')],
['mean', t('Average (Mean)')],
['min', t('Minimum')],
['max', t('Maximum')],
['median', t('Median')],
],
description: t('Select an aggregation method to apply to the metric.'),
provideFormDataToProps: true,
mapStateToProps: ({ form_data }: ControlPanelState) => ({
value: form_data.aggregation || 'LAST_VALUE',
}),
},
};
const xAxisMultiSortVisibility = ({
controls,
}: {

View File

@@ -19,6 +19,7 @@
export { default as sharedControls } from './sharedControls';
// React control components
export { default as sharedControlComponents } from './components';
export { aggregationControl } from './customControls';
export * from './components';
export * from './customControls';
export * from './mixins';

View File

@@ -0,0 +1,121 @@
/**
* 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 { QueryObject, SqlaFormData, VizType } from '@superset-ui/core';
import { aggregationOperator } from '@superset-ui/chart-controls';
describe('aggregationOperator', () => {
const formData: SqlaFormData = {
metrics: [
'count(*)',
{ label: 'sum(val)', expressionType: 'SQL', sqlExpression: 'sum(val)' },
],
time_range: '2015 : 2016',
granularity: 'month',
datasource: 'foo',
viz_type: VizType.Table,
};
const queryObject: QueryObject = {
metrics: [
'count(*)',
{ label: 'sum(val)', expressionType: 'SQL', sqlExpression: 'sum(val)' },
],
time_range: '2015 : 2016',
granularity: 'month',
};
test('should return undefined for LAST_VALUE aggregation', () => {
const formDataWithLastValue = {
...formData,
aggregation: 'LAST_VALUE',
};
expect(
aggregationOperator(formDataWithLastValue, queryObject),
).toBeUndefined();
});
test('should return undefined when metrics is empty', () => {
const queryObjectWithoutMetrics = {
...queryObject,
metrics: [],
};
const formDataWithSum = {
...formData,
aggregation: 'sum',
};
expect(
aggregationOperator(formDataWithSum, queryObjectWithoutMetrics),
).toBeUndefined();
});
test('should apply sum aggregation to all metrics', () => {
const formDataWithSum = {
...formData,
aggregation: 'sum',
};
expect(aggregationOperator(formDataWithSum, queryObject)).toEqual({
operation: 'aggregate',
options: {
groupby: [],
aggregates: {
'count(*)': {
operator: 'sum',
column: 'count(*)',
},
'sum(val)': {
operator: 'sum',
column: 'sum(val)',
},
},
},
});
});
test('should apply mean aggregation to all metrics', () => {
const formDataWithMean = {
...formData,
aggregation: 'mean',
};
expect(aggregationOperator(formDataWithMean, queryObject)).toEqual({
operation: 'aggregate',
options: {
groupby: [],
aggregates: {
'count(*)': {
operator: 'mean',
column: 'count(*)',
},
'sum(val)': {
operator: 'mean',
column: 'sum(val)',
},
},
},
});
});
test('should use default aggregation when not specified', () => {
expect(aggregationOperator(formData, queryObject)).toBeUndefined();
});
});

View File

@@ -54,7 +54,7 @@ const queryObject: QueryObject = {
},
},
{
operation: 'aggregation',
operation: 'aggregate',
options: {
groupby: ['col1'],
aggregates: {},

View File

@@ -67,7 +67,7 @@ export interface Aggregates {
export type DefaultPostProcessing = undefined;
interface _PostProcessingAggregation {
operation: 'aggregation';
operation: 'aggregate';
options: {
groupby: string[];
aggregates: Aggregates;
@@ -271,7 +271,7 @@ export type PostProcessingRule =
export function isPostProcessingAggregation(
rule?: PostProcessingRule,
): rule is PostProcessingAggregation {
return rule?.operation === 'aggregation';
return rule?.operation === 'aggregate';
}
export function isPostProcessingBoxplot(

View File

@@ -61,7 +61,7 @@ const AGGREGATES_OPTION: Aggregates = {
};
const AGGREGATE_RULE: PostProcessingAggregation = {
operation: 'aggregation',
operation: 'aggregate',
options: {
groupby: ['foo'],
aggregates: AGGREGATES_OPTION,

View File

@@ -24,6 +24,7 @@ import {
QueryFormData,
} from '@superset-ui/core';
import {
aggregationOperator,
flattenOperator,
pivotOperator,
resampleOperator,
@@ -47,5 +48,19 @@ export default function buildQuery(formData: QueryFormData) {
flattenOperator(formData, baseQueryObject),
],
},
{
...baseQueryObject,
columns: [
...(isXAxisSet(formData)
? ensureIsArray(getXAxisColumn(formData))
: []),
],
...(isXAxisSet(formData) ? {} : { is_timeseries: true }),
post_processing: [
pivotOperator(formData, baseQueryObject),
aggregationOperator(formData, baseQueryObject),
],
},
]);
}

View File

@@ -18,6 +18,7 @@
*/
import { SMART_DATE_ID, t } from '@superset-ui/core';
import {
aggregationControl,
ControlPanelConfig,
ControlSubSectionHeader,
D3_FORMAT_DOCS,
@@ -35,6 +36,7 @@ const config: ControlPanelConfig = {
controlSetRows: [
['x_axis'],
['time_grain_sqla'],
[aggregationControl],
['metric'],
['adhoc_filters'],
],

View File

@@ -66,6 +66,7 @@ export default function transformProps(
metric = 'value',
showTimestamp,
showTrendLine,
aggregation,
startYAxisAtZero,
subheader = '',
subheaderFontSize,
@@ -82,6 +83,15 @@ export default function transformProps(
from_dttm: fromDatetime,
to_dttm: toDatetime,
} = queriesData[0];
const aggregatedQueryData = queriesData.length > 1 ? queriesData[1] : null;
const hasAggregatedData =
aggregatedQueryData?.data &&
aggregatedQueryData.data.length > 0 &&
aggregation !== 'LAST_VALUE';
const aggregatedData = hasAggregatedData ? aggregatedQueryData.data[0] : null;
const refs: Refs = {};
const metricName = getMetricLabel(metric);
const compareLag = Number(compareLag_) || 0;
@@ -95,18 +105,39 @@ export default function transformProps(
let percentChange = 0;
let bigNumber = data.length === 0 ? null : data[0][metricName];
let timestamp = data.length === 0 ? null : data[0][xAxisLabel];
let bigNumberFallback;
const metricColtypeIndex = colnames.findIndex(name => name === metricName);
const metricColtype =
metricColtypeIndex > -1 ? coltypes[metricColtypeIndex] : null;
let bigNumberFallback = null;
let sortedData: [number | null, number | null][] = [];
if (data.length > 0) {
const sortedData = (data as BigNumberDatum[])
.map(d => [d[xAxisLabel], parseMetricValue(d[metricName])])
sortedData = (data as BigNumberDatum[])
.map(
d =>
[d[xAxisLabel], parseMetricValue(d[metricName])] as [
number | null,
number | null,
],
)
// sort in time descending order
.sort((a, b) => (a[0] !== null && b[0] !== null ? b[0] - a[0] : 0));
}
if (hasAggregatedData && aggregatedData) {
if (
aggregatedData[metricName] !== null &&
aggregatedData[metricName] !== undefined
) {
bigNumber = aggregatedData[metricName];
} else {
const metricKeys = Object.keys(aggregatedData).filter(
key =>
key !== xAxisLabel &&
aggregatedData[key] !== null &&
typeof aggregatedData[key] === 'number',
);
bigNumber = metricKeys.length > 0 ? aggregatedData[metricKeys[0]] : null;
}
timestamp = sortedData.length > 0 ? sortedData[0][0] : null;
} else if (sortedData.length > 0) {
bigNumber = sortedData[0][1];
timestamp = sortedData[0][0];
@@ -115,25 +146,28 @@ export default function transformProps(
bigNumber = bigNumberFallback ? bigNumberFallback[1] : null;
timestamp = bigNumberFallback ? bigNumberFallback[0] : null;
}
}
if (compareLag > 0) {
const compareIndex = compareLag;
if (compareIndex < sortedData.length) {
const compareValue = sortedData[compareIndex][1];
// compare values must both be non-nulls
if (bigNumber !== null && compareValue !== null) {
percentChange = compareValue
? (bigNumber - compareValue) / Math.abs(compareValue)
: 0;
formattedSubheader = `${formatPercentChange(
percentChange,
)} ${compareSuffix}`;
}
if (compareLag > 0 && sortedData.length > 0) {
const compareIndex = compareLag;
if (compareIndex < sortedData.length) {
const compareValue = sortedData[compareIndex][1];
// compare values must both be non-nulls
if (bigNumber !== null && compareValue !== null) {
percentChange = compareValue
? (Number(bigNumber) - compareValue) / Math.abs(compareValue)
: 0;
formattedSubheader = `${formatPercentChange(
percentChange,
)} ${compareSuffix}`;
}
}
sortedData.reverse();
}
if (data.length > 0) {
const reversedData = [...sortedData].reverse();
// @ts-ignore
trendLineData = showTrendLine ? sortedData : undefined;
trendLineData = showTrendLine ? reversedData : undefined;
}
let className = '';
@@ -143,6 +177,10 @@ export default function transformProps(
className = 'negative';
}
const metricColtypeIndex = colnames.findIndex(name => name === metricName);
const metricColtype =
metricColtypeIndex > -1 ? coltypes[metricColtypeIndex] : null;
let metricEntry: Metric | undefined;
if (chartProps.datasource?.metrics) {
metricEntry = chartProps.datasource.metrics.find(

View File

@@ -186,3 +186,188 @@ describe('BigNumberWithTrendline', () => {
});
});
});
describe('BigNumberWithTrendline - Aggregation Tests', () => {
const baseProps = {
width: 800,
height: 600,
formData: {
colorPicker: { r: 0, g: 0, b: 0, a: 1 },
metric: 'metric',
aggregation: 'LAST_VALUE',
},
queriesData: [
{
data: [
{ __timestamp: 1607558400000, metric: 10 },
{ __timestamp: 1607558500000, metric: 30 },
{ __timestamp: 1607558600000, metric: 50 },
{ __timestamp: 1607558700000, metric: 60 },
],
colnames: ['__timestamp', 'metric'],
coltypes: ['TIMESTAMP', 'BIGINT'],
},
],
hooks: {},
filterState: {},
datasource: {
columnFormats: {},
currencyFormats: {},
},
rawDatasource: {},
rawFormData: {},
theme: {
colors: {
grayscale: {
light5: '#fafafa',
},
},
},
} as unknown as BigNumberWithTrendlineChartProps;
const propsWithEvenData = {
...baseProps,
queriesData: [
{
data: [
{ __timestamp: 1607558400000, metric: 10 },
{ __timestamp: 1607558500000, metric: 20 },
{ __timestamp: 1607558600000, metric: 30 },
{ __timestamp: 1607558700000, metric: 40 },
],
colnames: ['__timestamp', 'metric'],
coltypes: ['TIMESTAMP', 'BIGINT'],
},
],
} as unknown as BigNumberWithTrendlineChartProps;
it('should correctly calculate SUM', () => {
const props = {
...baseProps,
formData: { ...baseProps.formData, aggregation: 'sum' },
queriesData: [
baseProps.queriesData[0],
{
data: [{ metric: 150 }],
colnames: ['metric'],
coltypes: ['BIGINT'],
},
],
} as unknown as BigNumberWithTrendlineChartProps;
const transformed = transformProps(props);
expect(transformed.bigNumber).toStrictEqual(150);
});
it('should correctly calculate AVG', () => {
const props = {
...baseProps,
formData: { ...baseProps.formData, aggregation: 'mean' },
queriesData: [
baseProps.queriesData[0],
{
data: [{ metric: 37.5 }],
colnames: ['metric'],
coltypes: ['BIGINT'],
},
],
} as unknown as BigNumberWithTrendlineChartProps;
const transformed = transformProps(props);
expect(transformed.bigNumber).toStrictEqual(37.5);
});
it('should correctly calculate MIN', () => {
const props = {
...baseProps,
formData: { ...baseProps.formData, aggregation: 'min' },
queriesData: [
baseProps.queriesData[0],
{
data: [{ metric: 10 }],
colnames: ['metric'],
coltypes: ['BIGINT'],
},
],
} as unknown as BigNumberWithTrendlineChartProps;
const transformed = transformProps(props);
expect(transformed.bigNumber).toStrictEqual(10);
});
it('should correctly calculate MAX', () => {
const props = {
...baseProps,
formData: { ...baseProps.formData, aggregation: 'max' },
queriesData: [
baseProps.queriesData[0],
{
data: [{ metric: 60 }],
colnames: ['metric'],
coltypes: ['BIGINT'],
},
],
} as unknown as BigNumberWithTrendlineChartProps;
const transformed = transformProps(props);
expect(transformed.bigNumber).toStrictEqual(60);
});
it('should correctly calculate MEDIAN (odd count)', () => {
const oddCountProps = {
...baseProps,
queriesData: [
{
data: [
{ __timestamp: 1607558300000, metric: 10 },
{ __timestamp: 1607558400000, metric: 20 },
{ __timestamp: 1607558500000, metric: 30 },
{ __timestamp: 1607558600000, metric: 40 },
{ __timestamp: 1607558700000, metric: 50 },
],
colnames: ['__timestamp', 'metric'],
coltypes: ['TIMESTAMP', 'BIGINT'],
},
],
} as unknown as BigNumberWithTrendlineChartProps;
const props = {
...oddCountProps,
formData: { ...oddCountProps.formData, aggregation: 'median' },
queriesData: [
oddCountProps.queriesData[0],
{
data: [{ metric: 30 }],
colnames: ['metric'],
coltypes: ['BIGINT'],
},
],
} as unknown as BigNumberWithTrendlineChartProps;
const transformed = transformProps(props);
expect(transformed.bigNumber).toStrictEqual(30);
});
it('should correctly calculate MEDIAN (even count)', () => {
const props = {
...propsWithEvenData,
formData: { ...propsWithEvenData.formData, aggregation: 'median' },
queriesData: [
propsWithEvenData.queriesData[0],
{
data: [{ metric: 25 }],
colnames: ['metric'],
coltypes: ['BIGINT'],
},
],
} as unknown as BigNumberWithTrendlineChartProps;
const transformed = transformProps(props);
expect(transformed.bigNumber).toStrictEqual(25);
});
it('should return the LAST_VALUE correctly', () => {
const transformed = transformProps(baseProps);
expect(transformed.bigNumber).toStrictEqual(10);
});
});