diff --git a/packages/devextreme/js/__internal/grids/data_grid/summary/__tests__/m_summary.integration.test.ts b/packages/devextreme/js/__internal/grids/data_grid/summary/__tests__/m_summary.integration.test.ts new file mode 100644 index 000000000000..0b7028be6b98 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/data_grid/summary/__tests__/m_summary.integration.test.ts @@ -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(); + }); + }); +}); diff --git a/packages/devextreme/js/__internal/grids/data_grid/summary/__tests__/utils.test.ts b/packages/devextreme/js/__internal/grids/data_grid/summary/__tests__/utils.test.ts new file mode 100644 index 000000000000..b8ae2fb9b85d --- /dev/null +++ b/packages/devextreme/js/__internal/grids/data_grid/summary/__tests__/utils.test.ts @@ -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 => ({ + ...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 => ( + new Map([ + [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(); + + expect(getColumnFromMap(0, emptyMap)).toBeUndefined(); + expect(getColumnFromMap('fieldA', emptyMap)).toBeUndefined(); + expect(getColumnFromMap(undefined, emptyMap)).toBeUndefined(); + }); +}); diff --git a/packages/devextreme/js/__internal/grids/data_grid/summary/m_summary.ts b/packages/devextreme/js/__internal/grids/data_grid/summary/m_summary.ts index 8dbc6081f7e6..a49cab1a3a11 100644 --- a/packages/devextreme/js/__internal/grids/data_grid/summary/m_summary.ts +++ b/packages/devextreme/js/__internal/grids/data_grid/summary/m_summary.ts @@ -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'; @@ -471,9 +472,9 @@ const data = (Base: ModuleType) => class SummaryDataControllerEx private _processGroupItem(groupItem, options) { const that = this; - if (!options.summaryGroupItems) { - options.summaryGroupItems = that.option('summary.groupItems') || []; - } + options.summaryGroupItems ??= that.option('summary.groupItems') || []; + options.summaryColumnMap ??= this._buildColumnLookupMap(); + if (groupItem.rowType === 'group') { let groupColumnIndex = -1; let afterGroupColumnIndex = -1; @@ -490,32 +491,96 @@ const data = (Base: ModuleType) => 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 ?? []), + ]; + + 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[], + 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) { diff --git a/packages/devextreme/js/__internal/grids/data_grid/summary/types.ts b/packages/devextreme/js/__internal/grids/data_grid/summary/types.ts new file mode 100644 index 000000000000..97eb4331d699 --- /dev/null +++ b/packages/devextreme/js/__internal/grids/data_grid/summary/types.ts @@ -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; diff --git a/packages/devextreme/js/__internal/grids/data_grid/summary/utils.ts b/packages/devextreme/js/__internal/grids/data_grid/summary/utils.ts index a6065e3b565e..bcdb802be97e 100644 --- a/packages/devextreme/js/__internal/grids/data_grid/summary/utils.ts +++ b/packages/devextreme/js/__internal/grids/data_grid/summary/utils.ts @@ -18,3 +18,10 @@ export function getSummaryCellIndex( return !isDefined(column.groupIndex) ? cellIndex : -1; } + +export function getColumnFromMap( + identifier: string | number | undefined, + columnMap: Map, +): Column | undefined { + return identifier !== undefined ? columnMap.get(identifier) : undefined; +}