Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 24 additions & 23 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -625,13 +625,10 @@ In order to improve that the user can set the `largeArrayMechanism` and

- `default` - This option keeps the full feature set of this lib: every element
is still coerced to the type declared in `items`, and type errors are still
thrown. Arrays whose `items` schema is a plain `{ type: 'string' }` (no
`format`, `nullable`, `const`, or combinator keyword) are serialized at or
above `largeArraySize` by copying the elements and calling the native
`JSON.stringify` on the copy, which is several times faster than appending one
element at a time. If an element is not a string, that element and the ones
after it go through the regular per-element coercion, so the output is the
same as before.
thrown. Arrays of plain primitives (`string`, `number`, `integer`) use a
copy-then-`JSON.stringify` strategy with their own length cutoffs, see
[Primitive arrays](#primitive-arrays) below; `largeArraySize` does not affect
them.
- `json-stringify` - This option will remove support for schema validation
within **large arrays** completely. By doing so the overhead previously
mentioned is nulled, greatly improving execution time. Mind there's no change
Expand All @@ -646,27 +643,31 @@ integer-like values, such as:
- `'2e4'` - _note this will be converted to `2`, not `20000`_
- `1.5` - _note this will be converted to `1`_

##### Number and integer arrays
<a name="primitive-arrays"></a>
##### Primitive arrays

With `largeArrayMechanism: 'default'`, arrays whose `items` schema is a plain
`{ type: 'number' }` or `{ type: 'integer' }` (no `nullable`, `const`, `$ref`,
or combinator keyword) use the same copy-then-`JSON.stringify` strategy, but
with their own length cutoffs that do not depend on `largeArraySize`. The
cutoffs are the array lengths above which the copy beats the per-element loop,
and they depend on the V8 version:
`{ type: 'string' }`, `{ type: 'number' }` or `{ type: 'integer' }` (no
`format`, `nullable`, `const`, or combinator keyword; a `$ref` to a plain
string schema also qualifies, a `$ref` for `number`/`integer` does not) are
serialized by copying the elements into a fresh array and calling the native
`JSON.stringify` on the copy, which is several times faster than appending one
element at a time. This happens at or above a length cutoff that does not
depend on `largeArraySize`. The cutoffs are the array lengths above which the
copy beats the per-element loop, and they depend on the V8 version:

| Node.js | `number` | `integer` |
|---|---:|---:|
| 22 and later | 128 elements | 512 elements |
| 20 | 300000 elements | 300000 elements |
| Node.js | `string` | `number` | `integer` |
|---|---:|---:|---:|
| 22 and later | 8 elements | 128 elements | 512 elements |
| 20 | 128 elements | 300000 elements | 300000 elements |

Below the cutoff the array is serialized element by element as before. Above
it, the elements are copied until one is found that is not already a `number`
(or not already an integer, for `integer`); that element and the ones after it
go through the regular per-element coercion and validation (`null` still
becomes `0`, `rounding` still applies, `NaN` still throws). The output is
byte-identical either way. Arrays of `boolean`, `null`, objects, or any other
`items` schema are not affected.
it, the elements are copied until one is found that is not already of the
declared type (not a string, not a `number`, or not an integer for `integer`);
that element and the ones after it go through the regular per-element coercion
and validation (`null` still becomes `""` or `0`, `rounding` still applies,
`NaN` still throws). The output is byte-identical either way. Arrays of
`boolean`, `null`, objects, or any other `items` schema are not affected.

In [standalone mode](#standalone) the cutoffs are baked into the generated
code, so the generated module inherits the cutoffs of the Node.js version that
Expand Down
15 changes: 12 additions & 3 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,21 @@ const NAMED_FRAGMENT_REF = /^#[a-z_][-\w._]*$/i
// Node 20: JSON.stringify only beats the loop for every element kind from
// ~3e5 (1e5 is machine-dependent), so both kinds wait for 3e5 there.
//
// Strings: the generated loop escapes each element in JS (asString), which
// is far slower than the native serializer, so the copy wins almost
// immediately. Node 22+: from 2 elements for every string length and content
// (2-6x faster from 8 elements on). Node 20: from 32 elements for short
// strings, but only from 128 for long (~120 chars) strings without escapes,
// so Node 20 waits for 128. Measured on Node 20, 22, 24 and 26 (arm64) with
// 8, 40 and 120 char strings, with and without characters to escape.
//
// Booleans never win and keep the loop. The array lookup instead of a
// ternary keeps both branches covered on every Node.
// TODO: drop the Node 20 tier (and this version check) once Node 20 is no
// longer supported.
const nodeMajor = Number.parseInt(process.versions.node, 10)
const PRIMITIVE_FAST_PATH_MIN_LENGTH = [{ integer: 3e5, number: 3e5 }, { integer: 512, number: 128 }][+(nodeMajor >= 22)]
const PRIMITIVE_FAST_PATH_MIN_LENGTH = [{ integer: 3e5, number: 3e5, string: 128 }, { integer: 512, number: 128, string: 8 }][+(nodeMajor >= 22)]
const STRING_ARRAY_FAST_PATH_MIN_LENGTH = PRIMITIVE_FAST_PATH_MIN_LENGTH.string
const INTEGER_ARRAY_FAST_PATH = { condition: 'Number.isInteger(v)', minLength: PRIMITIVE_FAST_PATH_MIN_LENGTH.integer }
const NUMBER_ARRAY_FAST_PATH = { condition: 'typeof v === \'number\' && v === v', minLength: PRIMITIVE_FAST_PATH_MIN_LENGTH.number }

Expand Down Expand Up @@ -1043,7 +1052,7 @@ function buildArray (context, location, input) {
} else if (plainStringFastPath) {
// Copy strings once; the helper resumes ordered coercion on the first miss.
functionCode += `
if (typeof arrayLength === 'number' && arrayLength >= ${context.largeArraySize} && Number.isInteger(arrayLength) && arrayLength <= 0xffffffff) {
if (typeof arrayLength === 'number' && arrayLength >= ${STRING_ARRAY_FAST_PATH_MIN_LENGTH} && Number.isInteger(arrayLength) && arrayLength <= 0xffffffff) {
return asPlainStringArray(obj, arrayLength)
}
`
Expand Down Expand Up @@ -1169,7 +1178,7 @@ function buildArray (context, location, input) {
} else if (plainStringFastPath) {
inlinedCode += `
let fjsPlain_${objVar}
if (typeof arrayLength_${objVar} === 'number' && arrayLength_${objVar} >= ${context.largeArraySize} && Number.isInteger(arrayLength_${objVar}) && arrayLength_${objVar} <= 0xffffffff) fjsPlain_${objVar} = asPlainStringArray(${objVar}, arrayLength_${objVar})
if (typeof arrayLength_${objVar} === 'number' && arrayLength_${objVar} >= ${STRING_ARRAY_FAST_PATH_MIN_LENGTH} && Number.isInteger(arrayLength_${objVar}) && arrayLength_${objVar} <= 0xffffffff) fjsPlain_${objVar} = asPlainStringArray(${objVar}, arrayLength_${objVar})
if (fjsPlain_${objVar} !== undefined) json += fjsPlain_${objVar}
else {
`
Expand Down
62 changes: 62 additions & 0 deletions test/string-array-cutoff.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
'use strict'

const { test } = require('node:test')
const build = require('..')

// Plain string arrays use their own copy-then-JSON.stringify cutoff, independent
// of largeArraySize (which only drives largeArrayMechanism: 'json-stringify').
const nodeMajor = Number.parseInt(process.versions.node, 10)
const CUTOFF = nodeMajor >= 22 ? 8 : 128

const schema = { type: 'array', items: { type: 'string' } }
const nested = { type: 'object', properties: { tags: schema } }
const strings = (n) => Array.from({ length: n }, (_, i) => 'item-' + i)

test('string array cutoff does not depend on largeArraySize', t => {
t.plan(2)
const code = build(schema, { mode: 'debug', largeArraySize: 5000 }).code
t.assert.ok(code.includes(`arrayLength >= ${CUTOFF} `), 'uses the string cutoff')
t.assert.ok(!code.includes('arrayLength >= 5000'), 'ignores largeArraySize')
})

test('string arrays at and above the cutoff are byte-identical to JSON.stringify', t => {
const stringify = build(schema)
const lengths = [CUTOFF - 1, CUTOFF, CUTOFF + 1, CUTOFF * 10]
t.plan(lengths.length)
for (const n of lengths) {
t.assert.equal(stringify(strings(n)), JSON.stringify(strings(n)))
}
})

test('nested string arrays use the same cutoff', t => {
t.plan(2)
const code = build(nested, { mode: 'debug', largeArraySize: 5000 }).code
t.assert.ok(code.includes(`>= ${CUTOFF} `))
t.assert.equal(build(nested)({ tags: strings(CUTOFF) }), JSON.stringify({ tags: strings(CUTOFF) }))
})

test('escapes and non-string elements above the cutoff keep per-element semantics', t => {
t.plan(4)
const stringify = build(schema)
const withEscapes = strings(CUTOFF).map((s, i) => i % 2 ? s + '"\n\u0000' : s)
t.assert.equal(stringify(withEscapes), JSON.stringify(withEscapes))
const mixed = strings(CUTOFF)
mixed[1] = 42
mixed[2] = null
// asString semantics: non-strings are coerced via toString, null becomes ""
t.assert.equal(stringify(mixed), JSON.stringify(mixed.map((v) => v === null ? '' : String(v))))
const dates = strings(CUTOFF)
dates[0] = new Date(0)
t.assert.equal(stringify(dates), JSON.stringify(dates.map((v) => v instanceof Date ? v.toISOString() : v)))
const regexps = strings(CUTOFF)
regexps[3] = /a"b/
regexps[5] = /c/g
t.assert.equal(stringify(regexps), JSON.stringify(regexps.map((v) => v instanceof RegExp ? v.source : v)))
})

test('largeArraySize still drives the json-stringify mechanism for strings', t => {
t.plan(2)
const code = build(schema, { mode: 'debug', largeArrayMechanism: 'json-stringify', largeArraySize: 5000 }).code
t.assert.ok(code.includes('arrayLength >= 5000'))
t.assert.ok(!code.includes('asPlainStringArray(obj'))
})
3 changes: 2 additions & 1 deletion types/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,8 @@ declare namespace build {
/**
* Large arrays are defined as arrays containing, by default, `20000`
* elements or more. That value can be adjusted via the option parameter
* `largeArraySize`.
* `largeArraySize`. Only used with `largeArrayMechanism: 'json-stringify'`;
* arrays of plain primitives have their own, runtime-dependent cutoffs.
*
* @default 20000
*/
Expand Down
Loading