diff --git a/README.md b/README.md
index dd042d73..d3a7fc66 100644
--- a/README.md
+++ b/README.md
@@ -623,11 +623,15 @@ In order to improve that the user can set the `largeArrayMechanism` and
`largeArrayMechanism`'s default value is `default`. Valid values for it are:
-- `default` - This option is a compromise between performance and feature set by
-still providing the expected functionality out of this lib but giving up some
-possible performance gain. With this option set, **large arrays** would be
-stringified by joining their stringified elements using `Array.join` instead of
-string concatenation for better performance
+- `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.
- `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
@@ -642,6 +646,32 @@ 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
+
+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:
+
+| Node.js | `number` | `integer` |
+|---|---:|---:|
+| 22 and later | 128 elements | 512 elements |
+| 20 | 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.
+
+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
+ran `build()`, not of the one that runs the module.
+
#### Unsafe string
By default, the library escapes all strings. With the 'unsafe' format, the string isn't escaped. This has a potentially dangerous security issue. You can use it only if you are sure that your data doesn't need escaping. The advantage is a significant performance improvement.
@@ -748,6 +778,12 @@ may require Ajv runtime helpers used by the generated validation code. Custom
Ajv formats used by these schemas must support Ajv's standalone code
generation.
+The generated code also bakes in the length cutoffs used for
+[number and integer arrays](#largearrays). Those cutoffs are chosen for the
+Node.js version that ran `build()`; a module built on Node.js 20 and run on
+Node.js 22 or later keeps the Node.js 20 cutoffs, and vice versa. The output is
+the same either way, only the speed differs.
+
## Acknowledgments
diff --git a/benchmark/bench-fixtures.js b/benchmark/bench-fixtures.js
new file mode 100644
index 00000000..4f3cb0e2
--- /dev/null
+++ b/benchmark/bench-fixtures.js
@@ -0,0 +1,54 @@
+'use strict'
+
+const arrayKinds = ['integer', 'number', 'short-string', 'long-string', 'flat-object', 'nested-object', 'nullable-number', 'Float64Array', 'Int32Array']
+const arraySizes = [1e3, 2e4, 1e5, 1e6]
+const mechanisms = ['default', 'json-stringify', 'native']
+const flatSchema = { type: 'object', properties: { s: { type: 'string' }, n: { type: 'number' }, b: { type: 'boolean' } } }
+const longString = 'a"quoted" string\\with\ncontrols '.repeat(4)
+
+function arrayFixture (kind, size) {
+ let items
+ let value
+ switch (kind) {
+ case 'integer':
+ case 'Int32Array':
+ items = { type: 'integer' }
+ value = i => (i % 2001) - 1000
+ break
+ case 'number':
+ case 'Float64Array':
+ items = { type: 'number' }
+ value = i => ((i % 2001) - 1000) / 10
+ break
+ case 'short-string':
+ items = { type: 'string' }
+ value = i => `hello ${i % 100}`
+ break
+ case 'long-string':
+ items = { type: 'string' }
+ value = () => longString
+ break
+ case 'flat-object':
+ items = flatSchema
+ value = i => ({ s: `hello ${i % 100}`, n: i % 1000, b: i % 2 === 0 })
+ break
+ case 'nested-object':
+ items = { type: 'object', properties: { id: { type: 'integer' }, inner: { type: 'array', items: flatSchema } } }
+ value = i => ({ id: i, inner: [{ s: 'hello', n: i % 1000, b: true }, { s: 'world', n: i / 10, b: false }] })
+ break
+ case 'nullable-number':
+ items = { type: 'number', nullable: true }
+ value = i => i % 4 === 0 ? null : i / 10
+ break
+ default:
+ throw new Error(`Unknown array kind: ${kind}`)
+ }
+ const input = kind === 'Float64Array'
+ ? Float64Array.from({ length: size }, (_, i) => value(i))
+ : kind === 'Int32Array'
+ ? Int32Array.from({ length: size }, (_, i) => value(i))
+ : Array.from({ length: size }, (_, i) => value(i))
+ return { schema: { type: 'array', items }, input, typed: ArrayBuffer.isView(input) }
+}
+
+module.exports = { arrayKinds, arraySizes, mechanisms, arrayFixture }
diff --git a/benchmark/bench-large-array.js b/benchmark/bench-large-array.js
new file mode 100644
index 00000000..cbdb0cd4
--- /dev/null
+++ b/benchmark/bench-large-array.js
@@ -0,0 +1,64 @@
+'use strict'
+
+const assert = require('node:assert/strict')
+const { parseArgs } = require('node:util')
+const { isMainThread, parentPort, workerData } = require('node:worker_threads')
+const { Bench } = require('tinybench')
+const build = require('..')
+const { arrayKinds, arraySizes, mechanisms, arrayFixture } = require('./bench-fixtures')
+const { positiveNumber, runWorker, saveResults } = require('./bench-utils')
+
+async function measure ({ kind, size, mechanism, time, iterations }) {
+ const { schema, input, typed } = arrayFixture(kind, size)
+ const stringify = mechanism === 'native' ? JSON.stringify : build(schema, { largeArrayMechanism: mechanism })
+ if (typed && mechanism !== 'native') {
+ // Rejection is existing behavior; do not time exceptions as serialization.
+ assert.throws(() => stringify(input), TypeError)
+ return { kind, size, mechanism, status: 'unsupported (TypeError)' }
+ }
+ assert.equal(stringify(input), JSON.stringify(input))
+ const bytes = Buffer.byteLength(stringify(input))
+ const bench = new Bench({ time, iterations, warmupTime: 100, warmupIterations: 2, throws: true })
+ bench.add(kind, () => {
+ Buffer.byteLength(stringify(input))
+ })
+ await bench.run()
+ const { latency, throughput } = bench.tasks[0].result
+ return {
+ kind,
+ size,
+ mechanism,
+ status: 'ok',
+ bytes,
+ ops: throughput.p50,
+ meanOps: throughput.mean,
+ medianMs: latency.p50,
+ rme: latency.rme,
+ samples: latency.samplesCount
+ }
+}
+
+async function main () {
+ const { values } = parseArgs({ options: { kind: { type: 'string' }, size: { type: 'string' }, time: { type: 'string', default: '300' }, iterations: { type: 'string', default: '8' }, json: { type: 'string' } } })
+ const kinds = values.kind ? [values.kind] : arrayKinds
+ if (kinds.some(kind => !arrayKinds.includes(kind))) throw new Error(`kind must be one of ${arrayKinds.join(', ')}`)
+ const sizes = values.size ? [positiveNumber(values.size, 'size', true)] : arraySizes
+ const config = { time: positiveNumber(values.time, 'time'), iterations: positiveNumber(values.iterations, 'iterations', true) }
+ const rows = []
+ console.log('| Kind | Size | Mechanism | Median ops/sec | Latency RME % | Samples |')
+ console.log('| --- | ---: | --- | ---: | ---: | ---: |')
+ for (const size of sizes) {
+ for (const kind of kinds) {
+ for (const mechanism of mechanisms) {
+ const row = await runWorker(__filename, { kind, size, mechanism, ...config })
+ rows.push(row)
+ console.log(`| ${kind} | ${size} | ${mechanism} | ${row.ops?.toFixed(2) ?? row.status} | ${row.rme?.toFixed(2) ?? '—'} | ${row.samples ?? '—'} |`)
+ }
+ }
+ }
+ console.log('\nTyped arrays are rejected by FJS; native serializes indexed objects. Each measured call includes Buffer.byteLength().')
+ saveResults(values.json, { ...config, kinds, sizes }, rows)
+}
+
+if (isMainThread) main().catch(err => { console.error(err); process.exitCode = 1 })
+else measure(workerData).then(result => parentPort.postMessage(result)).catch(err => { throw err })
diff --git a/benchmark/bench-utils.js b/benchmark/bench-utils.js
new file mode 100644
index 00000000..3d1f4eb4
--- /dev/null
+++ b/benchmark/bench-utils.js
@@ -0,0 +1,41 @@
+'use strict'
+
+const { Worker } = require('node:worker_threads')
+const { writeFileSync } = require('node:fs')
+const os = require('node:os')
+
+function positiveNumber (value, name, integer = false) {
+ const result = Number(value)
+ if (!Number.isFinite(result) || result <= 0 || (integer && !Number.isInteger(result))) {
+ throw new Error(`${name} must be a positive ${integer ? 'integer' : 'number'}`)
+ }
+ return result
+}
+
+function runWorker (file, workerData) {
+ return new Promise((resolve, reject) => {
+ const worker = new Worker(file, { workerData })
+ let result
+ worker.on('message', value => { result = value })
+ worker.on('error', reject)
+ worker.on('exit', code => {
+ if (code !== 0 || result === undefined) reject(new Error(`Benchmark worker exited ${code} without a valid result`))
+ else resolve(result)
+ })
+ })
+}
+
+function saveResults (file, config, rows) {
+ if (!file) return
+ writeFileSync(file, JSON.stringify({
+ timestamp: new Date().toISOString(),
+ node: process.version,
+ platform: `${process.platform}/${process.arch}`,
+ cpu: os.cpus()[0].model,
+ cpus: os.cpus().length,
+ config,
+ rows
+ }, null, 2) + '\n')
+}
+
+module.exports = { positiveNumber, runWorker, saveResults }
diff --git a/index.js b/index.js
index 07bf545d..ebaf9d6b 100644
--- a/index.js
+++ b/index.js
@@ -13,6 +13,29 @@ const mergeSchemas = require('./lib/merge-schemas')
const DEFAULT_MAX_DEPTH = 100
const NAMED_FRAGMENT_REF = /^#[a-z_][-\w._]*$/i
+// Minimum array lengths at which copying into a null-prototype array and
+// calling native JSON.stringify beats the generated per-item loop. Measured
+// against main with tinybench (alternating rounds, median ops/s, output
+// consumed with Buffer.byteLength) on Node 20, 22, 24 and 26 x64.
+//
+// Node 22+: floats win from 32 elements, but integer-valued elements under a
+// `number` schema only break even around 100 on Node 26 (-25% at 32), and a
+// `number` schema cannot tell the two apart at build time, so numbers wait
+// for 128. Integers win from 512 because small-int ToString is cached, so
+// the loop is already cheap below that.
+//
+// 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.
+//
+// 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 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 }
+
const singleSchemaKeywords = [
'additionalItems',
'additionalProperties',
@@ -44,7 +67,8 @@ const {
asDateTime,
asDate,
asTime,
- asUnsafeString
+ asUnsafeString,
+ asPlainStringArray
} = serializer
const asInteger = serializer.asInteger.bind(serializer)
@@ -81,6 +105,7 @@ function schemaDepthError (maxDepth, name) {
function assertObjectDepth (schema, maxDepth, name) {
const stack = [{ value: schema, depth: 0, exit: false }]
const path = new WeakSet()
+ let hasRef = false
while (stack.length > 0) {
const entry = stack.pop()
@@ -107,9 +132,19 @@ function assertObjectDepth (schema, maxDepth, name) {
stack.push({ value, depth: entry.depth, exit: true })
for (const key in value) {
- stack.push({ value: value[key], depth: entry.depth + 1, exit: false })
+ const child = value[key]
+ if (key === '$ref' && typeof child === 'string') {
+ hasRef = true
+ }
+ // Primitive values cannot contain cycles or contribute object depth.
+ // Avoid allocating stack entries that the next iteration would discard.
+ if (typeof child === 'object' && child !== null) {
+ stack.push({ value: child, depth: entry.depth + 1, exit: false })
+ }
}
}
+
+ return hasRef
}
function assertSchemaDepth (schema, maxDepth, name) {
@@ -324,7 +359,9 @@ function build (schema, options) {
options = options || {}
const maxDepth = getMaxDepth(options)
- assertObjectDepth(schema, maxDepth)
+ // assertObjectDepth also reports whether any $ref appears; recursive-schema
+ // detection is only needed when $ref can form cycles.
+ let hasRef = assertObjectDepth(schema, maxDepth)
assertSchemaDepth(schema, maxDepth)
isValidSchema(schema)
@@ -357,7 +394,9 @@ function build (schema, options) {
const schema = options.schema[key]
const schemaId = getSchemaId(schema, key)
if (!context.refResolver.hasSchema(schemaId)) {
- assertObjectDepth(schema, maxDepth, key)
+ if (assertObjectDepth(schema, maxDepth, key)) {
+ hasRef = true
+ }
assertSchemaDepth(schema, maxDepth, key)
isValidSchema(schema, key)
context.refResolver.addSchema(schema, key)
@@ -395,7 +434,11 @@ function build (schema, options) {
}
const location = new Location(schema, context.rootSchemaId)
- detectRecursiveSchemas(context, location)
+ // Without $ref, json-pointer paths are unique in a tree walk, so no cycle
+ // can appear in recursivePaths. Skip the third full-schema traversal.
+ if (hasRef) {
+ detectRecursiveSchemas(context, location)
+ }
const code = buildValue(context, location, 'input')
let contextFunctionCode = `
@@ -437,23 +480,35 @@ function build (schema, options) {
}
const serializer = new Serializer(options)
- const validator = new Validator(
- options.ajv,
- options.mode === 'standalone' && options.inlineValidators
- )
-
- for (const schemaId of context.validatorSchemasIds) {
- const schema = context.refResolver.getSchema(schemaId)
- validator.addSchema(schema, schemaId)
+ // Ajv construction dominates tiny build() cost. Only create a Validator when
+ // generated code needs validator.validate (anyOf/oneOf/if-then-else), or when
+ // debug mode exposes the instance. Standalone already emits `validator = null`
+ // when validatorSchemasIds is empty.
+ const needsValidator =
+ context.validatorSchemasIds.size > 0 ||
+ options.mode === 'debug'
+
+ const validator = needsValidator
+ ? new Validator(
+ options.ajv,
+ options.mode === 'standalone' && options.inlineValidators
+ )
+ : null
- const dependencies = getSchemaDependencies(context.refResolver, schemaId)
- for (const [schemaId, schema] of Object.entries(dependencies)) {
+ if (validator !== null) {
+ for (const schemaId of context.validatorSchemasIds) {
+ const schema = context.refResolver.getSchema(schemaId)
validator.addSchema(schema, schemaId)
+
+ const dependencies = getSchemaDependencies(context.refResolver, schemaId)
+ for (const [schemaId, schema] of Object.entries(dependencies)) {
+ validator.addSchema(schema, schemaId)
+ }
}
- }
- if (options.compileValidators) {
- validator.compileSchemas(context.validatorSchemaRefs)
+ if (options.compileValidators) {
+ validator.compileSchemas(context.validatorSchemaRefs)
+ }
}
if (options.mode === 'debug') {
@@ -592,7 +647,7 @@ function buildExtraObjectPropertiesSerializer (context, location, addComma, objV
return code
}
-function buildInnerObject (context, location, objVar) {
+function buildInnerObject (context, location, objVar, isReturnForm) {
const schema = location.schema
const propertiesLocation = location.getPropertyLocation('properties')
@@ -616,17 +671,16 @@ function buildInnerObject (context, location, objVar) {
}
}
- code += 'json += JSON_STR_BEGIN_OBJECT\n'
-
const localUid = context.uid++
let addComma = ''
+ let emptyObjectGuard = null
// propertiesKeys is sorted required-first; the guard checks [0] is required because
// otherwise additionalProperties/patternProperties would emit a stray `{ ,"k":v }`.
if (propertiesKeys.length > 0 && requiredProperties.includes(propertiesKeys[0])) {
- // The first property is required, so we don't need a comma.
- // For the subsequent properties, we can blindly add a comma.
-
+ // The first property is required, so it always emits (or throws): fold the
+ // object-open brace into its key prefix. Subsequent properties fold the
+ // comma into their key prefix the same way, cutting one append per property.
for (let i = 0; i < propertiesKeys.length; i++) {
const key = propertiesKeys[i]
const propertyLocation = propertiesLocation.getPropertyLocation(key)
@@ -640,23 +694,18 @@ function buildInnerObject (context, location, objVar) {
const value = `value_${key.replace(/[^a-zA-Z0-9]/g, '_')}_${context.uid++}`
const defaultValue = resolvedLocation.schema.default
const isRequired = requiredProperties.includes(key)
-
- // i === 0 means it's the first property, and it IS required (due to sort). No comma.
- // i > 0 means it follows a required property (or a sequence starting with one). Unconditional comma.
- const currentAddComma = i === 0 ? '' : 'json += JSON_STR_COMMA'
+ const keyPrefix = (i === 0 ? '{' : ',') + sanitizedKey + ':'
code += `
const ${value} = ${objVar}[${sanitizedKey}]
if (${value} !== undefined) {
- ${currentAddComma}
- json += ${JSON.stringify(sanitizedKey + ':')}
+ json += ${JSON.stringify(keyPrefix)}
${buildValue(context, resolvedLocation, `${value}`)}
}`
if (defaultValue !== undefined) {
code += ` else {
- ${currentAddComma}
- json += ${JSON.stringify(sanitizedKey + ':' + JSON.stringify(defaultValue))}
+ json += ${JSON.stringify(keyPrefix + JSON.stringify(defaultValue))}
}
`
} else if (isRequired) {
@@ -671,11 +720,16 @@ function buildInnerObject (context, location, objVar) {
addComma = 'json += JSON_STR_COMMA'
} else {
+ // No property is guaranteed to emit: fold `{` into whichever property
+ // emits first, and only emit a bare `{` when none does.
const needsRuntimeComma = propertiesKeys.length > 1 || schema.patternProperties || (schema.additionalProperties !== undefined && schema.additionalProperties !== false)
if (needsRuntimeComma) {
code += `let addComma_${localUid} = false\n`
- addComma = `!addComma_${localUid} && (addComma_${localUid} = true) || (json += JSON_STR_COMMA)`
+ addComma = `json += addComma_${localUid} ? JSON_STR_COMMA : ((addComma_${localUid} = true), JSON_STR_BEGIN_OBJECT)`
+ emptyObjectGuard = `if (!addComma_${localUid}) json += JSON_STR_BEGIN_OBJECT\n`
+ } else if (propertiesKeys.length === 0) {
+ code += 'json += JSON_STR_BEGIN_OBJECT\n'
}
for (const key of propertiesKeys) {
@@ -688,12 +742,10 @@ function buildInnerObject (context, location, objVar) {
const value = `value_${key.replace(/[^a-zA-Z0-9]/g, '_')}_${context.uid++}`
const defaultValue = propertyLocation.schema.default
const isRequired = requiredProperties.includes(key) // Should be false here but good to keep
- // Select a complete prefix so the comma does not need a separate concatenation.
- const propertyPrefix = sanitizedKey + ':'
+ const keyColon = sanitizedKey + ':'
const addProperty = needsRuntimeComma
- ? `json += addComma_${localUid} ? ${JSON.stringify(',' + propertyPrefix)} : ${JSON.stringify(propertyPrefix)}
- addComma_${localUid} = true`
- : `json += ${JSON.stringify(propertyPrefix)}`
+ ? `json += addComma_${localUid} ? ${JSON.stringify(',' + keyColon)} : ((addComma_${localUid} = true), ${JSON.stringify('{' + keyColon)})`
+ : `json += ${JSON.stringify('{' + keyColon)}`
code += `
const ${value} = ${objVar}[${sanitizedKey}]
@@ -703,17 +755,34 @@ function buildInnerObject (context, location, objVar) {
}`
if (defaultValue !== undefined) {
- code += ` else {
+ const defaultValueLiteral = JSON.stringify(sanitizedKey + ':' + JSON.stringify(defaultValue))
+ if (needsRuntimeComma) {
+ code += ` else {
${addComma}
- json += ${JSON.stringify(sanitizedKey + ':' + JSON.stringify(defaultValue))}
+ json += ${defaultValueLiteral}
+ }
+ `
+ } else {
+ // Single optional property with default: `{` is folded into the
+ // default literal so the absent branch still opens the object.
+ code += ` else {
+ json += ${JSON.stringify('{' + sanitizedKey + ':' + JSON.stringify(defaultValue))}
}
`
+ }
} else if (isRequired) {
// Should not happen if requiredProperties.length === 0 but safety
code += ` else {
throw new Error('${sanitizedKey.replace(/'/g, '\\\'')} is required!')
}
`
+ } else if (!needsRuntimeComma) {
+ // Single optional property: `{` is folded into its key, so emit `{`
+ // when the property is absent.
+ code += ` else {
+ json += JSON_STR_BEGIN_OBJECT
+ }
+ `
} else {
code += '\n'
}
@@ -724,9 +793,15 @@ function buildInnerObject (context, location, objVar) {
code += buildExtraObjectPropertiesSerializer(context, location, addComma, objVar)
}
- code += `
- json += JSON_STR_END_OBJECT
- `
+ if (emptyObjectGuard !== null) {
+ code += emptyObjectGuard
+ }
+
+ // Named object serializers can return `json + '}'` directly, saving one
+ // append + intermediate assignment in the hot per-item path.
+ code += isReturnForm
+ ? '\nreturn json + JSON_STR_END_OBJECT\n'
+ : '\njson += JSON_STR_END_OBJECT\n'
return code
}
@@ -820,8 +895,7 @@ function buildObject (context, location, input) {
if (obj === null) return ${nullable ? 'JSON_STR_NULL' : 'JSON_STR_EMPTY_OBJECT'}
let json = ''
- ${buildInnerObject(context, location, 'obj')}
- return json
+ ${buildInnerObject(context, location, 'obj', true)}
}
`
@@ -843,6 +917,73 @@ function buildObject (context, location, input) {
return code
}
+// Per-element condition under which a native JSON.stringify(array) is
+// byte-identical to the generated per-item serializer for primitive item
+// schemas, plus the minimum array length at which copying beats the existing
+// loop. Returns null when the item schema uses any keyword that changes
+// serialization dispatch, or when the kind never wins (boolean: 'true'/'false'
+// concat is already cheap; the copy loses 25-44% at every size on Node 24).
+function getPrimitiveArrayFastPathCondition (itemsSchema) {
+ if (
+ Array.isArray(itemsSchema) ||
+ itemsSchema.$ref !== undefined ||
+ typeof itemsSchema.type !== 'string' ||
+ itemsSchema.const !== undefined ||
+ itemsSchema.nullable === true ||
+ itemsSchema.allOf !== undefined ||
+ itemsSchema.anyOf !== undefined ||
+ itemsSchema.oneOf !== undefined ||
+ itemsSchema.if !== undefined
+ ) {
+ return null
+ }
+ switch (itemsSchema.type) {
+ case 'number':
+ // asNumber(): NaN throws, Infinity serializes as null, every other
+ // number renders via ToString, exactly like JSON.stringify. The cutoff
+ // is in NUMBER_ARRAY_FAST_PATH.
+ return NUMBER_ARRAY_FAST_PATH
+ case 'integer':
+ // asInteger(): only integer numbers skip the (rounding-dependent)
+ // parseInteger path; those render via ToString like JSON.stringify.
+ // Small-int ToString is cached, so the loop stays cheap for longer;
+ // the cutoff is in INTEGER_ARRAY_FAST_PATH.
+ return INTEGER_ARRAY_FAST_PATH
+ default:
+ return null
+ }
+}
+
+// Item schema { type: 'string' } with no format, nullable, const, or combinator.
+// Other keywords (enum, pattern, minLength) are not applied while serializing.
+function isPlainStringItemsSchema (schema) {
+ if (schema.type !== 'string') {
+ return false
+ }
+ if (schema.format !== undefined) {
+ return false
+ }
+ if (schema.nullable === true) {
+ return false
+ }
+ if (schema.const !== undefined) {
+ return false
+ }
+ if (schema.allOf !== undefined) {
+ return false
+ }
+ if (schema.anyOf !== undefined) {
+ return false
+ }
+ if (schema.oneOf !== undefined) {
+ return false
+ }
+ if (schema.if !== undefined) {
+ return false
+ }
+ return true
+}
+
function buildArray (context, location, input) {
const schema = location.schema
@@ -854,6 +995,9 @@ function buildArray (context, location, input) {
}
const itemsSchema = itemsLocation.schema
+ const plainStringFastPath = context.largeArrayMechanism !== 'json-stringify' &&
+ !Array.isArray(itemsSchema) &&
+ isPlainStringItemsSchema(itemsSchema)
if (context.functionsNamesBySchema.has(schema)) {
const funcName = context.functionsNamesBySchema.get(schema)
@@ -896,6 +1040,44 @@ function buildArray (context, location, input) {
if (context.largeArrayMechanism === 'json-stringify') {
functionCode += `if (arrayLength >= ${context.largeArraySize}) return JSON.stringify(obj)\n`
+ } 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) {
+ return asPlainStringArray(obj, arrayLength)
+ }
+ `
+ } else {
+ const fastPath = getPrimitiveArrayFastPathCondition(itemsSchema)
+ if (fastPath) {
+ const valueCode = buildValue(context, itemsLocation, 'v')
+ functionCode += `
+ // Copying small arrays costs more than the existing loop.
+ if (typeof arrayLength === 'number' && arrayLength >= ${fastPath.minLength} && Number.isInteger(arrayLength) && arrayLength <= 0xffffffff) {
+ const copy = new Array(arrayLength)
+ Object.setPrototypeOf(copy, null)
+ for (let i = 0; i < arrayLength; i++) {
+ const v = obj[i]
+ if (!(${fastPath.condition})) {
+ // Serialize the captured prefix, then resume at the first miss.
+ // Never reread getters or read ahead of an item's coercion.
+ copy.length = i
+ json = JSON.stringify(copy).slice(0, -1)
+ if (i > 0) json += JSON_STR_COMMA
+ ${valueCode}
+ for (i++; i < arrayLength; i++) {
+ json += JSON_STR_COMMA
+ const v = obj[i]
+ ${valueCode}
+ }
+ return json + JSON_STR_END_ARRAY
+ }
+ copy[i] = v
+ }
+ return JSON.stringify(copy)
+ }
+ `
+ }
}
functionCode += `
@@ -981,6 +1163,13 @@ function buildArray (context, location, input) {
if (context.largeArrayMechanism === 'json-stringify') {
inlinedCode += `if (arrayLength_${objVar} >= ${context.largeArraySize}) json += JSON.stringify(${objVar})\n else {`
+ } 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 (fjsPlain_${objVar} !== undefined) json += fjsPlain_${objVar}
+ else {
+ `
}
inlinedCode += `
@@ -1037,7 +1226,7 @@ function buildArray (context, location, input) {
json += JSON_STR_END_ARRAY
`
- if (context.largeArrayMechanism === 'json-stringify') {
+ if (context.largeArrayMechanism === 'json-stringify' || plainStringFastPath) {
inlinedCode += '}'
}
@@ -1236,8 +1425,8 @@ function detectRecursiveSchemas (context, location) {
throw schemaDepthError(context.maxDepth)
}
- const schemaId = location.schemaId || ''
- const jsonPointer = location.jsonPointer || ''
+ const schemaId = location.schemaId
+ const jsonPointer = location.jsonPointer
const fullPath = `${schemaId}#${jsonPointer}`
if (pathStack.has(fullPath)) {
diff --git a/lib/location.js b/lib/location.js
index b7d81220..f29977c6 100644
--- a/lib/location.js
+++ b/lib/location.js
@@ -9,7 +9,8 @@ class Location {
getPropertyLocation (propertyName) {
// a JSON pointer segment, so a property name with a / or a ~ still points at the property
- const segment = String(propertyName).replace(/~/g, '~0').replace(/\//g, '~1')
+ const name = String(propertyName)
+ const segment = /[~/]/.test(name) ? name.replace(/~/g, '~0').replace(/\//g, '~1') : name
const propertyLocation = new Location(
this.schema[propertyName],
this.schemaId,
diff --git a/lib/serializer.js b/lib/serializer.js
index 1b3a77d8..78fbbcff 100644
--- a/lib/serializer.js
+++ b/lib/serializer.js
@@ -135,6 +135,39 @@ module.exports = class Serializer {
return '"' + str + '"'
}
+ // Copy primitive strings once, ignoring array/prototype toJSON hooks. On a
+ // miss, serialize the captured prefix and coerce the remaining items in order,
+ // matching buildSingleTypeSerializer without rereading getters.
+ asPlainStringArray (arr, len = arr.length) {
+ const copy = new Array(len)
+ Object.setPrototypeOf(copy, null)
+ for (let i = 0; i < len; i++) {
+ const value = arr[i]
+ if (typeof value !== 'string') {
+ copy.length = i
+ let json = JSON.stringify(copy).slice(0, -1)
+ for (let j = i; j < len; j++) {
+ const item = j === i ? value : arr[j]
+ if (j > 0) json += ','
+ if (typeof item === 'string') {
+ json += asString(item)
+ } else if (item === null) {
+ json += '""'
+ } else if (item instanceof Date) {
+ json += '"' + item.toISOString() + '"'
+ } else if (item instanceof RegExp) {
+ json += asString(item.source)
+ } else {
+ json += asString(item.toString())
+ }
+ }
+ return json + ']'
+ }
+ copy[i] = value
+ }
+ return JSON.stringify(copy)
+ }
+
getState () {
return this._options
}
diff --git a/test/array.test.js b/test/array.test.js
index ded80238..2c8d3393 100644
--- a/test/array.test.js
+++ b/test/array.test.js
@@ -711,3 +711,125 @@ buildTest({
largeArraySize: '10000',
largeArrayMechanism: 'default'
})
+
+buildTest({
+ title: 'string array whose items declare const',
+ type: 'array',
+ items: { type: 'string', const: 'a' }
+}, ['a', 'a'])
+
+buildTest({
+ title: 'string array whose items declare allOf',
+ type: 'array',
+ items: { type: 'string', allOf: [{ type: 'string' }] }
+}, ['a', 'b'])
+
+buildTest({
+ title: 'string array whose items declare anyOf',
+ type: 'array',
+ items: { type: 'string', anyOf: [{ type: 'string' }] }
+}, ['a', 'b'])
+
+buildTest({
+ title: 'string array whose items declare oneOf',
+ type: 'array',
+ items: { type: 'string', oneOf: [{ type: 'string' }] }
+}, ['a', 'b'])
+
+buildTest({
+ title: 'string array whose items declare if',
+ type: 'array',
+ items: { type: 'string', if: { type: 'string' }, then: { type: 'string' } }
+}, ['a', 'b'])
+
+test('string array coerces a non-string item', (t) => {
+ const stringify = build({
+ type: 'array',
+ items: { type: 'string' }
+ }, { largeArraySize: 2 })
+ const date = new Date('2020-01-02T03:04:05.006Z')
+
+ t.assert.equal(
+ stringify([1, 'a', null, date, /re/]),
+ '["1","a","","2020-01-02T03:04:05.006Z","re"]'
+ )
+})
+
+// Large number/integer arrays are copied and handed to JSON.stringify. The
+// first element that is not already the right type switches to the per-item
+// loop from that index on. 3e5 elements is the largest fast-path cutoff on
+// any supported Node release (see PRIMITIVE_FAST_PATH_MIN_LENGTH in index.js),
+// so these always exercise the resume path.
+const FAST_PATH_LENGTH = 3e5
+
+for (const [type, bad, expected] of [
+ ['integer', 1.9, '1'],
+ ['integer', '7', '7'],
+ ['integer', null, '0'],
+ ['number', '7.5', '7.5'],
+ ['number', null, '0'],
+ ['number', true, '1']
+]) {
+ for (const [position, index] of [
+ ['start', 0],
+ ['middle', FAST_PATH_LENGTH >> 1],
+ ['end', FAST_PATH_LENGTH - 1]
+ ]) {
+ test(`large ${type} array falls back to the loop on ${JSON.stringify(bad)} at the ${position}`, (t) => {
+ const stringify = build({ type: 'array', items: { type } })
+ const input = Array.from({ length: FAST_PATH_LENGTH }, (_, i) => i - 1000)
+ input[index] = bad
+
+ const reference = Array.from(input, (v, i) => i === index ? expected : String(v))
+ t.assert.equal(stringify(input), '[' + reference.join(',') + ']')
+ })
+ }
+}
+
+test('large integer array applies the rounding option after the fallback', (t) => {
+ const stringify = build({ type: 'array', items: { type: 'integer' } }, { rounding: 'ceil' })
+ const input = Array.from({ length: FAST_PATH_LENGTH }, (_, i) => i)
+ input[10] = 1.1
+ input[FAST_PATH_LENGTH - 1] = 2.1
+
+ const output = stringify(input)
+ t.assert.equal(output.slice(0, 23), '[0,1,2,3,4,5,6,7,8,9,2,')
+ t.assert.equal(output.slice(-3), ',3]')
+})
+
+test('large number array still throws on a non-numeric string after the fallback', (t) => {
+ const stringify = build({ type: 'array', items: { type: 'number' } })
+ const input = Array.from({ length: FAST_PATH_LENGTH }, (_, i) => i / 10)
+ input[FAST_PATH_LENGTH >> 1] = 'not a number'
+
+ t.assert.throws(() => stringify(input), new Error('The value "not a number" cannot be converted to a number.'))
+})
+
+// A real NaN is typeof 'number', so only the `v === v` half of the fast-path
+// condition rejects it. Check it at every position the copy loop can stop at.
+for (const [position, index] of [['start', 0], ['middle', FAST_PATH_LENGTH >> 1], ['end', FAST_PATH_LENGTH - 1]]) {
+ test(`large number array still throws on NaN at the ${position} after the fallback`, (t) => {
+ const stringify = build({ type: 'array', items: { type: 'number' } })
+ const input = Array.from({ length: FAST_PATH_LENGTH }, (_, i) => i / 10)
+ input[index] = NaN
+
+ t.assert.throws(() => stringify(input), new Error('The value "NaN" cannot be converted to a number.'))
+ })
+}
+
+test('large number array reads each element once across the fallback', (t) => {
+ const stringify = build({ type: 'array', items: { type: 'number' } })
+ const input = Array.from({ length: FAST_PATH_LENGTH }, (_, i) => i)
+ const reads = []
+ for (const index of [0, 1, FAST_PATH_LENGTH >> 1, FAST_PATH_LENGTH - 1]) {
+ Object.defineProperty(input, index, {
+ get () {
+ reads.push(index)
+ return index === 1 ? '1' : index
+ }
+ })
+ }
+
+ t.assert.equal(stringify(input), JSON.stringify(Array.from({ length: FAST_PATH_LENGTH }, (_, i) => i)))
+ t.assert.deepEqual(reads, [0, 1, FAST_PATH_LENGTH >> 1, FAST_PATH_LENGTH - 1])
+})
diff --git a/test/schema-depth.test.js b/test/schema-depth.test.js
index 1f570dc8..d643db6b 100644
--- a/test/schema-depth.test.js
+++ b/test/schema-depth.test.js
@@ -110,11 +110,23 @@ test('rejects invalid maximum schema depths', (t) => {
test('checks schema dependencies without treating property lists as schemas', (t) => {
t.assert.doesNotThrow(() => build({
type: 'object',
+ definitions: {
+ valueProp: { type: 'string' },
+ any: true
+ },
+ // Include a $ref so recursive-schema detection runs and walks dependency
+ // schema forms (property-list dependencies must still be ignored). Boolean
+ // schemas must also be visited so traverse's non-object early-return stays covered.
+ properties: {
+ link: { $ref: '#/definitions/valueProp' },
+ flag: { $ref: '#/definitions/any' },
+ open: true
+ },
dependencies: {
- enabled: { properties: { value: { type: 'string' } } },
+ enabled: { properties: { value: { $ref: '#/definitions/valueProp' } } },
value: ['enabled']
}
- }, { maxDepth: 2 }))
+ }, { maxDepth: 3 }))
})
test('checks external schemas before validating them recursively', (t) => {