Compare commits

...
12 changed files with 183 additions and 93 deletions
@@ -53,12 +53,6 @@ jobs:
python-version: ${{ github.event_name == 'pull_request' && fromJSON('["current"]') || fromJSON('["current", "next"]') }}
env:
PYTHONPATH: ${{ github.workspace }}
# Promotes the SQLAlchemy 2.0 deprecation warnings already locked in as
# errors via pytest.ini's `filterwarnings` to actually run in CI, so a
# regression on those fails the build instead of relying on a
# contributor remembering to set this locally. See the migration
# battleplan: https://github.com/apache/superset/discussions/40273
SQLALCHEMY_WARN_20: "1"
steps:
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@@ -17,7 +17,7 @@
* under the License.
*/
import { QueryFormMetric } from '@superset-ui/core';
import { getTotalsMetrics } from './getTotalsMetrics';
import { getTotalsMetrics, toTotalsAggregate } from './getTotalsMetrics';
const simpleMetric = (aggregate: string): QueryFormMetric =>
({
@@ -76,4 +76,31 @@ describe('getTotalsMetrics', () => {
test('returns an empty array when given no metrics', () => {
expect(getTotalsMetrics([], 'AVG')).toEqual([]);
});
test("ORIGINAL keeps each metric's own aggregate", () => {
const metrics = [
simpleMetric('COUNT_DISTINCT'),
sqlMetric(),
savedMetric(),
];
const result = getTotalsMetrics(metrics, 'ORIGINAL');
expect(result).toBe(metrics);
expect(result[0]).toEqual(
expect.objectContaining({ aggregate: 'COUNT_DISTINCT' }),
);
});
});
describe('toTotalsAggregate', () => {
test.each(['SUM', 'AVG'] as const)('passes %s through', value => {
expect(toTotalsAggregate(value)).toBe(value);
});
test.each([undefined, null, '', 'MEDIAN', 'sum'])(
'falls back to ORIGINAL for %p',
value => {
expect(toTotalsAggregate(value)).toBe('ORIGINAL');
},
);
});
@@ -18,26 +18,46 @@
*/
import { isAdhocMetricSimple, QueryFormMetric } from '@superset-ui/core';
export type TotalsAggregate = 'SUM' | 'AVG';
/**
* How the "Show summary" totals row aggregates each metric.
*
* ``ORIGINAL`` keeps every metric's own aggregation. It is the default because
* overriding is not universally valid: ``SUM`` over a ``COUNT_DISTINCT`` of a
* non-numeric column (a uuid, say) is rejected outright by the database, and
* over a numeric id column it silently produces a meaningless number.
*/
export type TotalsAggregate = 'ORIGINAL' | 'SUM' | 'AVG';
/**
* Build the metrics for a chart's "Show summary" totals query, overriding
* each Simple (adhoc) metric's aggregate function with the user-chosen
* totals aggregate. The totals query has no GROUP BY, so the database
* evaluates each metric fresh over all rows -- swapping the aggregate here
* is a correct, independent computation, not a re-aggregation of
* already-aggregated per-row values.
* Build the metrics for a chart's "Show summary" totals query.
*
* Custom-SQL metrics and saved (string) metrics pass through unchanged:
* there is no safe way to rewrite an arbitrary SQL expression's aggregate
* function without parsing it, so the totals row keeps their own native
* aggregate for those.
* With SUM or AVG, each Simple (adhoc) metric is cloned with its aggregate
* replaced. The totals query has no GROUP BY, so the database evaluates each
* metric fresh over all rows -- that swap is an independent computation, not a
* re-aggregation of already-aggregated per-row values.
*
* Custom-SQL and saved (string) metrics always pass through unchanged: there is
* no safe way to rewrite an arbitrary SQL expression's aggregate without
* parsing it, so the totals row keeps their own native aggregate.
*/
export function getTotalsMetrics(
metrics: QueryFormMetric[],
aggregate: TotalsAggregate,
): QueryFormMetric[] {
if (aggregate === 'ORIGINAL') {
return metrics;
}
return metrics.map(metric =>
isAdhocMetricSimple(metric) ? { ...metric, aggregate } : metric,
);
}
/**
* Narrow a raw ``totals_aggregate`` form-data value to a TotalsAggregate.
*
* Anything other than an explicit SUM/AVG — including charts saved before the
* control existed — keeps each metric's own aggregation.
*/
export function toTotalsAggregate(value: unknown): TotalsAggregate {
return value === 'SUM' || value === 'AVG' ? value : 'ORIGINAL';
}
@@ -64,9 +64,8 @@ export default function createSmartNumberFormatter(
description,
formatFunc: value => `${getSign(value)}${formatValue(value)}`,
id:
id || signed
? NumberFormats.SMART_NUMBER_SIGNED
: NumberFormats.SMART_NUMBER,
id ??
(signed ? NumberFormats.SMART_NUMBER_SIGNED : NumberFormats.SMART_NUMBER),
label: label ?? 'Adaptive formatter',
});
}
@@ -24,6 +24,12 @@ describe('createSmartNumberFormatter(options)', () => {
const formatter = createSmartNumberFormatter();
expect(formatter).toBeInstanceOf(NumberFormatter);
});
test('uses the supplied formatter id regardless of signed option', () => {
expect(createSmartNumberFormatter({ id: 'custom' }).id).toBe('custom');
expect(
createSmartNumberFormatter({ id: 'custom-signed', signed: true }).id,
).toBe('custom-signed');
});
describe('using default options', () => {
const formatter = createSmartNumberFormatter();
test('formats 0 correctly', () => {
@@ -38,7 +38,7 @@ import {
getTotalsMetrics,
isTimeComparison,
timeCompareOperator,
TotalsAggregate,
toTotalsAggregate,
} from '@superset-ui/chart-controls';
import { isEmpty } from 'lodash-es';
import { TableChartFormData } from './types';
@@ -696,13 +696,16 @@ export const buildQueryUncached: BuildQuery<TableChartFormData> = (
formData.show_totals &&
queryMode === QueryMode.Aggregate,
);
const totalsAggregate: TotalsAggregate =
formData.totals_aggregate === 'AVG' ? 'AVG' : 'SUM';
const totalsAggregate = toTotalsAggregate(formData.totals_aggregate);
// Raw-mode summary columns have no metric of their own to preserve, so
// ORIGINAL has nothing to fall back to; sum them as before.
const rawSummaryAggregate =
totalsAggregate === 'ORIGINAL' ? 'SUM' : totalsAggregate;
const totalsMetrics =
rawSummaryColumns.length > 0
? rawSummaryColumns.map(columnName => ({
expressionType: 'SIMPLE' as const,
aggregate: totalsAggregate,
aggregate: rawSummaryAggregate,
column: { column_name: columnName },
label: columnName,
}))
@@ -503,14 +503,18 @@ const config: ControlPanelConfig = {
label: t('Summary aggregation'),
renderTrigger: true,
description: t(
'Aggregation used for the summary row, independent of each ' +
"metric's own aggregation. Only applies to simple metrics " +
'(a metric built from custom SQL keeps its own aggregation ' +
'in the summary row).',
'Aggregation used for the summary row. By default each metric ' +
'keeps its own aggregation; Sum and Average override it for ' +
'the summary row only. The override applies to simple ' +
'metrics (a metric built from custom SQL always keeps its ' +
'own aggregation). Overriding a count or a distinct count ' +
'sums the counted column instead, which fails outright on a ' +
'non-numeric column.',
),
default: 'SUM',
default: 'ORIGINAL',
clearable: false,
choices: [
['ORIGINAL', t("Each metric's own")],
['SUM', t('Sum')],
['AVG', t('Average')],
],
@@ -1561,7 +1561,7 @@ describe('plugin-chart-ag-grid-table', () => {
expect(queries[1].metrics).toEqual(['count']);
});
test('defaults aggregate-mode totals to SUM for a simple metric', () => {
test("defaults aggregate-mode totals to the metric's own aggregate", () => {
const simpleMetric = {
expressionType: 'SIMPLE' as const,
column: { column_name: 'sales' },
@@ -1580,9 +1580,29 @@ describe('plugin-chart-ag-grid-table', () => {
{ ownState: {} },
);
expect(queries[1].metrics).toEqual([
{ ...simpleMetric, aggregate: 'SUM' },
]);
expect(queries[1].metrics).toEqual([simpleMetric]);
});
test('keeps COUNT_DISTINCT in aggregate-mode totals by default', () => {
const countDistinctMetric = {
expressionType: 'SIMPLE' as const,
column: { column_name: 'contract_id' },
aggregate: 'COUNT_DISTINCT' as const,
label: 'contracts',
};
const { queries } = buildQuery(
{
viz_type: VizType.Table,
datasource: '11__table',
query_mode: QueryMode.Aggregate,
groupby: ['state'],
metrics: [countDistinctMetric],
show_totals: true,
},
{ ownState: {} },
);
expect(queries[1].metrics).toEqual([countDistinctMetric]);
});
test('overrides aggregate-mode totals to AVG for a simple metric when totals_aggregate is set', () => {
@@ -34,7 +34,7 @@ import {
getTotalsMetrics,
isTimeComparison,
timeCompareOperator,
TotalsAggregate,
toTotalsAggregate,
} from '@superset-ui/chart-controls';
import { isEmpty } from 'lodash-es';
import { TableChartFormData } from './types';
@@ -349,8 +349,7 @@ export const buildQuery: BuildQuery<TableChartFormData> = (
formData.show_totals &&
queryMode === QueryMode.Aggregate
) {
const totalsAggregate: TotalsAggregate =
formData.totals_aggregate === 'AVG' ? 'AVG' : 'SUM';
const totalsAggregate = toTotalsAggregate(formData.totals_aggregate);
extraQueries.push({
...queryObject,
columns: [],
@@ -475,14 +475,18 @@ const config: ControlPanelConfig = {
type: 'SelectControl',
label: t('Summary aggregation'),
description: t(
'Aggregation used for the summary row, independent of each ' +
"metric's own aggregation. Only applies to simple metrics " +
'(a metric built from custom SQL keeps its own aggregation ' +
'in the summary row).',
'Aggregation used for the summary row. By default each metric ' +
'keeps its own aggregation; Sum and Average override it for ' +
'the summary row only. The override applies to simple ' +
'metrics (a metric built from custom SQL always keeps its ' +
'own aggregation). Overriding a count or a distinct count ' +
'sums the counted column instead, which fails outright on a ' +
'non-numeric column.',
),
default: 'SUM',
default: 'ORIGINAL',
clearable: false,
choices: [
['ORIGINAL', t("Each metric's own")],
['SUM', t('Sum')],
['AVG', t('Average')],
],
@@ -340,7 +340,7 @@ describe('plugin-chart-table', () => {
label: 'sum_sales',
};
test('defaults the totals query metric aggregate to SUM', () => {
test("defaults to each metric's own aggregate", () => {
const { queries } = buildQueryCached({
...basicFormData,
query_mode: QueryMode.Aggregate,
@@ -350,9 +350,28 @@ describe('plugin-chart-table', () => {
});
expect(queries).toHaveLength(2);
expect(queries[1].metrics).toEqual([
{ ...simpleMetric, aggregate: 'SUM' },
]);
expect(queries[1].metrics).toEqual([simpleMetric]);
});
test('keeps COUNT_DISTINCT in the summary row by default', () => {
// Overriding this to SUM sums the counted column instead of counting
// it, which is meaningless on a numeric id and is rejected outright by
// the database on a non-numeric one (e.g. a uuid).
const countDistinctMetric = {
expressionType: 'SIMPLE' as const,
column: { column_name: 'contract_id' },
aggregate: 'COUNT_DISTINCT' as const,
label: 'contracts',
};
const { queries } = buildQueryCached({
...basicFormData,
query_mode: QueryMode.Aggregate,
metrics: [countDistinctMetric],
groupby: ['category'],
show_totals: true,
});
expect(queries[1].metrics).toEqual([countDistinctMetric]);
});
test('overrides simple metric aggregate with totals_aggregate for the summary query only', () => {
@@ -379,37 +379,37 @@ msgstr "虛擬"
msgid "%s aggregates(s)"
msgstr "%s 聚合"
#, fuzzy, python-format
#, python-format
msgid "%s column"
msgid_plural "%s columns"
msgstr[0] "%s "
msgstr[0] "%s 個欄位"
#, python-format
msgid "%s column(s)"
msgstr "%s "
msgstr "%s 個欄位"
#, fuzzy, python-format
#, python-format
msgid "%s day ago"
msgid_plural "%s days ago"
msgstr[0] "1前"
msgstr[0] "%s 天前"
#, fuzzy, python-format
#, python-format
msgid "%s hr ago"
msgid_plural "%s hr ago"
msgstr[0] "%s "
msgstr[0] "%s 小時前"
#, fuzzy, python-format
#, python-format
msgid "%s imported"
msgstr "數據集已導入"
msgstr "已匯入 %s"
#, fuzzy, python-format
#, python-format
msgid "%s item"
msgid_plural "%s items"
msgstr[0] "%s 個項"
msgstr[0] "%s 個項"
#, fuzzy, python-format
#, python-format
msgid "%s item(s)"
msgstr "%s 個項"
msgstr "%s 個項"
# Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: ar, cs, de,
# es, fa, fr, ja, lv, mi, nl, pl, pt_BR, ru, sk, sl, sr, sr_Latn, tr, uk]
@@ -419,15 +419,15 @@ msgid ""
"all selected objects."
msgstr "%s 個項目無法標記,因為您對所有選取的物件沒有編輯權限。"
#, fuzzy, python-format
#, python-format
msgid "%s metric"
msgid_plural "%s metrics"
msgstr[0] "排序指標"
msgstr[0] "%s 個指標"
#, fuzzy, python-format
#, python-format
msgid "%s min ago"
msgid_plural "%s min ago"
msgstr[0] ""
msgstr[0] "%s 分鐘前"
#, python-format
msgid ""
@@ -451,47 +451,47 @@ msgstr[0] "%s 個選項"
msgid "%s option(s)"
msgstr "%s 個選項"
#, fuzzy, python-format
#, python-format
msgid "%s out of %s column"
msgid_plural "%s out of %s columns"
msgstr[0] "自定義列"
msgstr[0] "已選取 %s%s 個欄位"
#, fuzzy, python-format
#, python-format
msgid "%s out of %s metric"
msgid_plural "%s out of %s metrics"
msgstr[0] "排序指標"
msgstr[0] "已選取 %s%s 個指標"
#, fuzzy, python-format
#, python-format
msgid "%s out of %s selected"
msgstr "%s 已選定"
msgstr "已選取 %s%s 個項目"
#, fuzzy, python-format
#, python-format
msgid "%s recipients"
msgstr "%s 最近"
msgstr "%s 收件者"
#, fuzzy, python-format
#, python-format
msgid "%s record..."
msgid_plural "%s records..."
msgstr[0] "%s 異常"
msgstr[0] "%s 筆記錄..."
#, fuzzy, python-format
#, python-format
msgid "%s row"
msgid_plural "%s rows"
msgstr[0] "%s "
msgstr[0] "%s "
#, fuzzy, python-format
#, python-format
msgid "%s s ago"
msgid_plural "%s s ago"
msgstr[0] "30 天之前"
msgstr[0] "%s 秒前"
#, python-format
msgid "%s saved metric(s)"
msgstr "%s 保存的指標"
#, fuzzy, python-format
#, python-format
msgid "%s second"
msgid_plural "%s seconds"
msgstr[0] "5 秒"
msgstr[0] "%s 秒"
# Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: de, es, sr,
# sr_Latn]
@@ -511,13 +511,13 @@ msgstr "%s 個語意檢視新增失敗"
msgid "%s semantic view(s) failed to add: %s"
msgstr "%s 個語意檢視新增失敗:%s"
#, fuzzy, python-format
#, python-format
msgid "%s tab selected"
msgstr "%s 已選定"
msgstr "已選取「%s」分頁"
#, fuzzy, python-format
#, python-format
msgid "%s updated"
msgstr "上次更新 %s"
msgstr "更新 %s"
#, python-format
msgid "%s%s"
@@ -677,13 +677,11 @@ msgstr "每年年初的頻率"
msgid "10 minute"
msgstr "10 分鐘"
#, fuzzy
msgid "10 seconds"
msgstr "30 秒"
msgstr "10 秒"
#, fuzzy
msgid "10/90 percentiles"
msgstr "9/91 百分位"
msgstr "10/90 百分位"
#. do-not-translate
msgid "10000"
@@ -696,9 +694,8 @@ msgstr "週"
msgid "104 weeks ago"
msgstr "104 週之前"
#, fuzzy
msgid "12 hours"
msgstr "1 小時"
msgstr "12 小時"
msgid "15 minute"
msgstr "15 分鐘"
@@ -761,9 +758,8 @@ msgstr "2/98 百分位"
msgid "22"
msgstr "22"
#, fuzzy
msgid "24 hours"
msgstr "6 小時"
msgstr "24 小時"
#, fuzzy
msgid "28 days"
@@ -831,9 +827,8 @@ msgstr "5 秒"
msgid "5 seconds"
msgstr "5 秒"
#, fuzzy
msgid "5/95 percentiles"
msgstr "9/91 百分位"
msgstr "5/95 百分位"
#, fuzzy
msgid "52 weeks"