Skip to content

Commit 66b79f0

Browse files
authored
feat: add support for better handling with large arrays (#402)
1 parent d6d12e5 commit 66b79f0

File tree

4 files changed

+227
-10
lines changed

4 files changed

+227
-10
lines changed

README.md

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ compile-json-stringify date format x 1,086,187 ops/sec ±0.16% (99 runs sampled)
6161
- <a href="#long">`Long integers`</a>
6262
- <a href="#integer">`Integers`</a>
6363
- <a href="#nullable">`Nullable`</a>
64+
- <a href="#largearrays">`Large Arrays`</a>
6465
- <a href="#security">`Security Notice`</a>
6566
- <a href="#acknowledgements">`Acknowledgements`</a>
6667
- <a href="#license">`License`</a>
@@ -117,6 +118,8 @@ const stringify = fastJson(mySchema, {
117118
- `schema`: external schemas references by $ref property. [More details](#ref)
118119
- `ajv`: [ajv v8 instance's settings](https://ajv.js.org/options.html) for those properties that require `ajv`. [More details](#anyof)
119120
- `rounding`: setup how the `integer` types will be rounded when not integers. [More details](#integer)
121+
- `largeArrayMechanism`: set the mechanism that should be used to handle large
122+
(by default `20000` or more items) arrays. [More details](#largearrays)
120123

121124

122125
<a name="api"></a>
@@ -582,6 +585,59 @@ Otherwise, instead of raising an error, null values will be coerced as follows:
582585
- `string` -> `""`
583586
- `boolean` -> `false`
584587

588+
<a name="largearrays"></a>
589+
#### Large Arrays
590+
591+
Large arrays are, for the scope of this document, defined as arrays containing,
592+
by default, `20000` elements or more. That value can be adjusted via the option
593+
parameter `largeArraySize`.
594+
595+
At some point the overhead caused by the default mechanism used by
596+
`fast-json-stringify` to handle arrays starts increasing exponentially, leading
597+
to slow overall executions.
598+
599+
##### Settings
600+
601+
In order to improve that the user can set the `largeArrayMechanism` and
602+
`largeArraySize` options.
603+
604+
`largeArrayMechanism`'s default value is `default`. Valid values for it are:
605+
606+
- `default` - This option is a compromise between performance and feature set by
607+
still providing the expected functionality out of this lib but giving up some
608+
possible performance gain. With this option set, **large arrays** would be
609+
stringified by joining their stringified elements using `Array.join` instead of
610+
string concatenation for better performance
611+
- `json-stringify` - This option will remove support for schema validation
612+
within **large arrays** completely. By doing so the overhead previously
613+
mentioned is nulled, greatly improving execution time. Mind there's no change
614+
in behavior for arrays not considered _large_
615+
616+
`largeArraySize`'s default value is `20000`. Valid values for it are
617+
integer-like values, such as:
618+
619+
- `20000`
620+
- `2e4`
621+
- `'20000'`
622+
- `'2e4'` - _note this will be converted to `2`, not `20000`_
623+
- `1.5` - _note this will be converted to `1`_
624+
625+
##### Benchmarks
626+
627+
For reference, here goes some benchmarks for comparison over the three
628+
mechanisms. Benchmarks conducted on an old machine.
629+
630+
- Machine: `ST1000LM024 HN-M 1TB HDD, Intel Core i7-3610QM @ 2.3GHz, 12GB RAM, 4C/8T`.
631+
- Node.js `v16.13.1`
632+
633+
```
634+
JSON.stringify large array x 157 ops/sec ±0.73% (86 runs sampled)
635+
fast-json-stringify large array default x 48.72 ops/sec ±4.92% (48 runs sampled)
636+
fast-json-stringify large array json-stringify x 157 ops/sec ±0.76% (86 runs sampled)
637+
compile-json-stringify large array x 175 ops/sec ±4.47% (79 runs sampled)
638+
AJV Serialize large array x 58.76 ops/sec ±4.59% (60 runs sampled)
639+
```
640+
585641
<a name="security"></a>
586642
## Security notice
587643

bench.js

Lines changed: 69 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@
33
const benchmark = require('benchmark')
44
const suite = new benchmark.Suite()
55

6+
const STR_LEN = 1e4
7+
const LARGE_ARRAY_SIZE = 2e4
8+
const MULTI_ARRAY_LENGHT = 1e3
9+
610
const schema = {
711
title: 'Example Schema',
812
type: 'object',
@@ -89,7 +93,8 @@ const obj = {
8993

9094
const date = new Date()
9195

92-
const multiArray = []
96+
const multiArray = new Array(MULTI_ARRAY_LENGHT)
97+
const largeArray = new Array(LARGE_ARRAY_SIZE)
9398

9499
const CJS = require('compile-json-stringify')
95100
const CJSStringify = CJS(schemaCJS)
@@ -99,7 +104,10 @@ const CJSStringifyString = CJS({ type: 'string' })
99104

100105
const FJS = require('.')
101106
const stringify = FJS(schema)
102-
const stringifyArray = FJS(arraySchema)
107+
const stringifyArrayDefault = FJS(arraySchema)
108+
const stringifyArrayJSONStringify = FJS(arraySchema, {
109+
largeArrayMechanism: 'json-stringify'
110+
})
103111
const stringifyDate = FJS(dateFormatSchema)
104112
const stringifyString = FJS({ type: 'string' })
105113
let str = ''
@@ -110,18 +118,48 @@ const ajvSerialize = ajv.compileSerializer(schemaAJVJTD)
110118
const ajvSerializeArray = ajv.compileSerializer(arraySchemaAJVJTD)
111119
const ajvSerializeString = ajv.compileSerializer({ type: 'string' })
112120

121+
const getRandomString = (length) => {
122+
if (!Number.isInteger(length)) {
123+
throw new Error('Expected integer length')
124+
}
125+
126+
const validCharacters = 'abcdefghijklmnopqrstuvwxyz'
127+
const nValidCharacters = 26
128+
129+
let result = ''
130+
for (let i = 0; i < length; ++i) {
131+
result += validCharacters[Math.floor(Math.random() * nValidCharacters)]
132+
}
133+
134+
return result[0].toUpperCase() + result.slice(1)
135+
}
136+
113137
// eslint-disable-next-line
114-
for (var i = 0; i < 10000; i++) {
138+
for (let i = 0; i < STR_LEN; i++) {
139+
largeArray[i] = {
140+
firstName: getRandomString(8),
141+
lastName: getRandomString(6),
142+
age: Math.ceil(Math.random() * 99)
143+
}
144+
115145
str += i
116146
if (i % 100 === 0) {
117147
str += '"'
118148
}
119149
}
120150

151+
for (let i = STR_LEN; i < LARGE_ARRAY_SIZE; ++i) {
152+
largeArray[i] = {
153+
firstName: getRandomString(10),
154+
lastName: getRandomString(4),
155+
age: Math.ceil(Math.random() * 99)
156+
}
157+
}
158+
121159
Number(str)
122160

123-
for (i = 0; i < 1000; i++) {
124-
multiArray.push(obj)
161+
for (let i = 0; i < MULTI_ARRAY_LENGHT; i++) {
162+
multiArray[i] = obj
125163
}
126164

127165
suite.add('FJS creation', function () {
@@ -138,8 +176,12 @@ suite.add('JSON.stringify array', function () {
138176
JSON.stringify(multiArray)
139177
})
140178

141-
suite.add('fast-json-stringify array', function () {
142-
stringifyArray(multiArray)
179+
suite.add('fast-json-stringify array default', function () {
180+
stringifyArrayDefault(multiArray)
181+
})
182+
183+
suite.add('fast-json-stringify array json-stringify', function () {
184+
stringifyArrayJSONStringify(multiArray)
143185
})
144186

145187
suite.add('compile-json-stringify array', function () {
@@ -150,6 +192,26 @@ suite.add('AJV Serialize array', function () {
150192
ajvSerializeArray(multiArray)
151193
})
152194

195+
suite.add('JSON.stringify large array', function () {
196+
JSON.stringify(largeArray)
197+
})
198+
199+
suite.add('fast-json-stringify large array default', function () {
200+
stringifyArrayDefault(largeArray)
201+
})
202+
203+
suite.add('fast-json-stringify large array json-stringify', function () {
204+
stringifyArrayJSONStringify(largeArray)
205+
})
206+
207+
suite.add('compile-json-stringify large array', function () {
208+
CJSStringifyArray(largeArray)
209+
})
210+
211+
suite.add('AJV Serialize large array', function () {
212+
ajvSerializeArray(largeArray)
213+
})
214+
153215
suite.add('JSON.stringify long string', function () {
154216
JSON.stringify(str)
155217
})

index.js

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,14 @@ const fjsCloned = Symbol('fast-json-stringify.cloned')
1111
const { randomUUID } = require('crypto')
1212

1313
const validate = require('./schema-validator')
14+
15+
let largeArraySize = 2e4
1416
let stringSimilarity = null
17+
let largeArrayMechanism = 'default'
18+
const validLargeArrayMechanisms = [
19+
'default',
20+
'json-stringify'
21+
]
1522

1623
const addComma = `
1724
if (addComma) {
@@ -73,6 +80,22 @@ function build (schema, options) {
7380
}
7481
}
7582

83+
if (options.largeArrayMechanism) {
84+
if (validLargeArrayMechanisms.includes(options.largeArrayMechanism)) {
85+
largeArrayMechanism = options.largeArrayMechanism
86+
} else {
87+
throw new Error(`Unsupported large array mechanism ${options.rounding}`)
88+
}
89+
}
90+
91+
if (options.largeArraySize) {
92+
if (!Number.isNaN(Number.parseInt(options.largeArraySize, 10))) {
93+
largeArraySize = options.largeArraySize
94+
} else {
95+
throw new Error(`Unsupported large array size. Expected integer-like, got ${options.largeArraySize}`)
96+
}
97+
}
98+
7699
/* eslint no-new-func: "off" */
77100
let code = `
78101
'use strict'
@@ -1029,6 +1052,11 @@ function buildArray (location, code, name, key = null) {
10291052

10301053
code += `
10311054
var l = obj.length
1055+
if (l && l >= ${largeArraySize}) {`
1056+
1057+
const concatSnippet = `
1058+
}
1059+
10321060
var jsonOutput= ''
10331061
for (var i = 0; i < l; i++) {
10341062
var json = ''
@@ -1040,7 +1068,25 @@ function buildArray (location, code, name, key = null) {
10401068
}
10411069
}
10421070
return \`[\${jsonOutput}]\`
1071+
}`
1072+
1073+
switch (largeArrayMechanism) {
1074+
case 'default':
1075+
code += `
1076+
return \`[\${obj.map(${result.mapFnName}).join(',')}]\``
1077+
break
1078+
1079+
case 'json-stringify':
1080+
code += `
1081+
return JSON.stringify(obj)`
1082+
break
1083+
1084+
default:
1085+
throw new Error(`Unsupported large array mechanism ${largeArrayMechanism}`)
10431086
}
1087+
1088+
code += `
1089+
${concatSnippet}
10441090
${result.laterCode}
10451091
`
10461092

@@ -1148,22 +1194,27 @@ function nested (laterCode, name, key, location, subKey, isArray) {
11481194

11491195
switch (type) {
11501196
case 'null':
1197+
funcName = '$asNull'
11511198
code += `
11521199
json += $asNull()
11531200
`
11541201
break
11551202
case 'string': {
1203+
funcName = '$asString'
11561204
const stringSerializer = getStringSerializer(schema.format)
11571205
code += nullable ? `json += obj${accessor} === null ? null : ${stringSerializer}(obj${accessor})` : `json += ${stringSerializer}(obj${accessor})`
11581206
break
11591207
}
11601208
case 'integer':
1209+
funcName = '$asInteger'
11611210
code += nullable ? `json += obj${accessor} === null ? null : $asInteger(obj${accessor})` : `json += $asInteger(obj${accessor})`
11621211
break
11631212
case 'number':
1213+
funcName = '$asNumber'
11641214
code += nullable ? `json += obj${accessor} === null ? null : $asNumber(obj${accessor})` : `json += $asNumber(obj${accessor})`
11651215
break
11661216
case 'boolean':
1217+
funcName = '$asBoolean'
11671218
code += nullable ? `json += obj${accessor} === null ? null : $asBoolean(obj${accessor})` : `json += $asBoolean(obj${accessor})`
11681219
break
11691220
case 'object':
@@ -1181,6 +1232,7 @@ function nested (laterCode, name, key, location, subKey, isArray) {
11811232
`
11821233
break
11831234
case undefined:
1235+
funcName = '$asNull'
11841236
if ('anyOf' in schema) {
11851237
// beware: dereferenceOfRefs has side effects and changes schema.anyOf
11861238
const anyOfLocations = dereferenceOfRefs(location, 'anyOf')
@@ -1319,7 +1371,8 @@ function nested (laterCode, name, key, location, subKey, isArray) {
13191371

13201372
return {
13211373
code,
1322-
laterCode
1374+
laterCode,
1375+
mapFnName: funcName
13231376
}
13241377
}
13251378

@@ -1335,6 +1388,8 @@ function isEmpty (schema) {
13351388

13361389
module.exports = build
13371390

1391+
module.exports.validLargeArrayMechanisms = validLargeArrayMechanisms
1392+
13381393
module.exports.restore = function ({ code, ajv }) {
13391394
// eslint-disable-next-line
13401395
return (Function.apply(null, ['ajv', code])

test/array.test.js

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,12 @@ const test = require('tap').test
55
const validator = require('is-my-json-valid')
66
const build = require('..')
77

8-
function buildTest (schema, toStringify) {
8+
function buildTest (schema, toStringify, options) {
99
test(`render a ${schema.title} as JSON`, (t) => {
1010
t.plan(3)
1111

1212
const validate = validator(schema)
13-
const stringify = build(schema)
13+
const stringify = build(schema, options)
1414
const output = stringify(toStringify)
1515

1616
t.same(JSON.parse(output), toStringify)
@@ -319,3 +319,47 @@ test('object array with anyOf and symbol', (t) => {
319319
])
320320
t.equal(value, '[{"name":"name-0","option":"Foo"},{"name":"name-1","option":"Bar"}]')
321321
})
322+
323+
const largeArray = new Array(2e4).fill({ a: 'test', b: 1 })
324+
buildTest({
325+
title: 'large array with default mechanism',
326+
type: 'object',
327+
properties: {
328+
ids: {
329+
type: 'array',
330+
items: {
331+
type: 'object',
332+
properties: {
333+
a: { type: 'string' },
334+
b: { type: 'number' }
335+
}
336+
}
337+
}
338+
}
339+
}, {
340+
ids: largeArray
341+
}, {
342+
largeArraySize: 2e4,
343+
largeArrayMechanism: 'default'
344+
})
345+
346+
buildTest({
347+
title: 'large array with json-stringify mechanism',
348+
type: 'object',
349+
properties: {
350+
ids: {
351+
type: 'array',
352+
items: {
353+
type: 'object',
354+
properties: {
355+
a: { type: 'string' },
356+
b: { type: 'number' }
357+
}
358+
}
359+
}
360+
}
361+
}, {
362+
ids: largeArray
363+
}, {
364+
largeArrayMechanism: 'json-stringify'
365+
})

0 commit comments

Comments
 (0)