-
Notifications
You must be signed in to change notification settings - Fork 667
DataGrid - Performance is slow if a large number of summaries is used (T1316562) #32921
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: 26_1
Are you sure you want to change the base?
Changes from all commits
41c35c5
2044d94
89aed94
32f9b5d
dc1d23a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 |
|---|---|---|
|
|
@@ -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<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') || []; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. looks like no reason to use alias for |
||
| options.summaryColumnMap ??= this._buildColumnLookupMap(); | ||
|
|
||
| if (groupItem.rowType === 'group') { | ||
| let groupColumnIndex = -1; | ||
| let afterGroupColumnIndex = -1; | ||
|
|
@@ -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 ?? []), | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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, | ||
dmirgaev marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| column.name, | ||
| column.dataField, | ||
| column.caption, | ||
| ].filter((key) => ( | ||
| key !== undefined && !columnMap.has(key) | ||
| )); | ||
|
|
||
dmirgaev marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| for (const key of keys) { | ||
| columnMap.set(key, copiedColumn); | ||
| } | ||
| } | ||
|
|
||
| return columnMap; | ||
| } | ||
|
|
||
| private _calculateSummaryCells( | ||
| summaryItems: SummaryItem[], | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) { | ||
|
|
||
| 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>; |
Uh oh!
There was an error while loading. Please reload this page.