mirror of
https://github.com/apache/superset.git
synced 2026-09-01 21:11:28 +00:00
554 lines
16 KiB
TypeScript
554 lines
16 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,
|
|
ShowValuesAsEnum,
|
|
} 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('isAdditiveMetric: MEDIAN/STDDEV_SAMP/VAR_SAMP are non-additive, with no dedicated code needed', () => {
|
|
// Regression guard: MEDIAN/STDDEV_SAMP/VAR_SAMP are new system-wide metric
|
|
// aggregates (not pivot-table-specific). They must fall outside
|
|
// ADDITIVE_AGGREGATES so totals/subtotals route through the correct
|
|
// DB-rollup path automatically, same as AVG/COUNT_DISTINCT already do --
|
|
// averaging per-group medians (or variances) is exactly the class of bug
|
|
// SIP-216 fixed for AVG, and would be equally wrong here.
|
|
(['MEDIAN', 'STDDEV_SAMP', 'VAR_SAMP'] as const).forEach(aggregate => {
|
|
expect(
|
|
isAdditiveMetric({
|
|
expressionType: 'SIMPLE',
|
|
aggregate,
|
|
column: { column_name: 'num' },
|
|
label: `${aggregate.toLowerCase()}_num`,
|
|
} as QueryFormMetric),
|
|
).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('pruning: percent_row forces the row-total (columns collapsed) level even with rowTotals off', () => {
|
|
const combinations = buildGroupbyCombinations({
|
|
...baseFormData,
|
|
colTotals: false,
|
|
rowTotals: false,
|
|
colSubTotals: false,
|
|
rowSubTotals: false,
|
|
showValuesAs: ShowValuesAsEnum.PERCENT_OF_ROW,
|
|
});
|
|
expect(combinations).toEqual([
|
|
{ rows: ['row1', 'row2'], columns: [] },
|
|
{ rows: ['row1', 'row2'], columns: ['col1', 'col2'] },
|
|
]);
|
|
});
|
|
|
|
test('pruning: percent_col forces the column-total (rows collapsed) level even with colTotals off', () => {
|
|
const combinations = buildGroupbyCombinations({
|
|
...baseFormData,
|
|
colTotals: false,
|
|
rowTotals: false,
|
|
colSubTotals: false,
|
|
rowSubTotals: false,
|
|
showValuesAs: ShowValuesAsEnum.PERCENT_OF_COLUMN,
|
|
});
|
|
expect(combinations).toEqual([
|
|
{ rows: [], columns: ['col1', 'col2'] },
|
|
{ rows: ['row1', 'row2'], columns: ['col1', 'col2'] },
|
|
]);
|
|
});
|
|
|
|
test('pruning: percent_total forces the grand-total level even with both totals off', () => {
|
|
const combinations = buildGroupbyCombinations({
|
|
...baseFormData,
|
|
colTotals: false,
|
|
rowTotals: false,
|
|
colSubTotals: false,
|
|
rowSubTotals: false,
|
|
showValuesAs: ShowValuesAsEnum.PERCENT_OF_TOTAL,
|
|
});
|
|
// Rows is the outer loop and columns the inner loop (see
|
|
// buildGroupbyCombinations's flatMap), so the rows-collapsed level pairs
|
|
// with every column prefix before the leaf row prefix does.
|
|
expect(combinations).toEqual([
|
|
{ rows: [], columns: [] },
|
|
{ rows: [], columns: ['col1', 'col2'] },
|
|
{ rows: ['row1', 'row2'], columns: [] },
|
|
{ rows: ['row1', 'row2'], columns: ['col1', 'col2'] },
|
|
]);
|
|
});
|
|
|
|
test('pruning: actual (no percent mode) does not force any extra level', () => {
|
|
const combinations = buildGroupbyCombinations({
|
|
...baseFormData,
|
|
colTotals: false,
|
|
rowTotals: false,
|
|
colSubTotals: false,
|
|
rowSubTotals: false,
|
|
showValuesAs: ShowValuesAsEnum.ACTUAL,
|
|
});
|
|
expect(combinations).toEqual([
|
|
{ rows: ['row1', 'row2'], columns: ['col1', 'col2'] },
|
|
]);
|
|
});
|
|
|
|
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([]);
|
|
});
|