Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import {
afterEach, beforeEach, describe, expect, it, jest,
} from '@jest/globals';

import {
afterTest,
beforeTest,
createDataGrid,
flushAsync,
} from '../../../grid_core/__tests__/__mock__/helpers/utils';

describe('Summary', () => {
beforeEach(beforeTest);
afterEach(afterTest);

describe('column lookup map performance optimization (T1316562)', () => {
const SUMMARY_COUNT = 100;
const GROUP_COUNT = 4;

const dataSource = [
{
id: 1, name: 'Alice', value: 10, category: 'A', region: 'X',
},
{
id: 2, name: 'Bob', value: 20, category: 'A', region: 'Y',
},
{
id: 3, name: 'Carol', value: 30, category: 'B', region: 'X',
},
{
id: 4, name: 'Dave', value: 40, category: 'B', region: 'Y',
},
];

const groupSummaryItems = Array.from(
{ length: SUMMARY_COUNT },
(_, i) => ({
column: i % 2 === 0 ? 'value' : 'id',
summaryType: 'sum' as const,
showInGroupFooter: false,
name: `summary_${i}`,
}),
);

it('should use columnMap optimization and avoid O(n*m) columnOption calls on refresh', async () => {
const { instance } = await createDataGrid({
dataSource,
columns: [
{ dataField: 'id' },
{ dataField: 'name' },
{ dataField: 'value' },
{ dataField: 'category', groupIndex: 0 },
{ dataField: 'region', groupIndex: 1 },
],
summary: { groupItems: groupSummaryItems },
});

await flushAsync();

const columnsController = instance.getController('columns');
const spy = jest.spyOn(columnsController, 'columnOption');

instance.refresh().catch(() => {});
await flushAsync();

const worstCaseMinCalls = SUMMARY_COUNT * GROUP_COUNT;

expect(spy.mock.calls.length).toBeLessThan(worstCaseMinCalls);

spy.mockRestore();
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import {
describe, expect, it,
} from '@jest/globals';
import type { Column } from '@ts/grids/grid_core/columns_controller/types';

import { getColumnFromMap, getSummaryCellIndex } from '../utils';

const makeColumn = (overrides: Partial<Column> = {}): Column => ({
...overrides,
});

describe('getSummaryCellIndex', () => {
describe('when isGroupRow is false (default)', () => {
it('should return column.index', () => {
const column = makeColumn({ index: 5 });

expect(getSummaryCellIndex(column)).toBe(5);
});

it('should return -1 when column.index is undefined', () => {
const column = makeColumn();

expect(getSummaryCellIndex(column)).toBe(-1);
});

it('should ignore prevColumn when isGroupRow is false', () => {
const column = makeColumn({ index: 3 });
const prevColumn = makeColumn({ index: 10, type: 'groupExpand' });

expect(getSummaryCellIndex(column, prevColumn, false)).toBe(3);
});
});

describe('when isGroupRow is true', () => {
describe('groupExpand handling', () => {
it('should return prevColumn.index when prevColumn.type is groupExpand', () => {
const column = makeColumn({ index: 5 });
const prevColumn = makeColumn({ index: 2, type: 'groupExpand' });

expect(getSummaryCellIndex(column, prevColumn, true)).toBe(2);
});

it('should return prevColumn.index when column.type is groupExpand', () => {
const column = makeColumn({ index: 5, type: 'groupExpand' });
const prevColumn = makeColumn({ index: 7 });

expect(getSummaryCellIndex(column, prevColumn, true)).toBe(7);
});

it('should return -1 when column.type is groupExpand and prevColumn is undefined', () => {
const column = makeColumn({ index: 5, type: 'groupExpand' });

expect(getSummaryCellIndex(column, undefined, true)).toBe(-1);
});

it('should return -1 when prevColumn.type is groupExpand and prevColumn.index is undefined', () => {
const column = makeColumn({ index: 5 });
const prevColumn = makeColumn({ type: 'groupExpand' });

expect(getSummaryCellIndex(column, prevColumn, true)).toBe(-1);
});
});

describe('groupIndex handling', () => {
it('should return column.index when groupIndex is not defined', () => {
const column = makeColumn({ index: 4 });

expect(getSummaryCellIndex(column, undefined, true)).toBe(4);
});

it('should return -1 when groupIndex is defined', () => {
const column = makeColumn({ index: 4, groupIndex: 0 });

expect(getSummaryCellIndex(column, undefined, true)).toBe(-1);
});

it('should return -1 when groupIndex is 0 (falsy but defined)', () => {
const column = makeColumn({ index: 8, groupIndex: 0 });

expect(getSummaryCellIndex(column, undefined, true)).toBe(-1);
});

it('should return column.index when groupIndex is undefined and prevColumn has no groupExpand type', () => {
const column = makeColumn({ index: 6 });
const prevColumn = makeColumn({ index: 3 });

expect(getSummaryCellIndex(column, prevColumn, true)).toBe(6);
});
});
});
});

describe('getColumnFromMap', () => {
const colA = makeColumn({ index: 0, dataField: 'fieldA' });
const colB = makeColumn({ index: 1, dataField: 'fieldB' });
const getColumnMap = (): Map<string | number, Column> => (
new Map<string | number, Column>([
[0, colA],
['fieldA', colA],
[1, colB],
['fieldB', colB],
])
);

it('should return column by numeric index', () => {
const columnMap = getColumnMap();

expect(getColumnFromMap(0, columnMap)).toBe(colA);
expect(getColumnFromMap(1, columnMap)).toBe(colB);
});

it('should return column by string dataField', () => {
const columnMap = getColumnMap();

expect(getColumnFromMap('fieldA', columnMap)).toBe(colA);
expect(getColumnFromMap('fieldB', columnMap)).toBe(colB);
});

it('should return undefined for undefined identifier', () => {
const columnMap = getColumnMap();

expect(getColumnFromMap(undefined, columnMap)).toBeUndefined();
});

it('should return undefined for identifier not in the map', () => {
const columnMap = getColumnMap();

expect(getColumnFromMap(999, columnMap)).toBeUndefined();
expect(getColumnFromMap('nonExistent', columnMap)).toBeUndefined();
});

it('should work with an empty map', () => {
const emptyMap = new Map<string | number, Column>();

expect(getColumnFromMap(0, emptyMap)).toBeUndefined();
expect(getColumnFromMap('fieldA', emptyMap)).toBeUndefined();
expect(getColumnFromMap(undefined, emptyMap)).toBeUndefined();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ import type { RowsView } from '../../grid_core/views/m_rows_view';
import AggregateCalculator from '../m_aggregate_calculator';
import gridCore from '../m_core';
import dataSourceAdapterProvider from '../m_data_source_adapter';
import { getSummaryCellIndex } from './utils';
import type { ColumnMap, SummaryItem } from './types';
import { getColumnFromMap, getSummaryCellIndex } from './utils';

const DATAGRID_TOTAL_FOOTER_CLASS = 'dx-datagrid-total-footer';
const DATAGRID_SUMMARY_ITEM_CLASS = 'dx-datagrid-summary-item';
Expand Down Expand Up @@ -471,9 +472,9 @@ const data = (Base: ModuleType<DataController>) => class SummaryDataControllerEx
private _processGroupItem(groupItem, options) {
const that = this;

if (!options.summaryGroupItems) {
options.summaryGroupItems = that.option('summary.groupItems') || [];
}
options.summaryGroupItems ??= that.option('summary.groupItems') || [];
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

looks like no reason to use alias for this here, same in line 524
tip: if use const { summary: { groupItems } } = this.option() it will not lose types when options typed, while usage with argument loses

options.summaryColumnMap ??= this._buildColumnLookupMap();

if (groupItem.rowType === 'group') {
let groupColumnIndex = -1;
let afterGroupColumnIndex = -1;
Expand All @@ -490,32 +491,96 @@ const data = (Base: ModuleType<DataController>) => class SummaryDataControllerEx
}
});

groupItem.summaryCells = this._calculateSummaryCells(options.summaryGroupItems, getGroupAggregates(groupItem.data), options.visibleColumns, (summaryItem, column) => {
if (summaryItem.showInGroupFooter) {
return -1;
}
groupItem.summaryCells = this._calculateSummaryCells(
options.summaryGroupItems,
getGroupAggregates(groupItem.data),
options.visibleColumns,
(summaryItem, column) => {
if (summaryItem.showInGroupFooter) {
return -1;
}

if (summaryItem.alignByColumn && column && !isDefined(column.groupIndex) && (column.index !== afterGroupColumnIndex)) {
return column.index;
}
return groupColumnIndex;
}, true);
if (summaryItem.alignByColumn
&& column
&& !isDefined(column.groupIndex)
&& (column.index !== afterGroupColumnIndex)
) {
return column.index;
}

return groupColumnIndex;
},
true,
options.summaryColumnMap,
);
}

if (groupItem.rowType === DATAGRID_GROUP_FOOTER_ROW_TYPE) {
groupItem.summaryCells = this._calculateSummaryCells(options.summaryGroupItems, getGroupAggregates(groupItem.data), options.visibleColumns, (summaryItem, column) => (summaryItem.showInGroupFooter && that._isDataColumn(column) ? column.index : -1));
groupItem.summaryCells = this._calculateSummaryCells(
options.summaryGroupItems,
getGroupAggregates(groupItem.data),
options.visibleColumns,
(summaryItem, column) => (
summaryItem.showInGroupFooter && that._isDataColumn(column) ? column.index : -1
),
false,
options.summaryColumnMap,
);
}

return groupItem;
}

private _calculateSummaryCells(summaryItems, aggregates, visibleColumns, calculateTargetColumnIndex, isGroupRow?) {
const that = this;
// The map is built once per _processItems cycle (via options) and discarded after.
private _buildColumnLookupMap(): ColumnMap {
const columnMap: ColumnMap = new Map();
const allColumns = [
...this._columnsController.getColumns(),
...(this._columnsController._commandColumns ?? []),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Even though the _commandColumns property has a public modifier, it should actually be private. Perhaps we should implement a public getCommandColumns method at the columnsController level? We could also add a public getAllColumns method to the columnController

];

for (const column of allColumns) {
const copiedColumn = { ...column };
// The method registers each column under a few keys: index, name, dataField, and caption.
// This is because the developer can specify summaryItem.column (and summaryItem.showInColumn)
// in any of these forms — number for column index and string for all the rest.
const keys = [
column.index,
column.name,
column.dataField,
column.caption,
].filter((key) => (
key !== undefined && !columnMap.has(key)
));

for (const key of keys) {
columnMap.set(key, copiedColumn);
}
}

return columnMap;
}

private _calculateSummaryCells(
summaryItems: SummaryItem[],
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor comment: There are too many arguments. Can we group some of them into an object?

aggregates,
visibleColumns,
calculateTargetColumnIndex,
isGroupRow?,
columnMap?: ColumnMap,
) {
const summaryCells: any = [];
const summaryCellsByColumns = {};

each(summaryItems, (summaryIndex, summaryItem) => {
const column = that._columnsController.columnOption(summaryItem.column);
const showInColumn = summaryItem.showInColumn && that._columnsController.columnOption(summaryItem.showInColumn) || column;
const column = columnMap
? getColumnFromMap(summaryItem.column, columnMap)
: this._columnsController.columnOption(summaryItem.column);
const showInColumn = (summaryItem.showInColumn
&& (columnMap
? getColumnFromMap(summaryItem.showInColumn, columnMap)
: this._columnsController.columnOption(summaryItem.showInColumn)))
|| column;
const columnIndex = calculateTargetColumnIndex(summaryItem, showInColumn);

if (columnIndex >= 0) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import type { Column } from '@ts/grids/grid_core/columns_controller/types';

export interface SummaryItem {
/** Number as a column index or string as a column name, dataField, or caption. */
column?: string | number | undefined;
/** Number as a column index or string as a column name, dataField, or caption. */
showInColumn?: string | number | undefined;
showInGroupFooter?: boolean;
alignByColumn?: boolean;
summaryType?: string | undefined;
valueFormat?: unknown;
name?: string | undefined;
skipEmptyValues?: boolean;
alignment?: string | undefined;
cssClass?: string | undefined;
customizeText?: ((itemInfo: { value?: string | number | Date; valueText: string }) => string);
displayFormat?: string | undefined;
}

export type ColumnMap = Map<string | number, Column>;
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,10 @@ export function getSummaryCellIndex(

return !isDefined(column.groupIndex) ? cellIndex : -1;
}

export function getColumnFromMap(
identifier: string | number | undefined,
columnMap: Map<string | number, Column>,
): Column | undefined {
return identifier !== undefined ? columnMap.get(identifier) : undefined;
}
Loading