Skip to content

Commit 60539e7

Browse files
committed
feat: option to improve Date Sorting by pre-parsing date items only once
1 parent 88e4372 commit 60539e7

File tree

8 files changed

+235
-150
lines changed

8 files changed

+235
-150
lines changed

docs/column-functionalities/Sorting.md

+58-1
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
- [Custom Sort Comparer](#custom-sort-comparer)
55
- [Update Sorting Dynamically](#update-sorting-dynamically)
66
- [Dynamic Query Field](#dynamic-query-field)
7+
- [Pre-Parse Date Columns for better perf](#pre-parse-date-columns-for-better-perf)
78

89
### Demo
910
[Demo Page](https://ghiscoding.github.io/Angular-Slickgrid/#/clientside) / [Demo Component](https://github.com/ghiscoding/angular-slickgrid/blob/master/src/app/examples/grid-clientside.component.ts)
@@ -131,4 +132,60 @@ queryFieldNameGetterFn: (dataContext) => {
131132
// for example let say that we query "profitRatio" when we have a profit else we query "lossRatio"
132133
return dataContext.profit > 0 ? 'profitRatio' : 'lossRatio';
133134
},
134-
```
135+
```
136+
137+
### Pre-Parse Date Columns for better perf
138+
##### requires v5.8.0 and higher
139+
140+
Sorting very large dataset with dates can be extremely slow when dates formated date strings, the reason is because these strings need to first be parsed and converted to real JS Dates before the Sorting process can actually happen (i.e. US Date Format). However parsing a large dataset can be slow **and** to make it worst, a Sort will revisit the same items over and over which mean that the same date strings will have to be reparsed over and over (for example while trying to Sort a dataset of 100 items, I saw some items being revisit 10 times and I can only imagine that it is exponentially worst with a large dataset).
141+
142+
So what can we do to make this faster with a more reasonable time? Well, we can simply pre-parse all date strings once and only once and convert them to JS Date objects. Then once we get Date objects, we'll simply read the UNIX timestamp which is what we need to Sort. The first pre-parse takes a bit of time and will be executed only on the first date column Sort (any sort afterward will read the pre-parsed Date objects).
143+
144+
What perf do we get with pre-parsing versus regular non-parsing? The benchmark was pulled using 50K items with 2 date columns (with US date format)
145+
- without non-parsing: ~15sec
146+
- with pre-parsing: ~1.4sec (1st pre-parse) and any subsequent Date sort is about ~0.2sec => so about ~1.5sec total
147+
148+
The summary, is that we get a 10x boost **but** not only that, we also get an extremely fast subsequent sort afterward (sorting Date objects is as fast as sorting Numbers).
149+
150+
#### Usage
151+
152+
You can use the `preParseDateColumns` grid option, it can be either set as either `boolean` or a `string` but there's big distinction between the 2 approaches (both approaches will mutate the dataset).
153+
1. `string` (i.e. set to `"__"`, it will parse a `"start"` date string and assign it as a `Date` object to a new `"__start"` prop)
154+
2. `boolean` (i.e. parse `"start"` date string and reassign it as a `Date` object on the same `"start"` prop)
155+
156+
> **Note** this option **does not work** with Backend Services because it simply has no effect.
157+
158+
For example if our dataset has 2 columns named "start" and "finish", then pre-parse the dataset,
159+
160+
with the 1nd approach (`string`), let's use `"__"` (which is in reality a prefix) it will mutate the dataset by adding new props (where `Date` is a `Date` object)
161+
162+
```diff
163+
data = [
164+
- { id: 0, start: '02/28/24', finish: '03/02/24' },
165+
- { id: 1, start: '01/14/24', finish: '02/13/24' },
166+
+ { id: 0, start: '02/28/24', finish: '03/02/24', __start: Date, __finish: Date },
167+
+ { id: 1, start: '01/14/24', finish: '02/13/24', __start: Date, __finish: Date },
168+
]
169+
```
170+
171+
with the 2nd approach (`boolean`), it will instead mutate the dataset by overwriting the same properties
172+
173+
```diff
174+
data = [
175+
- { id: 0, start: '02/28/24', finish: '03/02/24' },
176+
- { id: 1, start: '01/14/24', finish: '02/13/24' },
177+
+ { id: 0, start: Date, finish: Date },
178+
+ { id: 1, start: Date, finish: Date },
179+
]
180+
```
181+
182+
Which approach to choose? Both have pros and cons, overwriting the same props might cause problems with the column `type` that you use, you will have to give it a try yoursel. On the other hand, with the other approach, it will duplicate all date properties and take a bit more memory usage and when changing cells we'll need to make sure to keep these props in sync, however you will likely have less `type` issues.
183+
184+
What happens when we do any cell changes (for our use case, it would be Create/Update), for any Editors we simply subscribe to the `onCellChange` change event and we re-parse the date strings when detected. We also subscribe to certain CRUD functions as long as they come from the `GridService` then all is fine... However, if you use the DataView functions directly then we have no way of knowing when to parse because these functions from the DataView don't have any events. Lastly, if we overwrite the entire dataset, we will also detect this (via an internal flag) and the next time you sort a date then the pre-parse kicks in again.
185+
186+
#### Can I call the pre-parse myself?
187+
188+
Yes, if for example you want to pre-parse right after the grid is loaded, you could call the pre-parse yourself for either all items or a single item
189+
- all item pre-parsing: `this.sgb.sortService.preParseAllDateItems();`
190+
- the items will be read directly from the DataView
191+
- a single item parsing: `this.sgb.sortService.preParseSingleDateItem(item);`

package.json

+14-14
Original file line numberDiff line numberDiff line change
@@ -50,13 +50,13 @@
5050
},
5151
"dependencies": {
5252
"@ngx-translate/core": "^15.0.0",
53-
"@slickgrid-universal/common": "~5.7.0",
54-
"@slickgrid-universal/custom-footer-component": "~5.7.0",
55-
"@slickgrid-universal/empty-warning-component": "~5.7.0",
56-
"@slickgrid-universal/event-pub-sub": "~5.7.0",
57-
"@slickgrid-universal/pagination-component": "~5.7.0",
58-
"@slickgrid-universal/row-detail-view-plugin": "~5.7.0",
59-
"@slickgrid-universal/rxjs-observable": "~5.7.0",
53+
"@slickgrid-universal/common": "~5.8.0",
54+
"@slickgrid-universal/custom-footer-component": "~5.8.0",
55+
"@slickgrid-universal/empty-warning-component": "~5.8.0",
56+
"@slickgrid-universal/event-pub-sub": "~5.8.0",
57+
"@slickgrid-universal/pagination-component": "~5.8.0",
58+
"@slickgrid-universal/row-detail-view-plugin": "~5.8.0",
59+
"@slickgrid-universal/rxjs-observable": "~5.8.0",
6060
"dequal": "^2.0.3",
6161
"rxjs": "^7.8.1"
6262
},
@@ -79,19 +79,19 @@
7979
"@angular/platform-browser": "^18.2.6",
8080
"@angular/platform-browser-dynamic": "^18.2.6",
8181
"@angular/router": "^18.2.6",
82-
"@faker-js/faker": "^8.4.1",
82+
"@faker-js/faker": "^9.0.3",
8383
"@fnando/sparkline": "^0.3.10",
8484
"@formkit/tempo": "^0.1.2",
8585
"@ng-select/ng-select": "^13.8.2",
8686
"@ngx-translate/http-loader": "^8.0.0",
8787
"@popperjs/core": "^2.11.8",
8888
"@release-it/conventional-changelog": "^8.0.2",
89-
"@slickgrid-universal/composite-editor-component": "~5.7.0",
90-
"@slickgrid-universal/custom-tooltip-plugin": "~5.7.0",
91-
"@slickgrid-universal/excel-export": "~5.7.0",
92-
"@slickgrid-universal/graphql": "~5.7.0",
93-
"@slickgrid-universal/odata": "~5.7.0",
94-
"@slickgrid-universal/text-export": "~5.7.0",
89+
"@slickgrid-universal/composite-editor-component": "~5.8.0",
90+
"@slickgrid-universal/custom-tooltip-plugin": "~5.8.0",
91+
"@slickgrid-universal/excel-export": "~5.8.0",
92+
"@slickgrid-universal/graphql": "~5.8.0",
93+
"@slickgrid-universal/odata": "~5.8.0",
94+
"@slickgrid-universal/text-export": "~5.8.0",
9595
"@types/dompurify": "^3.0.5",
9696
"@types/fnando__sparkline": "^0.3.7",
9797
"@types/jest": "^29.5.13",

src/app/examples/grid-clientside.component.html

+3
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@ <h2>
3838
<button class="btn btn-outline-secondary btn-sm btn-icon" data-test="set-dynamic-sorting" (click)="setSortingDynamically()">
3939
Set Sorting Dynamically
4040
</button>
41+
<button class="btn btn-outline-secondary btn-sm btn-icon" (click)="logItems()">
42+
<span title="console.log all dataset items">Log Items</span>
43+
</button>
4144

4245
<angular-slickgrid gridId="grid4"
4346
[columnDefinitions]="columnDefinitions"

src/app/examples/grid-clientside.component.ts

+6-1
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import { CustomInputFilter } from './custom-inputFilter';
2020
function randomBetween(min: number, max: number) {
2121
return Math.floor(Math.random() * (max - min + 1) + min);
2222
}
23-
const NB_ITEMS = 1500;
23+
const NB_ITEMS = 5500;
2424
const URL_SAMPLE_COLLECTION_DATA = 'assets/data/collection_500_numbers.json';
2525

2626
@Component({
@@ -196,6 +196,7 @@ export class GridClientSideComponent implements OnInit {
196196
],
197197
},
198198
externalResources: [new ExcelExportService()],
199+
preParseDateColumns: '__' // or true
199200
};
200201

201202
// mock a dataset
@@ -206,6 +207,10 @@ export class GridClientSideComponent implements OnInit {
206207
this.angularGrid = angularGrid;
207208
}
208209

210+
logItems() {
211+
console.log(this.angularGrid.dataView?.getItems());
212+
}
213+
209214
mockData(itemCount: number, startingIndex = 0): any[] {
210215
// mock a dataset
211216
const tempDataset = [];

src/app/modules/angular-slickgrid/components/__tests__/angular-slickgrid.component.spec.ts

+37-8
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
ExtensionList,
2020
ExtensionService,
2121
ExtensionUtility,
22+
FieldType,
2223
Filters,
2324
FilterService,
2425
Formatter,
@@ -458,19 +459,17 @@ describe('Angular-Slickgrid Custom Component instantiated via Constructor', () =
458459
});
459460

460461
it('should keep frozen column index reference (via frozenVisibleColumnId) when grid is a frozen grid', () => {
461-
const sharedFrozenIndexSpy = jest.spyOn(SharedService.prototype, 'frozenVisibleColumnId', 'set');
462462
component.columnDefinitions = columnDefinitions;
463463
component.gridOptions = gridOptions;
464464
component.gridOptions.frozenColumn = 0;
465465

466466
component.initialization(slickEventHandler);
467467

468468
expect(component.eventHandler).toBe(slickEventHandler);
469-
expect(sharedFrozenIndexSpy).toHaveBeenCalledWith('name');
469+
expect(sharedService.frozenVisibleColumnId).toBe('name');
470470
});
471471

472472
it('should update "visibleColumns" in the Shared Service when "onColumnsReordered" event is triggered', () => {
473-
const sharedHasColumnsReorderedSpy = jest.spyOn(SharedService.prototype, 'hasColumnsReordered', 'set');
474473
const sharedVisibleColumnsSpy = jest.spyOn(SharedService.prototype, 'visibleColumns', 'set');
475474
const newVisibleColumns = [{ id: 'lastName', field: 'lastName' }, { id: 'fristName', field: 'fristName' }];
476475

@@ -479,7 +478,7 @@ describe('Angular-Slickgrid Custom Component instantiated via Constructor', () =
479478
mockGrid.onColumnsReordered.notify({ impactedColumns: newVisibleColumns, grid: mockGrid });
480479

481480
expect(component.eventHandler).toEqual(slickEventHandler);
482-
expect(sharedHasColumnsReorderedSpy).toHaveBeenCalledWith(true);
481+
expect(sharedService.hasColumnsReordered).toBe(true);
483482
expect(sharedVisibleColumnsSpy).toHaveBeenCalledWith(newVisibleColumns);
484483
});
485484

@@ -575,6 +574,40 @@ describe('Angular-Slickgrid Custom Component instantiated via Constructor', () =
575574
expect(resizerSpy).toHaveBeenCalledWith();
576575
});
577576

577+
it('should expect a console warning when grid is initialized with a dataset larger than 5K items without pre-parsing enabled', () => {
578+
const consoleWarnSpy = jest.spyOn(console, 'warn').mockReturnValue();
579+
jest.spyOn(mockDataView, 'getItemCount').mockReturnValueOnce(5001);
580+
const mockColumns: Column[] = [
581+
{ id: 'firstName', field: 'firstName' },
582+
{ id: 'updatedDate', field: 'updatedDate', type: FieldType.dateIso },
583+
];
584+
jest.spyOn(mockGrid, 'getColumns').mockReturnValueOnce(mockColumns);
585+
586+
component.gridOptions = { enableAutoResize: true };
587+
component.ngAfterViewInit();
588+
589+
expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('[Slickgrid-Universal] For getting better perf, we suggest you enable the `preParseDateColumns` grid option'));
590+
});
591+
592+
it('should expect a console warning when assigned dataset is larger than 5K items without pre-parsing enabled', () => {
593+
const consoleWarnSpy = jest.spyOn(console, 'warn').mockReturnValue();
594+
jest.spyOn(mockDataView, 'getItemCount').mockReturnValueOnce(0);
595+
const mockColumns: Column[] = [
596+
{ id: 'firstName', field: 'firstName' },
597+
{ id: 'updatedDate', field: 'updatedDate', type: FieldType.dateIso },
598+
];
599+
jest.spyOn(mockGrid, 'getColumns').mockReturnValueOnce(mockColumns);
600+
601+
component.gridOptions = { enableAutoResize: true };
602+
component.ngAfterViewInit();
603+
604+
// we'll do a fake dataset assignment of 5001 items
605+
jest.spyOn(mockDataView, 'getItemCount').mockReturnValueOnce(5001);
606+
component.dataset = [{ firstName: 'John', updatedDate: '2020-02-01' }];
607+
608+
expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('[Slickgrid-Universal] For getting better perf, we suggest you enable the `preParseDateColumns` grid option'));
609+
});
610+
578611
describe('autoAddCustomEditorFormatter grid option', () => {
579612
it('should initialize the grid and automatically add custom Editor Formatter when provided in the grid options', () => {
580613
component.gridOptions = { ...gridOptions, autoAddCustomEditorFormatter: customEditableInputFormatter };
@@ -818,22 +851,19 @@ describe('Angular-Slickgrid Custom Component instantiated via Constructor', () =
818851
describe('use grouping', () => {
819852
it('should load groupItemMetaProvider to the DataView when using "draggableGrouping" feature', () => {
820853
const dataviewSpy = jest.spyOn(SlickDataView.prototype, 'constructor' as any);
821-
const sharedMetaSpy = jest.spyOn(SharedService.prototype, 'groupItemMetadataProvider', 'set');
822854
jest.spyOn(extensionServiceStub, 'extensionList', 'get').mockReturnValue({ draggableGrouping: { pluginName: 'DraggableGrouping' } } as unknown as ExtensionList<any>);
823855

824856
component.gridOptions = { draggableGrouping: {} };
825857
component.initialization(slickEventHandler);
826858

827859
expect(dataviewSpy).toHaveBeenCalledWith(expect.objectContaining({ inlineFilters: false, groupItemMetadataProvider: expect.anything() }), eventPubSubService);
828860
expect(sharedService.groupItemMetadataProvider instanceof SlickGroupItemMetadataProvider).toBeTruthy();
829-
expect(sharedMetaSpy).toHaveBeenCalledWith(expect.toBeObject());
830861

831862
component.destroy();
832863
});
833864

834865
it('should load groupItemMetaProvider to the DataView when using "enableGrouping" feature', () => {
835866
const dataviewSpy = jest.spyOn(SlickDataView.prototype, 'constructor' as any);
836-
const sharedMetaSpy = jest.spyOn(SharedService.prototype, 'groupItemMetadataProvider', 'set');
837867
jest.spyOn(extensionServiceStub, 'extensionList', 'get').mockReturnValue({ draggableGrouping: { pluginName: 'DraggableGrouping' } } as unknown as ExtensionList<any>);
838868

839869
component.gridOptions = { enableGrouping: true, draggableGrouping: {} };
@@ -843,7 +873,6 @@ describe('Angular-Slickgrid Custom Component instantiated via Constructor', () =
843873
// expect(Object.keys(extensions).length).toBe(1);
844874
expect(dataviewSpy).toHaveBeenCalledWith(expect.objectContaining({ inlineFilters: false, groupItemMetadataProvider: expect.anything() }), eventPubSubService);
845875
expect(sharedService.groupItemMetadataProvider instanceof SlickGroupItemMetadataProvider).toBeTruthy();
846-
expect(sharedMetaSpy).toHaveBeenCalledWith(expect.toBeObject());
847876
expect(mockGrid.registerPlugin).toHaveBeenCalled();
848877

849878
// component.destroy();

src/app/modules/angular-slickgrid/components/angular-slickgrid.component.ts

+17-1
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
DataViewOption,
2626
EventSubscription,
2727
ExternalResource,
28+
isColumnDateType,
2829
ItemMetadata,
2930
Locale,
3031
Metrics,
@@ -84,6 +85,8 @@ import { AngularUtilService } from '../services/angularUtil.service';
8485
import { SlickRowDetailView } from '../extensions/slickRowDetailView';
8586
import { ContainerService } from '../services/container.service';
8687

88+
const WARN_NO_PREPARSE_DATE_SIZE = 5000; // data size to warn user when pre-parse isn't enabled
89+
8790
@Component({
8891
selector: 'angular-slickgrid',
8992
templateUrl: './angular-slickgrid.component.html',
@@ -211,6 +214,7 @@ export class AngularSlickgridComponent<TData = any> implements AfterViewInit, On
211214
this.slickGrid.autosizeColumns();
212215
this._isAutosizeColsCalled = true;
213216
}
217+
this.suggestDateParsingWhenHelpful();
214218
}
215219

216220
@Input()
@@ -302,7 +306,7 @@ export class AngularSlickgridComponent<TData = any> implements AfterViewInit, On
302306
this.filterFactory = new FilterFactory(slickgridConfig, this.translaterService, this.collectionService);
303307
this.filterService = externalServices?.filterService ?? new FilterService(this.filterFactory as any, this._eventPubSubService, this.sharedService, this.backendUtilityService);
304308
this.resizerService = externalServices?.resizerService ?? new ResizerService(this._eventPubSubService);
305-
this.sortService = externalServices?.sortService ?? new SortService(this.sharedService, this._eventPubSubService, this.backendUtilityService);
309+
this.sortService = externalServices?.sortService ?? new SortService(this.collectionService, this.sharedService, this._eventPubSubService, this.backendUtilityService);
306310
this.treeDataService = externalServices?.treeDataService ?? new TreeDataService(this._eventPubSubService, this.sharedService, this.sortService);
307311
this.paginationService = externalServices?.paginationService ?? new PaginationService(this._eventPubSubService, this.sharedService, this.backendUtilityService);
308312

@@ -372,6 +376,8 @@ export class AngularSlickgridComponent<TData = any> implements AfterViewInit, On
372376
if (this.gridOptions.darkMode) {
373377
this.setDarkMode(true);
374378
}
379+
380+
this.suggestDateParsingWhenHelpful();
375381
}
376382

377383
ngOnDestroy(): void {
@@ -937,6 +943,7 @@ export class AngularSlickgridComponent<TData = any> implements AfterViewInit, On
937943
this.handleOnItemCountChanged(dataView.getFilteredItemCount() || 0, dataView.getItemCount() || 0);
938944
});
939945
this._eventHandler.subscribe(dataView.onSetItemsCalled, (_e, args) => {
946+
this.sharedService.isItemsDateParsed = false;
940947
this.handleOnItemCountChanged(dataView.getFilteredItemCount() || 0, args.itemCount);
941948

942949
// when user has resize by content enabled, we'll force a full width calculation since we change our entire dataset
@@ -1487,6 +1494,15 @@ export class AngularSlickgridComponent<TData = any> implements AfterViewInit, On
14871494
});
14881495
}
14891496

1497+
protected suggestDateParsingWhenHelpful() {
1498+
if (this.dataView?.getItemCount() > WARN_NO_PREPARSE_DATE_SIZE && !this.gridOptions.preParseDateColumns && this.slickGrid.getColumns().some(c => isColumnDateType(c.type))) {
1499+
console.warn(
1500+
'[Slickgrid-Universal] For getting better perf, we suggest you enable the `preParseDateColumns` grid option, ' +
1501+
'for more info visit:: https://ghiscoding.gitbook.io/slickgrid-universal/column-functionalities/sorting#pre-parse-date-columns-for-better-perf'
1502+
);
1503+
}
1504+
}
1505+
14901506
/**
14911507
* When the Editor(s) has a "editor.collection" property, we'll load the async collection.
14921508
* Since this is called after the async call resolves, the pointer will not be the same as the "column" argument passed.

test/cypress/e2e/example04.cy.ts

+3-3
Original file line numberDiff line numberDiff line change
@@ -56,13 +56,13 @@ describe('Example 4 - Client Side Sort/Filter Grid', () => {
5656
});
5757
});
5858

59-
it('should have some metrics shown in the grid footer well below 1500 items', () => {
59+
it('should have some metrics shown in the grid footer well below 5500 items', () => {
6060
cy.get('#slickGridContainer-grid4')
6161
.find('.slick-custom-footer')
6262
.find('.right-footer')
6363
.should($span => {
6464
const text = removeExtraSpaces($span.text()); // remove all white spaces
65-
expect(text).not.to.eq('1500 of 1500 items');
65+
expect(text).not.to.eq('5500 of 5500 items');
6666
});
6767
});
6868

@@ -171,7 +171,7 @@ describe('Example 4 - Client Side Sort/Filter Grid', () => {
171171
.find('.right-footer')
172172
.should($span => {
173173
const text = removeExtraSpaces($span.text()); // remove all white spaces
174-
expect(text).to.eq('1500 of 1500 items');
174+
expect(text).to.eq('5500 of 5500 items');
175175
});
176176
});
177177

0 commit comments

Comments
 (0)