From af8220881b2791be2cc3f6605eda3955428094c7 Mon Sep 17 00:00:00 2001 From: Ghislain B Date: Fri, 29 Dec 2023 14:59:12 -0500 Subject: [PATCH] feat: make DataView Grouping `compileAccumulatorLoop` CSP safe (#1295) * feat: make DataView Grouping `compileAccumulatorLoop` CSP safe --- docs/TOC.md | 4 + .../Autocomplete-Editor-(Kraaden-lib).md | 2 - docs/developer-guides/csp-compliance.md | 44 +++ examples/vite-demo-vanilla-bundle/index.html | 2 +- .../src/core/__tests__/slickDataView.spec.ts | 252 ++++++++++++------ packages/common/src/core/slickDataview.ts | 24 +- 6 files changed, 229 insertions(+), 99 deletions(-) create mode 100644 docs/developer-guides/csp-compliance.md diff --git a/docs/TOC.md b/docs/TOC.md index bda4c7253..add70b771 100644 --- a/docs/TOC.md +++ b/docs/TOC.md @@ -64,6 +64,10 @@ * [Pagination Schema](backend-services/graphql/GraphQL-Pagination.md) * [Sorting Schema](backend-services/graphql/GraphQL-Sorting.md) +## Developer Guides + +* [CSP Compliance](developer-guides/csp-compliance.md) + ## Migrations * [Migration Guide to 1.x](migrations/migration-to-1.x.md) diff --git a/docs/column-functionalities/editors/Autocomplete-Editor-(Kraaden-lib).md b/docs/column-functionalities/editors/Autocomplete-Editor-(Kraaden-lib).md index 16a83ec29..fa4752401 100644 --- a/docs/column-functionalities/editors/Autocomplete-Editor-(Kraaden-lib).md +++ b/docs/column-functionalities/editors/Autocomplete-Editor-(Kraaden-lib).md @@ -1,5 +1,3 @@ -##### _updated for version 2.x_ - #### Index - [Using fixed `collection` or `collectionAsync`](#using-collection-or-collectionasync) - [Editor Options (`AutocompleterOption` interface)](#editor-options-autocompleteroption-interface) diff --git a/docs/developer-guides/csp-compliance.md b/docs/developer-guides/csp-compliance.md new file mode 100644 index 000000000..adfb74c39 --- /dev/null +++ b/docs/developer-guides/csp-compliance.md @@ -0,0 +1,44 @@ +## CSP Compliance +The library is now, at least mostly, CSP (Content Security Policy) compliant since `v4.0`, however there are some exceptions to be aware of. When using any html string as template (for example with Custom Formatter returning an html string), you will not be fully compliant unless you return `TrustedHTML`. You can achieve this by using the `sanitizer` method in combo with [DOMPurify](https://github.com/cure53/DOMPurify) to return `TrustedHTML` as shown below and with that in place you should be CSP compliant. + +> **Note** the default sanitizer in Slickgrid-Universal is actually already configured to return `TrustedHTML` but the CSP safe in the DataView is opt-in via `useCSPSafeFilter` + +```typescript +import DOMPurify from 'dompurify'; +import { Slicker, SlickVanillaGridBundle } from '@slickgrid-universal/vanilla-bundle'; + +// DOM Purify is already configured in Slickgrid-Universal with the configuration shown below +this.gridOptions = { + sanitizer: (html) => DOMPurify.sanitize(html, { RETURN_TRUSTED_TYPE: true }), + // you could also optionally use the sanitizerOptions instead + // sanitizerOptions: { RETURN_TRUSTED_TYPE: true } +} +this.sgb = new Slicker.GridBundle(gridContainerElm, this.columnDefinitions, this.gridOptions, this.dataset); +``` +with this code in place, we can use the following CSP meta tag (which is what we use in the lib demo, ref: [index.html](https://github.com/ghiscoding/slickgrid-universal/blob/master/examples/vite-demo-vanilla-bundle/index.html#L8-L14)) +```html + +``` + +#### DataView +Since we use the DataView, you will also need to enable a new `useCSPSafeFilter` flag to be CSP safe as the name suggest. This option is opt-in because it has a slight performance impact when enabling this option (it shouldn't be noticeable unless you use a very large dataset). + +```typescript +import DOMPurify from 'dompurify'; +import { Slicker, SlickVanillaGridBundle } from '@slickgrid-universal/vanilla-bundle'; + +// DOM Purify is already configured in Slickgrid-Universal with the configuration shown below +this.gridOptions = { + // you could also optionally use the sanitizerOptions instead + // sanitizerOptions: { RETURN_TRUSTED_TYPE: true } + dataView: { + useCSPSafeFilter: true + }, +} +this.sgb = new Slicker.GridBundle(gridContainerElm, this.columnDefinitions, this.gridOptions, this.dataset); +``` + +### Custom Formatter using native HTML +We now also allow passing native HTML Element as a Custom Formatter instead of HTML string in order to avoid the use of `innerHTML` and stay CSP safe. We also have a new grid option named `enableHtmlRendering`, which is enabled by default and is allowing the use of `innerHTML` in the library (by Formatters and others), however when disabled it will totally restrict the use of `innerHTML` which will help to stay CSP safe. + +You can take a look at the original SlickGrid library with this new [Filtered DataView with HTML Formatter - CSP Header (Content Security Policy)](https://6pac.github.io/SlickGrid/examples/example4-model-html-formatters.html) example which uses this new approach. There was no new Example created in Slickgrid-Universal specifically for this but the approach is the same. \ No newline at end of file diff --git a/examples/vite-demo-vanilla-bundle/index.html b/examples/vite-demo-vanilla-bundle/index.html index 1617fc410..146bbd362 100644 --- a/examples/vite-demo-vanilla-bundle/index.html +++ b/examples/vite-demo-vanilla-bundle/index.html @@ -8,7 +8,7 @@ diff --git a/packages/common/src/core/__tests__/slickDataView.spec.ts b/packages/common/src/core/__tests__/slickDataView.spec.ts index 6c47bf912..6e4c5be57 100644 --- a/packages/common/src/core/__tests__/slickDataView.spec.ts +++ b/packages/common/src/core/__tests__/slickDataView.spec.ts @@ -1,9 +1,11 @@ +import { Aggregators } from '../../aggregators'; +import { Grouping } from '../../interfaces'; import { SlickDataView } from '../slickDataview'; import 'flatpickr'; describe('SlickDatView core file', () => { let container: HTMLElement; - let dataView: SlickDataView; + let dv: SlickDataView; beforeEach(() => { container = document.createElement('div'); @@ -13,13 +15,13 @@ describe('SlickDatView core file', () => { afterEach(() => { document.body.textContent = ''; - dataView.destroy(); + dv.destroy(); }); it('should be able to instantiate SlickDataView', () => { - dataView = new SlickDataView({}); + dv = new SlickDataView({}); - expect(dataView.getItems()).toEqual([]); + expect(dv.getItems()).toEqual([]); }); it('should be able to add items to the DataView', () => { @@ -27,43 +29,43 @@ describe('SlickDatView core file', () => { { id: 1, firstName: 'John', lastName: 'Doe' }, { id: 2, firstName: 'Jane', lastName: 'Doe' }, ] - dataView = new SlickDataView({}); - dataView.addItem(mockData[0]); - dataView.addItem(mockData[1]); + dv = new SlickDataView({}); + dv.addItem(mockData[0]); + dv.addItem(mockData[1]); - expect(dataView.getLength()).toBe(2); - expect(dataView.getItemCount()).toBe(2); - expect(dataView.getItems()).toEqual(mockData); + expect(dv.getLength()).toBe(2); + expect(dv.getItemCount()).toBe(2); + expect(dv.getItems()).toEqual(mockData); }); describe('batch CRUD methods', () => { afterEach(() => { - dataView.endUpdate(); // close any batch that weren't closed because of potential error thrown - dataView.destroy(); + dv.endUpdate(); // close any batch that weren't closed because of potential error thrown + dv.destroy(); }); it('should batch items with addItems and begin/end batch update', () => { const items = [{ id: 0, name: 'John', age: 20 }, { id: 1, name: 'Jane', age: 24 }]; - dataView.beginUpdate(true); - dataView.addItems(items); - dataView.endUpdate(); + dv.beginUpdate(true); + dv.addItems(items); + dv.endUpdate(); - expect(dataView.getIdPropertyName()).toBe('id'); - expect(dataView.getItems()).toEqual(items); + expect(dv.getIdPropertyName()).toBe('id'); + expect(dv.getItems()).toEqual(items); }); it('should batch more items with addItems with begin/end batch update and expect them to be inserted at the end of the dataset', () => { const items = [{ id: 0, name: 'John', age: 20 }, { id: 1, name: 'Jane', age: 24 }]; const newItems = [{ id: 3, name: 'Smith', age: 30 }, { id: 4, name: 'Ronald', age: 34 }]; - dataView.setItems(items); // original items list + dv.setItems(items); // original items list - dataView.beginUpdate(true); - dataView.addItems(newItems); // batch extra items - dataView.endUpdate(); + dv.beginUpdate(true); + dv.addItems(newItems); // batch extra items + dv.endUpdate(); - expect(dataView.getItems()).toEqual([ + expect(dv.getItems()).toEqual([ { id: 0, name: 'John', age: 20 }, { id: 1, name: 'Jane', age: 24 }, { id: 3, name: 'Smith', age: 30 }, { id: 4, name: 'Ronald', age: 34 }, ]); @@ -73,20 +75,20 @@ describe('SlickDatView core file', () => { const items = [{ id: 0, name: 'John', age: 20 }, { id: 1, name: 'Jane', age: 24 }]; const newItems = [{ id: 3, name: 'Smith', age: 30 }, { id: 4, name: 'Ronald', age: 34 }]; - dataView.setItems(items); // original items list + dv.setItems(items); // original items list - dataView.beginUpdate(true); - dataView.insertItems(0, newItems); // batch extra items - dataView.endUpdate(); + dv.beginUpdate(true); + dv.insertItems(0, newItems); // batch extra items + dv.endUpdate(); - expect(dataView.getItems()).toEqual([ + expect(dv.getItems()).toEqual([ { id: 3, name: 'Smith', age: 30 }, { id: 4, name: 'Ronald', age: 34 }, { id: 0, name: 'John', age: 20 }, { id: 1, name: 'Jane', age: 24 } ]); - dataView.deleteItem(3); + dv.deleteItem(3); - expect(dataView.getItems()).toEqual([ + expect(dv.getItems()).toEqual([ { id: 4, name: 'Ronald', age: 34 }, { id: 0, name: 'John', age: 20 }, { id: 1, name: 'Jane', age: 24 } ]); @@ -95,33 +97,33 @@ describe('SlickDatView core file', () => { it('should be able to use different "id" when using setItems()', () => { const items = [{ keyId: 0, name: 'John', age: 20 }, { keyId: 1, name: 'Jane', age: 24 }]; - dataView.beginUpdate(true); - dataView.setItems(items, 'keyId'); - dataView.endUpdate(); + dv.beginUpdate(true); + dv.setItems(items, 'keyId'); + dv.endUpdate(); - expect(dataView.getIdPropertyName()).toBe('keyId'); - expect(dataView.getItems()).toEqual(items); + expect(dv.getIdPropertyName()).toBe('keyId'); + expect(dv.getItems()).toEqual(items); }); it('should batch more items with insertItems with begin/end batch update and expect them to be inserted at a certain index dataset', () => { const items = [{ id: 0, name: 'John', age: 20 }, { id: 1, name: 'Jane', age: 24 }]; const newItems = [{ id: 3, name: 'Smith', age: 30 }, { id: 4, name: 'Ronald', age: 34 }]; - dataView.setItems(items); // original items list + dv.setItems(items); // original items list - dataView.beginUpdate(true); - dataView.insertItems(1, newItems); // batch extra items - dataView.endUpdate(); + dv.beginUpdate(true); + dv.insertItems(1, newItems); // batch extra items + dv.endUpdate(); - expect(dataView.getItems()).toEqual([ + expect(dv.getItems()).toEqual([ { id: 0, name: 'John', age: 20 }, { id: 3, name: 'Smith', age: 30 }, { id: 4, name: 'Ronald', age: 34 }, { id: 1, name: 'Jane', age: 24 } ]); - dataView.deleteItems([3, 1]); + dv.deleteItems([3, 1]); - expect(dataView.getItems()).toEqual([ + expect(dv.getItems()).toEqual([ { id: 0, name: 'John', age: 20 }, { id: 4, name: 'Ronald', age: 34 }, ]); @@ -130,37 +132,37 @@ describe('SlickDatView core file', () => { it('should throw when trying to delete items with have invalid Ids', () => { const items = [{ id: 0, name: 'John', age: 20 }, { id: 1, name: 'Jane', age: 24 }]; - dataView.setItems(items); // original items list + dv.setItems(items); // original items list - expect(() => dataView.deleteItems([-1, 1])).toThrow('[SlickGrid DataView] Invalid id'); + expect(() => dv.deleteItems([-1, 1])).toThrow('[SlickGrid DataView] Invalid id'); }); it('should throw when trying to delete items with a batch that have invalid Ids', () => { const items = [{ id: 0, name: 'John', age: 20 }, { id: 1, name: 'Jane', age: 24 }]; - dataView.setItems(items); // original items list + dv.setItems(items); // original items list - dataView.beginUpdate(true); - expect(() => dataView.deleteItems([-1, 1])).toThrow('[SlickGrid DataView] Invalid id'); + dv.beginUpdate(true); + expect(() => dv.deleteItems([-1, 1])).toThrow('[SlickGrid DataView] Invalid id'); }); it('should call updateItems, without batch, and expect a refresh to be called', () => { const items = [{ id: 0, name: 'John', age: 20 }, { id: 1, name: 'Jane', age: 24 }]; const updatedItems = [{ id: 0, name: 'Smith', age: 30 }, { id: 1, name: 'Ronald', age: 34 }]; - const refreshSpy = jest.spyOn(dataView, 'refresh'); + const refreshSpy = jest.spyOn(dv, 'refresh'); - dataView.setItems(items); // original items list + dv.setItems(items); // original items list - dataView.updateItems(updatedItems.map(item => item.id), updatedItems); + dv.updateItems(updatedItems.map(item => item.id), updatedItems); expect(refreshSpy).toHaveBeenCalled(); - expect(dataView.getItems()).toEqual([ + expect(dv.getItems()).toEqual([ { id: 0, name: 'Smith', age: 30 }, { id: 1, name: 'Ronald', age: 34 }, ]); - dataView.deleteItem(1); + dv.deleteItem(1); - expect(dataView.getItems()).toEqual([ + expect(dv.getItems()).toEqual([ { id: 0, name: 'Smith', age: 30 } ]); }); @@ -168,22 +170,22 @@ describe('SlickDatView core file', () => { it('should batch updateItems and expect a refresh to be called', () => { const items = [{ id: 0, name: 'John', age: 20 }, { id: 1, name: 'Jane', age: 24 }]; const updatedItems = [{ id: 0, name: 'Smith', age: 30 }, { id: 1, name: 'Ronald', age: 34 }]; - const refreshSpy = jest.spyOn(dataView, 'refresh'); + const refreshSpy = jest.spyOn(dv, 'refresh'); - dataView.setItems(items); // original items list + dv.setItems(items); // original items list - dataView.beginUpdate(true); - dataView.updateItems(updatedItems.map(item => item.id), updatedItems); + dv.beginUpdate(true); + dv.updateItems(updatedItems.map(item => item.id), updatedItems); expect(refreshSpy).toHaveBeenCalled(); - expect(dataView.getItems()).toEqual([ + expect(dv.getItems()).toEqual([ { id: 0, name: 'Smith', age: 30 }, { id: 1, name: 'Ronald', age: 34 }, ]); - dataView.deleteItem(1); - dataView.endUpdate(); + dv.deleteItem(1); + dv.endUpdate(); - expect(dataView.getItems()).toEqual([ + expect(dv.getItems()).toEqual([ { id: 0, name: 'Smith', age: 30 } ]); }); @@ -191,22 +193,22 @@ describe('SlickDatView core file', () => { it('should batch updateItems and expect a refresh to be called', () => { const items = [{ id: 0, name: 'John', age: 20 }, { id: 1, name: 'Jane', age: 24 }]; const updatedItems = [{ id: 0, name: 'Smith', age: 30 }, { id: 1, name: 'Ronald', age: 34 }]; - const refreshSpy = jest.spyOn(dataView, 'refresh'); + const refreshSpy = jest.spyOn(dv, 'refresh'); - dataView.setItems(items); // original items list + dv.setItems(items); // original items list - dataView.beginUpdate(true); - dataView.updateItems(updatedItems.map(item => item.id), updatedItems); + dv.beginUpdate(true); + dv.updateItems(updatedItems.map(item => item.id), updatedItems); expect(refreshSpy).toHaveBeenCalled(); - expect(dataView.getItems()).toEqual([ + expect(dv.getItems()).toEqual([ { id: 0, name: 'Smith', age: 30 }, { id: 1, name: 'Ronald', age: 34 }, ]); - dataView.deleteItem(1); - dataView.endUpdate(); + dv.deleteItem(1); + dv.endUpdate(); - expect(dataView.getItems()).toEqual([ + expect(dv.getItems()).toEqual([ { id: 0, name: 'Smith', age: 30 } ]); }); @@ -214,31 +216,31 @@ describe('SlickDatView core file', () => { it('should throw when batching updateItems with some invalid Ids', () => { const items = [{ id: 0, name: 'John', age: 20 }, { id: 1, name: 'Jane', age: 24 }]; const updatedItems = [{ id: 0, name: 'Smith', age: 30 }, { id: 1, name: 'Ronald', age: 34 }]; - const refreshSpy = jest.spyOn(dataView, 'refresh'); + const refreshSpy = jest.spyOn(dv, 'refresh'); - dataView.setItems(items); // original items list + dv.setItems(items); // original items list - dataView.beginUpdate(true); + dv.beginUpdate(true); - expect(() => dataView.updateItems([-1, 1], updatedItems)).toThrow('[SlickGrid DataView] Invalid id'); + expect(() => dv.updateItems([-1, 1], updatedItems)).toThrow('[SlickGrid DataView] Invalid id'); }); it('should throw when trying to call setItems() with duplicate Ids', () => { const items = [{ id: 0, name: 'John', age: 20 }, { id: 0, name: 'Jane', age: 24 }]; - expect(() => dataView.setItems(items)).toThrow(`[SlickGrid DataView] Each data element must implement a unique 'id' property`); + expect(() => dv.setItems(items)).toThrow(`[SlickGrid DataView] Each data element must implement a unique 'id' property`); }); it('should call insertItem() at a defined index location', () => { const items = [{ id: 0, name: 'John', age: 20 }, { id: 1, name: 'Jane', age: 24 }]; const newItem = { id: 2, name: 'Smith', age: 30 }; - const refreshSpy = jest.spyOn(dataView, 'refresh'); + const refreshSpy = jest.spyOn(dv, 'refresh'); - dataView.setItems(items); - dataView.insertItem(1, newItem); + dv.setItems(items); + dv.insertItem(1, newItem); expect(refreshSpy).toHaveBeenCalled(); - expect(dataView.getItems()).toEqual([ + expect(dv.getItems()).toEqual([ { id: 0, name: 'John', age: 20 }, { id: 2, name: 'Smith', age: 30 }, { id: 1, name: 'Jane', age: 24 } @@ -249,8 +251,8 @@ describe('SlickDatView core file', () => { const items = [{ id: 0, name: 'John', age: 20 }, { id: 1, name: 'Jane', age: 24 }]; const newItem = { id: undefined, name: 'Smith', age: 30 }; - dataView.setItems(items); - expect(() => dataView.insertItem(1, newItem)).toThrow(`[SlickGrid DataView] Each data element must implement a unique 'id' property`); + dv.setItems(items); + expect(() => dv.insertItem(1, newItem)).toThrow(`[SlickGrid DataView] Each data element must implement a unique 'id' property`); }); it('should throw when trying to call insertItem() with undefined Id', () => { @@ -259,9 +261,95 @@ describe('SlickDatView core file', () => { { id: 1, name: 'Jane', age: 24 }, { id: undefined, name: 'Smith', age: 30 }]; - dataView.beginUpdate(true); - dataView.setItems(items); - expect(() => dataView.endUpdate()).toThrow(`[SlickGrid DataView] Each data element must implement a unique 'id' property`); + dv.beginUpdate(true); + dv.setItems(items); + expect(() => dv.endUpdate()).toThrow(`[SlickGrid DataView] Each data element must implement a unique 'id' property`); + }); + }); + + describe('Grouping', () => { + it('should call setGrouping() and expect grouping to be defined without any accumulator neither totals when Aggregators are omitted', () => { + const mockData = [ + { id: 1, firstName: 'John', lastName: 'Doe' }, + { id: 2, firstName: 'Jane', lastName: 'Doe' }, + ] + dv = new SlickDataView({}); + const refreshSpy = jest.spyOn(dv, 'refresh'); + dv.setItems(mockData); + + const agg = new Aggregators.Sum('lastName'); + dv.setGrouping({ + getter: 'lastName', + formatter: (g) => `Family: ${g.value} (${g.count} items)`, + } as Grouping); + + expect(refreshSpy).toHaveBeenCalled(); + expect(dv.getGrouping().length).toBe(1); + expect(dv.getGrouping()[0]).toMatchObject({ aggregators: [], getter: 'lastName' }); + + expect(dv.getItem(0)).toEqual({ + __group: true, + __nonDataRow: true, + collapsed: 0, + count: 2, + groupingKey: 'Doe', + groups: null, + level: 0, + rows: mockData, + selectChecked: false, + title: 'Family: Doe (2 items)', + totals: null, + value: 'Doe' + }); + expect(dv.getItem(1)).toEqual(mockData[0]); + expect(dv.getItem(2)).toEqual(mockData[1]); + expect(dv.getItem(3)).toBeUndefined(); // without Totals + }); + + it('should call setGrouping() and expect grouping to be defined with compiled accumulator and totals when providing Aggregators', () => { + const mockData = [ + { id: 1, firstName: 'John', lastName: 'Doe' }, + { id: 2, firstName: 'Jane', lastName: 'Doe' }, + ] + dv = new SlickDataView({}); + const refreshSpy = jest.spyOn(dv, 'refresh'); + dv.setItems(mockData); + + const agg = new Aggregators.Sum('lastName'); + dv.setGrouping({ + getter: 'lastName', + formatter: (g) => `Family: ${g.value} (${g.count} items)`, + aggregators: [agg], + aggregateCollapsed: false, + } as Grouping); + + expect(refreshSpy).toHaveBeenCalled(); + expect(dv.getGrouping().length).toBe(1); + expect(dv.getGrouping()[0]).toMatchObject({ aggregators: [agg], getter: 'lastName' }); + + expect(dv.getItem(0)).toEqual({ + __group: true, + __nonDataRow: true, + collapsed: 0, + count: 2, + groupingKey: 'Doe', + groups: null, + level: 0, + rows: mockData, + selectChecked: false, + title: 'Family: Doe (2 items)', + totals: expect.anything(), + value: 'Doe' + }); + expect(dv.getItem(1)).toEqual(mockData[0]); + expect(dv.getItem(2)).toEqual(mockData[1]); + expect(dv.getItem(3)).toEqual({ + __groupTotals: true, + __nonDataRow: true, + group: expect.anything(), + initialized: true, + sum: { lastName: 0 } + }); }); }); }); \ No newline at end of file diff --git a/packages/common/src/core/slickDataview.ts b/packages/common/src/core/slickDataview.ts index c35a859ec..03cefae19 100644 --- a/packages/common/src/core/slickDataview.ts +++ b/packages/common/src/core/slickDataview.ts @@ -400,7 +400,7 @@ export class SlickDataView implements CustomD gi.compiledAccumulators = []; let idx = gi.aggregators.length; while (idx--) { - gi.compiledAccumulators[idx] = this.compileAccumulatorLoop(gi.aggregators[idx]); + gi.compiledAccumulators[idx] = this.compileAccumulatorLoopCSPSafe(gi.aggregators[idx]); } this.toggledGroupsByLevel[i] = {}; @@ -1015,20 +1015,16 @@ export class SlickDataView implements CustomD }; } - protected compileAccumulatorLoop(aggregator: Aggregator) { + protected compileAccumulatorLoopCSPSafe(aggregator: Aggregator) { if (aggregator.accumulate) { - const accumulatorInfo = this.getFunctionInfo(aggregator.accumulate); - const fn: any = new Function( - '_items', - 'for (var ' + accumulatorInfo.params[0] + ', _i=0, _il=_items.length; _i<_il; _i++) {' + - accumulatorInfo.params[0] + ' = _items[_i]; ' + - accumulatorInfo.body + - '}' - ); - const fnName = 'compiledAccumulatorLoop'; - fn.displayName = fnName; - fn.name = this.setFunctionName(fn, fnName); - return fn; + return function (items: any[]) { + let result; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + result = aggregator.accumulate!.call(aggregator, item); + } + return result; + }; } else { return function noAccumulator() { }; }