mirror of
https://github.com/apache/superset.git
synced 2026-07-26 16:42:32 +00:00
Co-authored-by: Superset Dev <dev@superset.apache.org> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
467 lines
14 KiB
TypeScript
467 lines
14 KiB
TypeScript
/*
|
|
* 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 { QueryFormMetric, TimeGranularity } from '@superset-ui/core';
|
|
import buildGroupbyCombinations, {
|
|
isAdditiveMetric,
|
|
allMetricsAdditive,
|
|
additiveReducerFor,
|
|
synthesizeAdditiveLevels,
|
|
splitGroupingSetsResult,
|
|
groupingMarkerLabel,
|
|
} from '../../src/plugin/utilities';
|
|
import { PivotTableQueryFormData, MetricsLayoutEnum } from '../../src/types';
|
|
|
|
const baseFormData = {
|
|
groupbyRows: ['row1', 'row2'],
|
|
groupbyColumns: ['col1', 'col2'],
|
|
metrics: ['metric1', 'metric2'],
|
|
tableRenderer: 'Table With Subtotal',
|
|
colOrder: 'key_a_to_z',
|
|
rowOrder: 'key_a_to_z',
|
|
aggregateFunction: 'Sum',
|
|
metricsLayout: MetricsLayoutEnum.ROWS,
|
|
transposePivot: false,
|
|
rowSubtotalPosition: true,
|
|
colSubtotalPosition: true,
|
|
colTotals: true,
|
|
colSubTotals: true,
|
|
rowTotals: true,
|
|
rowSubTotals: true,
|
|
valueFormat: 'SMART_NUMBER',
|
|
datasource: '5__table',
|
|
viz_type: 'my_chart',
|
|
width: 800,
|
|
height: 600,
|
|
combineMetric: false,
|
|
verboseMap: {},
|
|
columnFormats: {},
|
|
currencyFormats: {},
|
|
metricColorFormatters: [],
|
|
dateFormatters: {},
|
|
setDataMask: () => {},
|
|
legacy_order_by: 'count',
|
|
order_desc: true,
|
|
margin: 0,
|
|
time_grain_sqla: TimeGranularity.MONTH,
|
|
temporal_columns_lookup: { col1: true },
|
|
currencyFormat: { symbol: 'USD', symbolPosition: 'prefix' },
|
|
} as unknown as PivotTableQueryFormData;
|
|
|
|
test('should build all combinations for basic pivot table', () => {
|
|
const combinations = buildGroupbyCombinations(baseFormData);
|
|
|
|
expect(combinations).toEqual([
|
|
{ rows: [], columns: [] },
|
|
{ rows: [], columns: ['col1'] },
|
|
{ rows: [], columns: ['col1', 'col2'] },
|
|
{ rows: ['row1'], columns: [] },
|
|
{ rows: ['row1'], columns: ['col1'] },
|
|
{ rows: ['row1'], columns: ['col1', 'col2'] },
|
|
{ rows: ['row1', 'row2'], columns: [] },
|
|
{ rows: ['row1', 'row2'], columns: ['col1'] },
|
|
{ rows: ['row1', 'row2'], columns: ['col1', 'col2'] },
|
|
]);
|
|
|
|
expect(combinations).toHaveLength(9);
|
|
});
|
|
|
|
test('should handle transposed pivot correctly', () => {
|
|
const modifiedFormData = {
|
|
...baseFormData,
|
|
transposePivot: true,
|
|
};
|
|
|
|
const combinations = buildGroupbyCombinations(modifiedFormData);
|
|
|
|
expect(combinations).toEqual([
|
|
{ rows: [], columns: [] },
|
|
{ rows: [], columns: ['row1'] },
|
|
{ rows: [], columns: ['row1', 'row2'] },
|
|
{ rows: ['col1'], columns: [] },
|
|
{ rows: ['col1'], columns: ['row1'] },
|
|
{ rows: ['col1'], columns: ['row1', 'row2'] },
|
|
{ rows: ['col1', 'col2'], columns: [] },
|
|
{ rows: ['col1', 'col2'], columns: ['row1'] },
|
|
{ rows: ['col1', 'col2'], columns: ['row1', 'row2'] },
|
|
]);
|
|
});
|
|
|
|
test('should filter combinations when combineMetric is true with ROWS layout', () => {
|
|
const modifiedFormData = {
|
|
...baseFormData,
|
|
combineMetric: true,
|
|
metricsLayout: MetricsLayoutEnum.ROWS,
|
|
};
|
|
|
|
const combinations = buildGroupbyCombinations(modifiedFormData);
|
|
|
|
expect(combinations).toEqual([
|
|
{ rows: ['row1', 'row2'], columns: [] },
|
|
{ rows: ['row1', 'row2'], columns: ['col1'] },
|
|
{ rows: ['row1', 'row2'], columns: ['col1', 'col2'] },
|
|
]);
|
|
|
|
expect(combinations).toHaveLength(3);
|
|
});
|
|
|
|
test('should filter combinations when combineMetric is true with COLUMNS layout', () => {
|
|
const modifiedFormData = {
|
|
...baseFormData,
|
|
combineMetric: true,
|
|
metricsLayout: MetricsLayoutEnum.COLUMNS,
|
|
};
|
|
|
|
const combinations = buildGroupbyCombinations(modifiedFormData);
|
|
|
|
expect(combinations).toEqual([
|
|
{ rows: [], columns: ['col1', 'col2'] },
|
|
{ rows: ['row1'], columns: ['col1', 'col2'] },
|
|
{ rows: ['row1', 'row2'], columns: ['col1', 'col2'] },
|
|
]);
|
|
|
|
expect(combinations).toHaveLength(3);
|
|
});
|
|
|
|
test('should handle single dimension in rows only', () => {
|
|
const modifiedFormData = {
|
|
...baseFormData,
|
|
groupbyRows: ['row'],
|
|
groupbyColumns: [],
|
|
};
|
|
|
|
const combinations = buildGroupbyCombinations(modifiedFormData);
|
|
|
|
expect(combinations).toEqual([
|
|
{ rows: [], columns: [] },
|
|
{ rows: ['row'], columns: [] },
|
|
]);
|
|
|
|
expect(combinations).toHaveLength(2);
|
|
});
|
|
|
|
test('should handle single dimension in columns only', () => {
|
|
const modifiedFormData = {
|
|
...baseFormData,
|
|
groupbyRows: [],
|
|
groupbyColumns: ['col'],
|
|
};
|
|
|
|
const combinations = buildGroupbyCombinations(modifiedFormData);
|
|
|
|
expect(combinations).toEqual([
|
|
{ rows: [], columns: [] },
|
|
{ rows: [], columns: ['col'] },
|
|
]);
|
|
|
|
expect(combinations).toHaveLength(2);
|
|
});
|
|
|
|
test('should handle empty groupby arrays', () => {
|
|
const modifiedFormData = {
|
|
...baseFormData,
|
|
groupbyRows: [],
|
|
groupbyColumns: [],
|
|
};
|
|
|
|
const combinations = buildGroupbyCombinations(modifiedFormData);
|
|
|
|
expect(combinations).toEqual([{ rows: [], columns: [] }]);
|
|
|
|
expect(combinations).toHaveLength(1);
|
|
});
|
|
|
|
test('should work with combineMetric and transposed pivot', () => {
|
|
const modifiedFormData = {
|
|
...baseFormData,
|
|
transposePivot: true,
|
|
combineMetric: true,
|
|
metricsLayout: MetricsLayoutEnum.COLUMNS,
|
|
};
|
|
|
|
const combinations = buildGroupbyCombinations(modifiedFormData);
|
|
|
|
expect(combinations).toEqual([
|
|
{ rows: [], columns: ['row1', 'row2'] },
|
|
{ rows: ['col1'], columns: ['row1', 'row2'] },
|
|
{ rows: ['col1', 'col2'], columns: ['row1', 'row2'] },
|
|
]);
|
|
});
|
|
|
|
test('should handle combineMetric with empty arrays correctly', () => {
|
|
const modifiedFormData = {
|
|
...baseFormData,
|
|
groupbyRows: [],
|
|
groupbyColumns: ['col'],
|
|
combineMetric: true,
|
|
metricsLayout: MetricsLayoutEnum.ROWS,
|
|
};
|
|
|
|
const combinations = buildGroupbyCombinations(modifiedFormData);
|
|
|
|
expect(combinations).toEqual([
|
|
{ rows: [], columns: [] },
|
|
{ rows: [], columns: ['col'] },
|
|
]);
|
|
});
|
|
|
|
test('should work with large number of dimensions', () => {
|
|
const modifiedFormData = {
|
|
...baseFormData,
|
|
groupbyRows: ['r1', 'r2', 'r3', 'r4'],
|
|
groupbyColumns: ['c1', 'c2', 'c3'],
|
|
};
|
|
|
|
const combinations = buildGroupbyCombinations(modifiedFormData);
|
|
|
|
expect(combinations).toHaveLength(20);
|
|
|
|
expect(combinations).toContainEqual({ rows: [], columns: [] });
|
|
expect(combinations).toContainEqual({
|
|
rows: ['r1', 'r2', 'r3', 'r4'],
|
|
columns: ['c1', 'c2', 'c3'],
|
|
});
|
|
});
|
|
|
|
test('isAdditiveMetric: SIMPLE metrics with additive aggregates are additive', () => {
|
|
expect(
|
|
isAdditiveMetric({
|
|
expressionType: 'SIMPLE',
|
|
aggregate: 'SUM',
|
|
column: { column_name: 'num' },
|
|
label: 'sum_num',
|
|
} as QueryFormMetric),
|
|
).toBe(true);
|
|
expect(
|
|
isAdditiveMetric({
|
|
expressionType: 'SIMPLE',
|
|
aggregate: 'COUNT',
|
|
column: { column_name: 'num' },
|
|
label: 'count_num',
|
|
} as QueryFormMetric),
|
|
).toBe(true);
|
|
});
|
|
|
|
test('isAdditiveMetric: non-additive aggregates, SQL, and saved metrics are not additive', () => {
|
|
expect(
|
|
isAdditiveMetric({
|
|
expressionType: 'SIMPLE',
|
|
aggregate: 'AVG',
|
|
column: { column_name: 'num' },
|
|
label: 'avg_num',
|
|
} as QueryFormMetric),
|
|
).toBe(false);
|
|
expect(
|
|
isAdditiveMetric({
|
|
expressionType: 'SIMPLE',
|
|
aggregate: 'COUNT_DISTINCT',
|
|
column: { column_name: 'name' },
|
|
label: 'distinct_names',
|
|
} as QueryFormMetric),
|
|
).toBe(false);
|
|
expect(
|
|
isAdditiveMetric({
|
|
expressionType: 'SQL',
|
|
sqlExpression: 'SUM(a) / SUM(b)',
|
|
label: 'ratio',
|
|
} as QueryFormMetric),
|
|
).toBe(false);
|
|
// saved-metric reference: aggregate unknown from form data -> non-additive
|
|
expect(isAdditiveMetric('count')).toBe(false);
|
|
});
|
|
|
|
test('allMetricsAdditive: all additive vs any non-additive vs empty', () => {
|
|
const sum = {
|
|
expressionType: 'SIMPLE',
|
|
aggregate: 'SUM',
|
|
column: { column_name: 'a' },
|
|
label: 'a',
|
|
} as QueryFormMetric;
|
|
const ratio = {
|
|
expressionType: 'SQL',
|
|
sqlExpression: 'SUM(a)/SUM(b)',
|
|
label: 'r',
|
|
} as QueryFormMetric;
|
|
expect(allMetricsAdditive([sum, sum])).toBe(true);
|
|
expect(allMetricsAdditive([sum, ratio])).toBe(false);
|
|
expect(allMetricsAdditive([])).toBe(false);
|
|
});
|
|
|
|
test('pruning: with all totals/subtotals off, only the leaf level is queried', () => {
|
|
const combinations = buildGroupbyCombinations({
|
|
...baseFormData,
|
|
colTotals: false,
|
|
rowTotals: false,
|
|
colSubTotals: false,
|
|
rowSubTotals: false,
|
|
});
|
|
expect(combinations).toEqual([
|
|
{ rows: ['row1', 'row2'], columns: ['col1', 'col2'] },
|
|
]);
|
|
});
|
|
|
|
test('pruning: totals on, subtotals off -> only full + fully-collapsed prefixes', () => {
|
|
const combinations = buildGroupbyCombinations({
|
|
...baseFormData,
|
|
colTotals: true,
|
|
rowTotals: true,
|
|
colSubTotals: false,
|
|
rowSubTotals: false,
|
|
});
|
|
// 2 row prefixes ([], full) x 2 col prefixes ([], full) = 4
|
|
expect(combinations).toEqual([
|
|
{ rows: [], columns: [] },
|
|
{ rows: [], columns: ['col1', 'col2'] },
|
|
{ rows: ['row1', 'row2'], columns: [] },
|
|
{ rows: ['row1', 'row2'], columns: ['col1', 'col2'] },
|
|
]);
|
|
});
|
|
|
|
test('pruning: row subtotals on, everything else off', () => {
|
|
const combinations = buildGroupbyCombinations({
|
|
...baseFormData,
|
|
colTotals: false,
|
|
rowTotals: false,
|
|
colSubTotals: false,
|
|
rowSubTotals: true,
|
|
});
|
|
// row prefixes: intermediate [row1] + full [row1,row2]; col prefixes: full only
|
|
expect(combinations).toEqual([
|
|
{ rows: ['row1'], columns: ['col1', 'col2'] },
|
|
{ rows: ['row1', 'row2'], columns: ['col1', 'col2'] },
|
|
]);
|
|
});
|
|
|
|
test('pruning: empty column dims keep the [] (leaf) level regardless of rowTotals', () => {
|
|
const combinations = buildGroupbyCombinations({
|
|
...baseFormData,
|
|
groupbyColumns: [],
|
|
colTotals: true, // bottom total row
|
|
rowTotals: false,
|
|
colSubTotals: false,
|
|
rowSubTotals: false,
|
|
});
|
|
// columns is empty so [] is the leaf level (always kept); rows: [] (colTotals) + full
|
|
expect(combinations).toEqual([
|
|
{ rows: [], columns: [] },
|
|
{ rows: ['row1', 'row2'], columns: [] },
|
|
]);
|
|
});
|
|
|
|
test('additiveReducerFor maps aggregates to reducers', () => {
|
|
const mk = (aggregate: string) =>
|
|
({
|
|
expressionType: 'SIMPLE',
|
|
aggregate,
|
|
label: aggregate,
|
|
}) as QueryFormMetric;
|
|
expect(additiveReducerFor(mk('SUM'))).toBe('sum');
|
|
expect(additiveReducerFor(mk('COUNT'))).toBe('sum');
|
|
expect(additiveReducerFor(mk('MIN'))).toBe('min');
|
|
expect(additiveReducerFor(mk('MAX'))).toBe('max');
|
|
// saved-metric reference is a plain string, no cast needed
|
|
expect(additiveReducerFor('saved_metric')).toBe('sum');
|
|
});
|
|
|
|
const LEAF = [
|
|
{ region: 'US', topic: 'a', value: 10 },
|
|
{ region: 'US', topic: 'b', value: 20 },
|
|
{ region: 'EU', topic: 'a', value: 5 },
|
|
];
|
|
|
|
test('synthesizeAdditiveLevels: sum reducer across rollup levels', () => {
|
|
const [grand, perRegion, leaf] = synthesizeAdditiveLevels(
|
|
LEAF,
|
|
[
|
|
{ rows: [], columns: [] },
|
|
{ rows: ['region'], columns: [] },
|
|
{ rows: ['region', 'topic'], columns: [] },
|
|
],
|
|
{ value: 'sum' },
|
|
);
|
|
expect(grand).toEqual([{ value: 35 }]);
|
|
expect(perRegion).toEqual([
|
|
{ region: 'US', value: 30 },
|
|
{ region: 'EU', value: 5 },
|
|
]);
|
|
// leaf level reduces single-row groups -> identity
|
|
expect(leaf).toEqual([
|
|
{ region: 'US', topic: 'a', value: 10 },
|
|
{ region: 'US', topic: 'b', value: 20 },
|
|
{ region: 'EU', topic: 'a', value: 5 },
|
|
]);
|
|
});
|
|
|
|
test('synthesizeAdditiveLevels: min/max reducers and null handling', () => {
|
|
const rows = [...LEAF, { region: 'EU', topic: 'b', value: null }];
|
|
const [grandMax] = synthesizeAdditiveLevels(
|
|
rows,
|
|
[{ rows: [], columns: [] }],
|
|
{ value: 'max' },
|
|
);
|
|
expect(grandMax).toEqual([{ value: 20 }]);
|
|
const [grandMin] = synthesizeAdditiveLevels(
|
|
rows,
|
|
[{ rows: [], columns: [] }],
|
|
{ value: 'min' },
|
|
);
|
|
expect(grandMin).toEqual([{ value: 5 }]);
|
|
});
|
|
|
|
test('splitGroupingSetsResult splits by markers and strips them', () => {
|
|
const gm = groupingMarkerLabel;
|
|
const rows = [
|
|
{ region: 'US', topic: 'a', v: 10, [gm('region')]: 0, [gm('topic')]: 0 },
|
|
{ region: 'US', topic: 'b', v: 20, [gm('region')]: 0, [gm('topic')]: 0 },
|
|
{ region: 'US', topic: null, v: 30, [gm('region')]: 0, [gm('topic')]: 1 },
|
|
{ region: null, topic: null, v: 60, [gm('region')]: 1, [gm('topic')]: 1 },
|
|
];
|
|
const [leaf, sub, grand] = splitGroupingSetsResult(
|
|
rows,
|
|
[['region', 'topic'], ['region'], []],
|
|
['region', 'topic'],
|
|
);
|
|
expect(leaf.map(r => r.v)).toEqual([10, 20]);
|
|
expect(sub.map(r => r.v)).toEqual([30]);
|
|
expect(grand.map(r => r.v)).toEqual([60]);
|
|
// markers stripped
|
|
[leaf, sub, grand].forEach(frame =>
|
|
frame.forEach(r =>
|
|
Object.keys(r).forEach(k =>
|
|
expect(k.endsWith('__superset_grouping')).toBe(false),
|
|
),
|
|
),
|
|
);
|
|
});
|
|
|
|
test('splitGroupingSetsResult attributes marker-less rows to the leaf level', () => {
|
|
const rows = [
|
|
{ region: 'US', topic: 'a', v: 10 },
|
|
{ region: 'US', topic: 'b', v: 20 },
|
|
];
|
|
const [leaf, sub, grand] = splitGroupingSetsResult(
|
|
rows,
|
|
[['region', 'topic'], ['region'], []],
|
|
['region', 'topic'],
|
|
);
|
|
expect(leaf).toEqual(rows);
|
|
expect(sub).toEqual([]);
|
|
expect(grand).toEqual([]);
|
|
});
|