From 638c599fa9a3af0a974bbb7076d8d8300b651c1b Mon Sep 17 00:00:00 2001 From: Daniel McSheehy Date: Sat, 17 Oct 2026 03:17:41 -0400 Subject: [PATCH 1/6] perf: speed up build() and large primitive and string arrays Keep output byte-identical to the generated loop. Lazy-compile the Ajv validator, skip the recursion scan when the schema has no $ref, and walk schemas with fewer allocations. Fold object key prefixes into one append. For plain number, integer, and string item arrays, copy into a null-prototype array and JSON.stringify, falling back to the original loop at the first non-conforming element. Booleans stay on the loop. Integer and number cutoffs are per runtime: Node 22+ uses 512 / 32; Node 20 uses 1e5. --- benchmark/bench-compile.js | 40 +++ benchmark/bench-fixtures.js | 83 +++++ benchmark/bench-large-array.js | 64 ++++ benchmark/bench-utils.js | 41 +++ index.js | 277 +++++++++++--- lib/location.js | 3 +- lib/serializer.js | 33 ++ test/array-fastpath-semantics.test.js | 116 ++++++ test/largeArrayMechanism.semantics.test.js | 62 ++++ test/primitive-array-fastpath.test.js | 227 ++++++++++++ test/schema-depth.test.js | 16 +- test/string-array-fastpath.test.js | 398 +++++++++++++++++++++ 12 files changed, 1309 insertions(+), 51 deletions(-) create mode 100644 benchmark/bench-compile.js create mode 100644 benchmark/bench-fixtures.js create mode 100644 benchmark/bench-large-array.js create mode 100644 benchmark/bench-utils.js create mode 100644 test/array-fastpath-semantics.test.js create mode 100644 test/largeArrayMechanism.semantics.test.js create mode 100644 test/primitive-array-fastpath.test.js create mode 100644 test/string-array-fastpath.test.js diff --git a/benchmark/bench-compile.js b/benchmark/bench-compile.js new file mode 100644 index 00000000..b8d6d22d --- /dev/null +++ b/benchmark/bench-compile.js @@ -0,0 +1,40 @@ +'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 { compileFixtures } = require('./bench-fixtures') +const { positiveNumber, runWorker, saveResults } = require('./bench-utils') + +async function measure ({ name, iterations }) { + const { schema, options } = compileFixtures()[name] + let output + const bench = new Bench({ time: 0, iterations, warmupTime: 0, warmupIterations: 20, throws: true }) + bench.add(name, () => { output = build(schema, options) }) + await bench.run() + assert.equal(typeof output, options?.mode === 'standalone' ? 'string' : 'function') + const { latency } = bench.tasks[0].result + return { name, medianMs: latency.p50, meanMs: latency.mean, sdMs: latency.sd, rme: latency.rme, ops: 1000 / latency.p50, samples: latency.samplesCount } +} + +async function main () { + const { values } = parseArgs({ options: { case: { type: 'string' }, iterations: { type: 'string', default: '100' }, json: { type: 'string' } } }) + const names = values.case ? [values.case] : Object.keys(compileFixtures()) + if (names.some(name => !Object.hasOwn(compileFixtures(), name))) throw new Error('Unknown compile case') + const config = { iterations: positiveNumber(values.iterations, 'iterations', true), warmupIterations: 20 } + const rows = [] + console.log('| Schema | Median build ms | Median builds/sec | SD ms | RME % | Samples |') + console.log('| --- | ---: | ---: | ---: | ---: | ---: |') + for (const name of names) { + const row = await runWorker(__filename, { name, ...config }) + rows.push(row) + console.log(`| ${name} | ${row.medianMs.toFixed(4)} | ${row.ops.toFixed(2)} | ${row.sdMs.toFixed(4)} | ${row.rme.toFixed(2)} | ${row.samples} |`) + } + console.log('\nSchema allocation, module loading and 20 warmup builds are excluded; standalone measures source generation, not loading the generated module.') + saveResults(values.json, { ...config, names }, 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-fixtures.js b/benchmark/bench-fixtures.js new file mode 100644 index 00000000..08d5dd51 --- /dev/null +++ b/benchmark/bench-fixtures.js @@ -0,0 +1,83 @@ +'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) } +} + +function compileFixtures () { + const wide = { type: 'object', properties: {} } + for (let i = 0; i < 200; i++) wide.properties[`p${i}`] = { type: ['string', 'number', 'boolean'][i % 3] } + let deep = { type: 'string' } + for (let i = 0; i < 20; i++) deep = { type: 'object', properties: { child: deep } } + const refs = { type: 'object', definitions: {}, properties: {} } + for (let i = 0; i < 50; i++) { + refs.definitions[`d${i}`] = structuredClone(flatSchema) + refs.properties[`p${i}`] = { $ref: `#/definitions/d${i}` } + } + return { + tiny: { schema: { type: 'string' } }, + wide: { schema: wide }, + deep: { schema: deep }, + refs: { schema: refs }, + 'array-of-objects': { schema: { type: 'array', items: flatSchema } }, + 'oneOf-anyOf': { + schema: { + type: 'object', + properties: { + choice: { oneOf: [{ type: 'string' }, { type: 'integer' }] }, + payload: { anyOf: [{ type: 'boolean' }, flatSchema] } + } + } + }, + standalone: { schema: wide, options: { mode: 'standalone' } } + } +} + +module.exports = { arrayKinds, arraySizes, mechanisms, arrayFixture, compileFixtures } 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..2f9c6c09 100644 --- a/index.js +++ b/index.js @@ -13,6 +13,21 @@ 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 +// with the output consumed (flattened) as a socket write would, in CPU-pinned +// containers on Node 20/22/24. On V8 >= 12.4 (Node 22) numbers win from +// ~100 elements because native float formatting beats per-item ToString, +// and integers from ~512 because small-int ToString is cached. Node 20's +// JSON.stringify lacks the fast integer-array path and loses 15-25% up to +// ~1e5 elements for integers and integer-valued numbers, so both kinds wait +// for 1e5 there. Booleans never win and keep the loop. Array lookup instead +// of a ternary keeps this fully covered on every Node. +const nodeMajor = Number.parseInt(process.versions.node, 10) +const PRIMITIVE_FAST_PATH_MIN_LENGTH = [{ integer: 1e5, number: 1e5 }, { integer: 512, number: 32 }][+(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 +59,8 @@ const { asDateTime, asDate, asTime, - asUnsafeString + asUnsafeString, + asPlainStringArray } = serializer const asInteger = serializer.asInteger.bind(serializer) @@ -81,6 +97,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 +124,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 +351,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 +386,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 +426,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 +472,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 +639,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 +663,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 +686,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 +712,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 +734,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 +747,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 +785,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 +887,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 +909,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. Native + // float formatting wins from ~100 elements on Node 20/22/24. + 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 copy only wins from ~500 + // elements, and only on V8 >= 12.4 (see 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 +987,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 +1032,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 +1155,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 +1218,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 +1417,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-fastpath-semantics.test.js b/test/array-fastpath-semantics.test.js new file mode 100644 index 00000000..c5cd2507 --- /dev/null +++ b/test/array-fastpath-semantics.test.js @@ -0,0 +1,116 @@ +'use strict' + +const { test } = require('node:test') +const build = require('..') + +for (const type of ['string', 'number', 'integer', 'boolean']) { + const item = type === 'string' ? 'first' : type === 'boolean' ? true : 1 + const later = type === 'string' ? 'later' : type === 'boolean' ? false : 2 + const schema = { type: 'array', items: { type } } + const stringify = build(schema, { largeArraySize: 1 }) + + test(`${type} fast path reads getters once and ignores source toJSON`, t => { + let reads = 0 + const input = new Array(32).fill(item) + Object.defineProperty(input, 0, { get () { return reads++ === 0 ? item : later } }) + input.toJSON = () => ['wrong'] + t.assert.equal(stringify(input), JSON.stringify(new Array(32).fill(item))) + t.assert.equal(reads, 1) + }) + + test(`${type} fast path miss retains the captured prefix`, t => { + let reads = 0 + const input = [item, null, ...new Array(30).fill(item)] + Object.defineProperty(input, 0, { get () { return reads++ === 0 ? item : later } }) + const coercedNull = type === 'string' ? '' : type === 'boolean' ? false : 0 + t.assert.equal(stringify(input), JSON.stringify([item, coercedNull, ...new Array(30).fill(item)])) + t.assert.equal(reads, 1) + }) + + test(`${type} fast path ignores inherited array toJSON`, t => { + const previous = Object.getOwnPropertyDescriptor(Array.prototype, 'toJSON') + let output + try { + // eslint-disable-next-line no-extend-native -- Deliberate user-data hook. + Object.defineProperty(Array.prototype, 'toJSON', { + configurable: true, + get () { throw new Error('array toJSON must not be read') } + }) + output = stringify(new Array(32).fill(item)) + } finally { + // eslint-disable-next-line no-extend-native -- Restore the original descriptor. + if (previous) Object.defineProperty(Array.prototype, 'toJSON', previous) + else delete Array.prototype.toJSON + } + t.assert.equal(output, JSON.stringify(new Array(32).fill(item))) + }) + + test(`${type} arrays read proxy length once without extra coercion`, t => { + let lengths = 0 + let coercions = 0 + const input = new Proxy([item, item, item, item], { + get (target, key) { + if (key === 'length') { + lengths++ + return { valueOf () { return ++coercions < 3 ? 4 : 2 } } + } + return target[key] + } + }) + t.assert.equal(stringify(input), JSON.stringify([item, item])) + t.assert.equal(lengths, 1) + t.assert.equal(coercions, 3) + }) + + test(`${type} arrays preserve numeric proxy lengths`, t => { + for (const length of [-1, NaN, 0, 1.5, 2]) { + let reads = 0 + const input = new Proxy([item, item], { + get (target, key) { + if (key === 'length') { reads++; return length } + return target[key] + } + }) + t.assert.equal(stringify(input), JSON.stringify(length > 0 ? [item, item] : [])) + t.assert.equal(reads, 1) + } + }) + + test(`${type} arrays work in standalone mode with source hooks`, t => { + const source = build(schema, { mode: 'standalone', largeArraySize: 1 }) + const module = { exports: {} } + // eslint-disable-next-line no-new-func + new Function('require', 'module', source)(require, module) + const input = [item, null, ...new Array(30).fill(item)] + input.toJSON = () => ['wrong'] + t.assert.equal(module.exports(input), stringify(input)) + }) +} + +for (const type of ['string', 'number', 'integer']) { + test(`${type} fallback coerces the first miss before reading the next item`, t => { + const events = [] + const input = [type === 'string' ? '1' : 1, { + toString () { events.push('coerce'); input[2] = 7; return '2' }, + valueOf () { events.push('coerce'); input[2] = 7; return 2 } + }, ...new Array(30).fill(3)] + const proxy = new Proxy(input, { + get (target, key) { events.push(key); return target[key] } + }) + const stringify = build({ type: 'array', items: { type } }, { largeArraySize: 1 }) + const expected = [1, 2, 7, ...new Array(29).fill(3)] + t.assert.equal(stringify(proxy), JSON.stringify(type === 'string' ? expected.map(String) : expected)) + t.assert.deepEqual(events, ['length', '0', '1', 'coerce', ...Array.from({ length: 30 }, (_, i) => String(i + 2))]) + }) +} + +test('fast paths stop before later getters when coercion throws', t => { + for (const type of ['string', 'number', 'integer']) { + let reads = 0 + const input = [type === 'string' ? '1' : 1, undefined, ...new Array(30).fill(3)] + Object.defineProperty(input, 2, { get () { reads++; return 3 } }) + const stringify = build({ type: 'array', items: { type } }, { largeArraySize: 1 }) + t.assert.throws(() => stringify(input)) + t.assert.equal(reads, 0) + } +}) diff --git a/test/largeArrayMechanism.semantics.test.js b/test/largeArrayMechanism.semantics.test.js new file mode 100644 index 00000000..7c8fa452 --- /dev/null +++ b/test/largeArrayMechanism.semantics.test.js @@ -0,0 +1,62 @@ +'use strict' + +const { test } = require('node:test') +const build = require('..') + +test('json-stringify mechanism diverges from default on null in non-nullable integer array', (t) => { + t.plan(2) + const schema = { type: 'array', items: { type: 'integer' } } + const def = build(schema, { largeArrayMechanism: 'default' }) + const js = build(schema, { largeArrayMechanism: 'json-stringify', largeArraySize: 1 }) + t.assert.equal(def([1, null, 2]), '[1,0,2]') + t.assert.equal(js([1, null, 2]), '[1,null,2]') +}) + +test('json-stringify mechanism diverges from default on extra object properties', (t) => { + t.plan(2) + const schema = { + type: 'array', + items: { + type: 'object', + properties: { + a: { type: 'integer' }, + b: { type: 'string' } + }, + additionalProperties: false + } + } + const def = build(schema, { largeArrayMechanism: 'default' }) + const js = build(schema, { largeArrayMechanism: 'json-stringify', largeArraySize: 1 }) + const input = [{ a: 1, b: 'x', extra: 'y' }] + t.assert.equal(def(input), '[{"a":1,"b":"x"}]') + t.assert.equal(js(input), '[{"a":1,"b":"x","extra":"y"}]') +}) + +test('json-stringify mechanism follows insertion order; default follows schema property order', (t) => { + t.plan(2) + const schema = { + type: 'array', + items: { + type: 'object', + properties: { + a: { type: 'integer' }, + b: { type: 'string' } + }, + required: ['a', 'b'] + } + } + const def = build(schema, { largeArrayMechanism: 'default' }) + const js = build(schema, { largeArrayMechanism: 'json-stringify', largeArraySize: 1 }) + const input = [{ b: 'x', a: 1 }] + t.assert.equal(def(input), '[{"a":1,"b":"x"}]') + t.assert.equal(js(input), '[{"b":"x","a":1}]') +}) + +test('typed arrays are not accepted as array inputs', (t) => { + t.plan(4) + const stringify = build({ type: 'array', items: { type: 'number' } }) + t.assert.equal(Array.isArray(new Float64Array([1, 2, 3])), false) + t.assert.equal(Array.isArray(new Int32Array([1, 2, 3])), false) + t.assert.throws(() => stringify(new Float64Array([1, 2, 3])), TypeError) + t.assert.throws(() => stringify(new Int32Array([1, 2, 3])), TypeError) +}) diff --git a/test/primitive-array-fastpath.test.js b/test/primitive-array-fastpath.test.js new file mode 100644 index 00000000..e9c6879e --- /dev/null +++ b/test/primitive-array-fastpath.test.js @@ -0,0 +1,227 @@ +'use strict' + +const { test } = require('node:test') + +const build = require('..') + +// The primitive-array fast path (whole-array JSON.stringify behind an +// element guard) must be byte-identical to the per-item serializer for +// every input class. These tests pin the exact bytes and errors for the +// edge inputs where the two mechanisms could diverge. + +test('array of numbers: fast path outputs byte-identical JSON', (t) => { + t.plan(8) + const stringify = build({ type: 'array', items: { type: 'number' } }) + + t.assert.equal(stringify([1, 2, 3]), '[1,2,3]') + t.assert.equal(stringify([1.5, -1.5, 0.1]), '[1.5,-1.5,0.1]') + t.assert.equal(stringify([-0]), '[0]') + t.assert.equal(stringify([Infinity, -Infinity]), '[null,null]') + t.assert.equal(stringify([1e21, 1e-7, 5e-324]), '[1e+21,1e-7,5e-324]') + t.assert.equal(stringify([Number.MAX_SAFE_INTEGER, Number.MIN_SAFE_INTEGER]), '[9007199254740991,-9007199254740991]') + t.assert.equal(stringify([]), '[]') + t.assert.equal(stringify(new Array(100).fill(42)), JSON.stringify(new Array(100).fill(42))) +}) + +test('array of numbers: NaN falls back and throws like the serializer', (t) => { + t.plan(3) + const stringify = build({ type: 'array', items: { type: 'number' } }) + + t.assert.throws(() => stringify([1, NaN, 3]), /The value "NaN" cannot be converted to a number\./) + t.assert.throws(() => stringify([1, undefined, 3]), /The value "undefined" cannot be converted to a number\./) + // holes read as undefined and must throw the same way + const holey = [1, 2, 3] + delete holey[1] + t.assert.throws(() => stringify(holey), /The value "undefined" cannot be converted to a number\./) +}) + +test('array of numbers: coercible non-numbers fall back to asNumber', (t) => { + t.plan(5) + const stringify = build({ type: 'array', items: { type: 'number' } }) + + t.assert.equal(stringify(['42']), '[42]') + t.assert.equal(stringify(['1.5']), '[1.5]') + t.assert.equal(stringify([null]), '[0]') + t.assert.equal(stringify([true, false]), '[1,0]') + t.assert.equal(stringify([10n]), '[10]') +}) + +test('array of integers: fast path outputs byte-identical JSON', (t) => { + t.plan(7) + const stringify = build({ type: 'array', items: { type: 'integer' } }) + + t.assert.equal(stringify([1, 2, 3]), '[1,2,3]') + t.assert.equal(stringify([-0]), '[0]') + t.assert.equal(stringify([1e21]), '[1e+21]') + t.assert.equal(stringify([Number.MAX_SAFE_INTEGER]), '[9007199254740991]') + t.assert.equal(stringify([]), '[]') + t.assert.equal(stringify(new Array(100).fill(7)), JSON.stringify(new Array(100).fill(7))) + // bigint items go through asInteger's bigint branch + t.assert.equal(stringify([10n, 9007199254740993n]), '[10,9007199254740993]') +}) + +test('array of integers: non-integers fall back and follow rounding', (t) => { + t.plan(7) + const stringify = build({ type: 'array', items: { type: 'integer' } }) + const ceilify = build({ type: 'array', items: { type: 'integer' } }, { rounding: 'ceil' }) + + t.assert.equal(stringify([1.5]), '[1]') + t.assert.equal(ceilify([1.5]), '[2]') + t.assert.equal(stringify([-1.5]), '[-1]') + t.assert.equal(stringify([1e-7]), '[0]') + t.assert.equal(ceilify([1e-7]), '[1]') + t.assert.throws(() => stringify([NaN]), /The value "NaN" cannot be converted to an integer\./) + t.assert.throws(() => stringify([Infinity]), /The value "Infinity" cannot be converted to an integer\./) +}) + +test('array of booleans: fast path and fallback', (t) => { + t.plan(6) + const stringify = build({ type: 'array', items: { type: 'boolean' } }) + + t.assert.equal(stringify([true, false, true]), '[true,false,true]') + t.assert.equal(stringify([]), '[]') + t.assert.equal(stringify(new Array(100).fill(true)), JSON.stringify(new Array(100).fill(true))) + // truthy/falsy non-booleans fall back to asBoolean coercion + t.assert.equal(stringify([1, 0]), '[true,false]') + t.assert.equal(stringify([null, undefined]), '[false,false]') + t.assert.equal(stringify(['x', '']), '[true,false]') +}) + +test('mixed guard-hit/miss arrays still produce exact baseline output', (t) => { + t.plan(4) + const stringify = build({ type: 'array', items: { type: 'number' } }) + + // guard breaks at index 1; slow loop must still serialize index 0 the same way + t.assert.equal(stringify([1, '2', 3]), '[1,2,3]') + t.assert.equal(stringify([1, 2, '3']), '[1,2,3]') + // objects with valueOf coerce through asNumber, they do not throw + t.assert.equal(stringify([1, 2, { valueOf: () => 42 }]), '[1,2,42]') + t.assert.equal(stringify([{ valueOf: () => 42 }, 2]), '[42,2]') +}) + +test('objects with toJSON in primitive arrays throw like the baseline', (t) => { + t.plan(2) + const numify = build({ type: 'array', items: { type: 'number' } }) + const boolify = build({ type: 'array', items: { type: 'boolean' } }) + + t.assert.throws(() => numify([{ toJSON: () => 42 }]), /The value "\[object Object\]" cannot be converted to a number\./) + // asBoolean coerces via truthiness; an object with toJSON is truthy + t.assert.equal(boolify([{ toJSON: () => false }]), '[true]') +}) + +test('nested primitive arrays inside objects use the fast path too', (t) => { + t.plan(5) + const stringify = build({ + type: 'object', + properties: { + ints: { type: 'array', items: { type: 'integer' } }, + nums: { type: 'array', items: { type: 'number' } }, + bools: { type: 'array', items: { type: 'boolean' } } + } + }) + + t.assert.equal( + stringify({ ints: [1, 2], nums: [1.5, Infinity], bools: [true] }), + '{"ints":[1,2],"nums":[1.5,null],"bools":[true]}' + ) + t.assert.equal(stringify({ ints: [], nums: [], bools: [] }), '{"ints":[],"nums":[],"bools":[]}') + t.assert.throws(() => stringify({ nums: [NaN] }), /The value "NaN" cannot be converted to a number\./) + // 1.5 is not an integer, but trunc-able: falls back and truncates like the baseline + t.assert.equal(stringify({ ints: [1.5] }), '{"ints":[1]}') + // missing properties stay omitted; present one keeps fast-path bytes + t.assert.equal(stringify({ ints: [1, '2'] }), '{"ints":[1,2]}') +}) + +test('large primitive arrays are byte-identical to JSON.stringify', (t) => { + t.plan(9) + const numify = build({ type: 'array', items: { type: 'number' } }) + const integify = build({ type: 'array', items: { type: 'integer' } }) + const boolify = build({ type: 'array', items: { type: 'boolean' } }) + + const sizes = [1e3, 2e4, 1e5] + const nums = [] + const ints = [] + const bools = [] + for (let i = 0; i < 1e5; i++) { + nums.push(i / 7) + ints.push(i - 5e4) + bools.push(i % 2 === 0) + } + for (const size of sizes) { + t.assert.equal(numify(nums.slice(0, size)), JSON.stringify(nums.slice(0, size))) + t.assert.equal(integify(ints.slice(0, size)), JSON.stringify(ints.slice(0, size))) + t.assert.equal(boolify(bools.slice(0, size)), JSON.stringify(bools.slice(0, size))) + } +}) + +test('largeArrayMechanism json-stringify keeps its own behavior', (t) => { + t.plan(3) + const stringify = build({ type: 'array', items: { type: 'number' } }, { largeArrayMechanism: 'json-stringify' }) + + // small arrays go through the per-item path and keep coercing + t.assert.equal(stringify(['42']), '[42]') + // large arrays are unconditionally natively stringified (documented opt-in trade-off) + const big = new Array(2e4).fill(1) + t.assert.equal(stringify(big), JSON.stringify(big)) + t.assert.throws(() => stringify([NaN]), /The value "NaN" cannot be converted to a number\./) +}) + +test('fast path does not apply to typed arrays (still TypeError)', (t) => { + t.plan(2) + const stringify = build({ type: 'array', items: { type: 'number' } }) + + t.assert.throws(() => stringify(new Float64Array([1, 2, 3])), /does not match schema definition\./) + t.assert.throws(() => stringify(new Int32Array([1, 2, 3])), /does not match schema definition\./) +}) + +test('excluded item schemas keep the classic path', (t) => { + t.plan(5) + const nullable = build({ type: 'array', items: { type: 'number', nullable: true } }) + t.assert.equal(nullable([1, null, 2]), '[1,null,2]') + + const typeArray = build({ type: 'array', items: { type: ['number', 'null'] } }) + t.assert.equal(typeArray([1, null, 2]), '[1,null,2]') + + const anyOf = build({ type: 'array', items: { anyOf: [{ type: 'number' }, { type: 'string' }] } }) + t.assert.equal(anyOf([1, 'a']), '[1,"a"]') + + const constItems = build({ type: 'array', items: { type: 'number', const: 42 } }) + t.assert.equal(constItems([42, 42]), '[42,42]') + + const strings = build({ type: 'array', items: { type: 'string' } }) + t.assert.equal(strings(['a', 'b']), '["a","b"]') +}) + +// The copy-then-stringify path only engages above a per-kind length cutoff +// (Node >= 22: number >= 32, integer >= 512; Node 20: both >= 1e5; boolean +// never). Exercise each kind on both sides of its cutoff with a miss in the +// middle, so the prefix/resume code runs for every kind on every runtime. +test('guard misses above and below each kind cutoff match the serializer', (t) => { + const cases = [ + ['number', 32, 1.5, '2', 2], + ['number', 31, 1.5, '2', 2], + ['integer', 512, 7, 1.5, 1], + ['integer', 511, 7, 1.5, 1], + ['integer', 600, 7, 10n, 10], + ['integer', 99999, 7, 1.5, 1], + ['integer', 100000, 7, 10n, 10], + ['number', 100000, 1.5, '2', 2], + ['boolean', 1000, true, 0, false] + ] + t.plan(cases.length * 3) + for (const [type, length, fill, miss, missOut] of cases) { + const stringify = build({ type: 'array', items: { type } }) + const clean = new Array(length).fill(fill) + t.assert.equal(stringify(clean), JSON.stringify(clean), `${type}/${length} clean`) + const mid = clean.slice() + mid[length >> 1] = miss + const expected = clean.slice() + expected[length >> 1] = missOut + t.assert.equal(stringify(mid), JSON.stringify(expected), `${type}/${length} mid miss`) + const last = clean.slice() + last[length - 1] = miss + const expectedLast = clean.slice() + expectedLast[length - 1] = missOut + t.assert.equal(stringify(last), JSON.stringify(expectedLast), `${type}/${length} last miss`) + } +}) 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) => { diff --git a/test/string-array-fastpath.test.js b/test/string-array-fastpath.test.js new file mode 100644 index 00000000..5f1312ed --- /dev/null +++ b/test/string-array-fastpath.test.js @@ -0,0 +1,398 @@ +'use strict' + +const { test } = require('node:test') + +const build = require('..') +const Serializer = require('../lib/serializer') +const OriginalLocation = require('../lib/location') + +const buildPath = require.resolve('..') +const locationPath = require.resolve('../lib/location') + +function loadBuildWithLocation (Location) { + require.cache[locationPath].exports = Location + delete require.cache[buildPath] + + try { + return require(buildPath) + } finally { + require.cache[locationPath].exports = OriginalLocation + delete require.cache[buildPath] + } +} + +class LocationWithoutSchemaId { + constructor (schema, _schemaId, jsonPointer = '#') { + this.schema = schema + this.schemaId = '' + this.jsonPointer = _schemaId ? _schemaId + jsonPointer : jsonPointer + } + + getPropertyLocation (propertyName) { + return new LocationWithoutSchemaId( + this.schema[propertyName], + '', + this.jsonPointer + '/' + propertyName + ) + } + + getSchemaRef () { + return this.schemaId + this.jsonPointer + } +} + +function stringArray (options) { + return build({ + type: 'array', + items: { type: 'string' } + }, options) +} + +test('asPlainStringArray matches native strings and preserves coercion on misses', (t) => { + const serializer = new Serializer() + const samples = [ + [], + [''], + ['hello'], + ['hello " world', 'a\\b', 'a"b\\c"'], + ['\n\r\t\b\f\u0000\u001f'], + ['A\uD800B', 'A\uDFFFB'], + ['emoji \uD83D\uDE00 end'], + ['a\u2028b\u2029c'], + ['x'.repeat(41), 'x'.repeat(42)], + ['x'.repeat(4999), 'y'.repeat(5000)], + [('c"\\').repeat(2000)] + ] + + for (const sample of samples) { + t.assert.equal(serializer.asPlainStringArray(sample), JSON.stringify(sample)) + } + + const misses = [ + [42], + [1.5], + [null], + [undefined], + [true], + [10n], + [Symbol('s')], + [new Date('2020-01-02T03:04:05.006Z')], + [new Date('invalid')], + [/a"b/], + [{ a: 1 }], + [{ toJSON () { return 'from-toJSON' }, toString () { return 'from-toString' } }], + [Object('boxed')], + [['x']], + ['ok', 1], + ['ok', 'still', null] + ] + + for (const sample of misses) { + const stringify = stringArray({ largeArraySize: 100 }) + let expected + let failure + try { + expected = stringify(sample) + } catch (error) { + failure = error + } + if (failure) { + t.assert.throws(() => serializer.asPlainStringArray(sample), failure.constructor) + } else { + t.assert.equal(serializer.asPlainStringArray(sample), expected) + } + } +}) + +test('asString matches JSON.stringify for string contents', (t) => { + const serializer = new Serializer() + const alphabet = ['a', '"', '\\', '\n', '\u0000', '\u0001', '\u2028', '\u2029', '\uD800', '\uDE00', '😀', ' '] + const lengths = [0, 1, 8, 41, 42, 80, 4999, 5000, 5001] + let seed = 0x12345678 + const next = () => { + seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0 + return seed + } + + for (const length of lengths) { + for (let sample = 0; sample < 15; sample++) { + let value = '' + for (let i = 0; i < length; i++) { + value += alphabet[next() % alphabet.length] + } + t.assert.equal(serializer.asString(value), JSON.stringify(value)) + } + } +}) + +test('large plain string arrays use the native fast path', (t) => { + const stringify = stringArray({ largeArraySize: 3 }) + const input = ['hello "world"', 'a\\b', 'a\u2028b', 'x'.repeat(80)] + input.toJSON = () => ['nope'] + + const original = JSON.stringify + let sawCopy = false + JSON.stringify = function (value, replacer, space) { + if ( + Array.isArray(value) && + value !== input && + value.length === input.length && + value[0] === input[0] && + typeof value.toJSON !== 'function' + ) { + sawCopy = true + } + return original.call(JSON, value, replacer, space) + } + + try { + t.assert.equal(stringify(input), original(input.slice())) + t.assert.equal(sawCopy, true) + } finally { + JSON.stringify = original + } +}) + +test('arrays below largeArraySize keep the per-element path', (t) => { + const stringify = stringArray({ largeArraySize: 4 }) + const input = ['a', 'b', 'c'] + const original = JSON.stringify + let sawArray = false + JSON.stringify = function (value, replacer, space) { + if (Array.isArray(value)) sawArray = true + return original.call(JSON, value, replacer, space) + } + + try { + t.assert.equal(stringify(input), '["a","b","c"]') + t.assert.equal(sawArray, false) + } finally { + JSON.stringify = original + } +}) + +test('fast path reads each element once and ignores array toJSON', (t) => { + const stringify = stringArray({ largeArraySize: 3 }) + let reads = 0 + const input = ['a', 'b', 'c'] + Object.defineProperty(input, 1, { + enumerable: true, + get () { + reads++ + return 'q"q' + } + }) + input.toJSON = () => ['z'] + + t.assert.equal(stringify(input), '["a","q\\"q","c"]') + t.assert.equal(reads, 1) +}) + +test('mixed string arrays keep coercion and thrown errors', (t) => { + const stringify = stringArray({ largeArraySize: 2 }) + const date = new Date('2020-01-02T03:04:05.006Z') + + t.assert.equal(stringify(['a', 42, 'b']), '["a","42","b"]') + t.assert.equal(stringify(['a', 1.5]), '["a","1.5"]') + t.assert.equal(stringify(['a', null, 'b']), '["a","","b"]') + t.assert.equal(stringify([true, false]), '["true","false"]') + t.assert.equal(stringify([10n]), '["10"]') + t.assert.equal(stringify([Symbol('s')]), '["Symbol(s)"]') + t.assert.equal(stringify([date]), `["${date.toISOString()}"]`) + t.assert.equal(stringify([/a"b/]), '["a\\"b"]') + t.assert.equal(stringify([{ a: 1 }]), '["[object Object]"]') + t.assert.equal(stringify([{ + toJSON () { return 'from-toJSON' }, + toString () { return 'from-toString' } + }]), '["from-toString"]') + t.assert.equal(stringify([Object('boxed')]), '["boxed"]') + t.assert.equal(stringify([['x', 'y']]), '["x,y"]') + t.assert.equal(stringify(['only']), '["only"]') + t.assert.throws(() => stringify([undefined]), TypeError) + t.assert.throws(() => stringify(['a', undefined]), TypeError) + t.assert.throws(() => stringify([new Date('invalid')]), RangeError) + t.assert.throws(() => stringify('nope'), TypeError) + t.assert.equal(stringify(null), '[]') + t.assert.equal(stringify([]), '[]') + + const sparse = new Array(3) + sparse[0] = 'a' + t.assert.throws(() => stringify(sparse), TypeError) +}) + +test('special strings in a large array match JSON.stringify', (t) => { + const stringify = stringArray({ largeArraySize: 2 }) + const input = [ + '', + 'plain', + 'hello " world', + 'a\\b', + 'trail\\', + '\n\r\t\u0000', + 'A\uD800B', + 'A\uDFFFB', + 'emoji \uD83D\uDE00', + 'a\u2028b\u2029c', + 'x'.repeat(5000), + ('q"\\').repeat(40) + ] + + t.assert.equal(stringify(input), JSON.stringify(input)) +}) + +test('plain string fast path is not used for other item schemas', (t) => { + const options = { largeArraySize: 2, mode: 'debug' } + const cases = [ + [{ type: 'number' }, [1, 2, 3], '[1,2,3]'], + [{ type: 'string', format: 'unsafe' }, ['a"b', 'c', 'd'], '["a"b","c","d"]'], + [{ type: 'string', nullable: true }, ['a', null, 'b"'], '["a",null,"b\\""]'], + [{ type: 'string', const: 'fixed' }, ['nope', 'also', 'no'], '["fixed","fixed","fixed"]'] + ] + + for (const [items, input, expected] of cases) { + const debug = build({ type: 'array', items }, options) + t.assert.equal(debug.code.includes('asPlainStringArray('), false) + const stringify = build({ type: 'array', items }, { largeArraySize: 2 }) + t.assert.equal(stringify(input), expected) + } + + const plain = build({ type: 'array', items: { type: 'string' } }, options) + t.assert.equal(plain.code.includes('asPlainStringArray('), true) + + const optedIn = build({ type: 'array', items: { type: 'string' } }, { + largeArraySize: 2, + largeArrayMechanism: 'json-stringify', + mode: 'debug' + }) + t.assert.equal(optedIn.code.includes('asPlainStringArray('), false) + const optedInRun = build({ type: 'array', items: { type: 'string' } }, { + largeArraySize: 2, + largeArrayMechanism: 'json-stringify' + }) + t.assert.equal(optedInRun(['a', 1, 'b']), '["a",1,"b"]') +}) + +test('combinators and formatted strings stay on the per-element path', (t) => { + const options = { largeArraySize: 2 } + const schemas = [ + { type: 'string', allOf: [{ type: 'string', minLength: 0 }] }, + { type: 'string', anyOf: [{ type: 'string', minLength: 0 }, { type: 'string', minLength: 1 }] }, + { type: 'string', oneOf: [{ type: 'string', minLength: 0 }] }, + { + type: 'string', + if: { type: 'string', minLength: 0 }, + then: { type: 'string' } + } + ] + + for (const items of schemas) { + const debug = build({ type: 'array', items }, { ...options, mode: 'debug' }) + t.assert.equal(debug.code.includes('asPlainStringArray('), false) + const stringify = build({ type: 'array', items }, options) + t.assert.equal(stringify(['a"b', 'c', 'd']), '["a\\"b","c","d"]') + } + + const dated = build({ + type: 'array', + items: { type: 'string', format: 'date-time' } + }, options) + const date = new Date('2020-01-02T03:04:05.006Z') + t.assert.equal(dated([date, date]), `["${date.toISOString()}","${date.toISOString()}"]`) + t.assert.equal(dated(['already-a-string', 'second-string']), '["already-a-string","second-string"]') +}) + +test('nested string arrays and object properties use the fast path per array', (t) => { + const nested = build({ + type: 'array', + items: { + type: 'array', + items: { type: 'string' } + } + }, { largeArraySize: 2 }) + + t.assert.equal( + nested([['a"b', 'c'], ['d', 'e']]), + '[["a\\"b","c"],["d","e"]]' + ) + t.assert.equal(nested([['a', 1], ['b', 'c']]), '[["a","1"],["b","c"]]') + + const wrapped = build({ + type: 'object', + properties: { + ids: { + type: 'array', + items: { type: 'string' } + } + } + }, { largeArraySize: 2 }) + + t.assert.equal(wrapped({ ids: ['a"b', 'c', 'd'] }), '{"ids":["a\\"b","c","d"]}') + t.assert.equal(wrapped({ ids: ['a', null] }), '{"ids":["a",""]}') +}) + +test('$ref to a plain string schema can fast-path and unsafe refs cannot', (t) => { + const plain = build({ + $id: 'root', + type: 'array', + items: { $ref: 'str#' } + }, { + largeArraySize: 2, + schema: { + str: { $id: 'str', type: 'string' } + } + }) + t.assert.equal(plain(['a"b', 'c', 'd']), '["a\\"b","c","d"]') + + const unsafe = build({ + $id: 'root-unsafe', + type: 'array', + items: { $ref: 'unsafe#' } + }, { + largeArraySize: 2, + mode: 'debug', + schema: { + unsafe: { $id: 'unsafe', type: 'string', format: 'unsafe' } + } + }) + t.assert.equal(unsafe.code.includes('asPlainStringArray('), false) + const unsafeRun = build({ + $id: 'root-unsafe-run', + type: 'array', + items: { $ref: 'unsafe#' } + }, { + largeArraySize: 2, + schema: { + unsafe: { $id: 'unsafe', type: 'string', format: 'unsafe' } + } + }) + t.assert.equal(unsafeRun(['a"b', 'c']), '["a"b","c"]') +}) + +test('inlined string arrays without a schema id keep both paths', (t) => { + const inlineBuild = loadBuildWithLocation(LocationWithoutSchemaId) + const stringify = inlineBuild({ + type: 'array', + items: { type: 'string' } + }, { largeArraySize: 2 }) + const debug = inlineBuild({ + type: 'array', + items: { type: 'string' } + }, { largeArraySize: 2, mode: 'debug' }) + + t.assert.equal(debug.code.includes('asPlainStringArray('), true) + t.assert.equal(stringify(['a"b', 'c', 'd']), '["a\\"b","c","d"]') + t.assert.equal(stringify(['a', 2]), '["a","2"]') + t.assert.equal(stringify(['only']), '["only"]') + + let reads = 0 + const input = ['a', 'b', 'c'] + Object.defineProperty(input, 0, { + enumerable: true, + get () { + reads++ + return 'z"z' + } + }) + t.assert.equal(stringify(input), '["z\\"z","b","c"]') + t.assert.equal(reads, 1) +}) From 11c2caecceb64ea5feed2b3e9775aa861849f6a7 Mon Sep 17 00:00:00 2001 From: Daniel McSheehy Date: Sat, 17 Oct 2026 03:35:26 -0400 Subject: [PATCH 2/6] bench: drop the compile microbench build() timing does not belong next to the serialization suite. The measured compile numbers stay in the pull request. --- benchmark/bench-compile.js | 40 ------------------------------------- benchmark/bench-fixtures.js | 31 +--------------------------- 2 files changed, 1 insertion(+), 70 deletions(-) delete mode 100644 benchmark/bench-compile.js diff --git a/benchmark/bench-compile.js b/benchmark/bench-compile.js deleted file mode 100644 index b8d6d22d..00000000 --- a/benchmark/bench-compile.js +++ /dev/null @@ -1,40 +0,0 @@ -'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 { compileFixtures } = require('./bench-fixtures') -const { positiveNumber, runWorker, saveResults } = require('./bench-utils') - -async function measure ({ name, iterations }) { - const { schema, options } = compileFixtures()[name] - let output - const bench = new Bench({ time: 0, iterations, warmupTime: 0, warmupIterations: 20, throws: true }) - bench.add(name, () => { output = build(schema, options) }) - await bench.run() - assert.equal(typeof output, options?.mode === 'standalone' ? 'string' : 'function') - const { latency } = bench.tasks[0].result - return { name, medianMs: latency.p50, meanMs: latency.mean, sdMs: latency.sd, rme: latency.rme, ops: 1000 / latency.p50, samples: latency.samplesCount } -} - -async function main () { - const { values } = parseArgs({ options: { case: { type: 'string' }, iterations: { type: 'string', default: '100' }, json: { type: 'string' } } }) - const names = values.case ? [values.case] : Object.keys(compileFixtures()) - if (names.some(name => !Object.hasOwn(compileFixtures(), name))) throw new Error('Unknown compile case') - const config = { iterations: positiveNumber(values.iterations, 'iterations', true), warmupIterations: 20 } - const rows = [] - console.log('| Schema | Median build ms | Median builds/sec | SD ms | RME % | Samples |') - console.log('| --- | ---: | ---: | ---: | ---: | ---: |') - for (const name of names) { - const row = await runWorker(__filename, { name, ...config }) - rows.push(row) - console.log(`| ${name} | ${row.medianMs.toFixed(4)} | ${row.ops.toFixed(2)} | ${row.sdMs.toFixed(4)} | ${row.rme.toFixed(2)} | ${row.samples} |`) - } - console.log('\nSchema allocation, module loading and 20 warmup builds are excluded; standalone measures source generation, not loading the generated module.') - saveResults(values.json, { ...config, names }, 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-fixtures.js b/benchmark/bench-fixtures.js index 08d5dd51..4f3cb0e2 100644 --- a/benchmark/bench-fixtures.js +++ b/benchmark/bench-fixtures.js @@ -51,33 +51,4 @@ function arrayFixture (kind, size) { return { schema: { type: 'array', items }, input, typed: ArrayBuffer.isView(input) } } -function compileFixtures () { - const wide = { type: 'object', properties: {} } - for (let i = 0; i < 200; i++) wide.properties[`p${i}`] = { type: ['string', 'number', 'boolean'][i % 3] } - let deep = { type: 'string' } - for (let i = 0; i < 20; i++) deep = { type: 'object', properties: { child: deep } } - const refs = { type: 'object', definitions: {}, properties: {} } - for (let i = 0; i < 50; i++) { - refs.definitions[`d${i}`] = structuredClone(flatSchema) - refs.properties[`p${i}`] = { $ref: `#/definitions/d${i}` } - } - return { - tiny: { schema: { type: 'string' } }, - wide: { schema: wide }, - deep: { schema: deep }, - refs: { schema: refs }, - 'array-of-objects': { schema: { type: 'array', items: flatSchema } }, - 'oneOf-anyOf': { - schema: { - type: 'object', - properties: { - choice: { oneOf: [{ type: 'string' }, { type: 'integer' }] }, - payload: { anyOf: [{ type: 'boolean' }, flatSchema] } - } - } - }, - standalone: { schema: wide, options: { mode: 'standalone' } } - } -} - -module.exports = { arrayKinds, arraySizes, mechanisms, arrayFixture, compileFixtures } +module.exports = { arrayKinds, arraySizes, mechanisms, arrayFixture } From 395a410598933595b46e7f3edaa9c7dbd7ab6c20 Mon Sep 17 00:00:00 2001 From: Daniel McSheehy Date: Sat, 17 Oct 2026 03:42:00 -0400 Subject: [PATCH 3/6] test: drop the extra array suites The coverage gate still needs the string-array miss path and the item schemas that must stay on the old loop. Those cases are in array.test.js. --- test/array-fastpath-semantics.test.js | 116 ------ test/array.test.js | 43 +++ test/largeArrayMechanism.semantics.test.js | 62 ---- test/primitive-array-fastpath.test.js | 227 ------------ test/string-array-fastpath.test.js | 398 --------------------- 5 files changed, 43 insertions(+), 803 deletions(-) delete mode 100644 test/array-fastpath-semantics.test.js delete mode 100644 test/largeArrayMechanism.semantics.test.js delete mode 100644 test/primitive-array-fastpath.test.js delete mode 100644 test/string-array-fastpath.test.js diff --git a/test/array-fastpath-semantics.test.js b/test/array-fastpath-semantics.test.js deleted file mode 100644 index c5cd2507..00000000 --- a/test/array-fastpath-semantics.test.js +++ /dev/null @@ -1,116 +0,0 @@ -'use strict' - -const { test } = require('node:test') -const build = require('..') - -for (const type of ['string', 'number', 'integer', 'boolean']) { - const item = type === 'string' ? 'first' : type === 'boolean' ? true : 1 - const later = type === 'string' ? 'later' : type === 'boolean' ? false : 2 - const schema = { type: 'array', items: { type } } - const stringify = build(schema, { largeArraySize: 1 }) - - test(`${type} fast path reads getters once and ignores source toJSON`, t => { - let reads = 0 - const input = new Array(32).fill(item) - Object.defineProperty(input, 0, { get () { return reads++ === 0 ? item : later } }) - input.toJSON = () => ['wrong'] - t.assert.equal(stringify(input), JSON.stringify(new Array(32).fill(item))) - t.assert.equal(reads, 1) - }) - - test(`${type} fast path miss retains the captured prefix`, t => { - let reads = 0 - const input = [item, null, ...new Array(30).fill(item)] - Object.defineProperty(input, 0, { get () { return reads++ === 0 ? item : later } }) - const coercedNull = type === 'string' ? '' : type === 'boolean' ? false : 0 - t.assert.equal(stringify(input), JSON.stringify([item, coercedNull, ...new Array(30).fill(item)])) - t.assert.equal(reads, 1) - }) - - test(`${type} fast path ignores inherited array toJSON`, t => { - const previous = Object.getOwnPropertyDescriptor(Array.prototype, 'toJSON') - let output - try { - // eslint-disable-next-line no-extend-native -- Deliberate user-data hook. - Object.defineProperty(Array.prototype, 'toJSON', { - configurable: true, - get () { throw new Error('array toJSON must not be read') } - }) - output = stringify(new Array(32).fill(item)) - } finally { - // eslint-disable-next-line no-extend-native -- Restore the original descriptor. - if (previous) Object.defineProperty(Array.prototype, 'toJSON', previous) - else delete Array.prototype.toJSON - } - t.assert.equal(output, JSON.stringify(new Array(32).fill(item))) - }) - - test(`${type} arrays read proxy length once without extra coercion`, t => { - let lengths = 0 - let coercions = 0 - const input = new Proxy([item, item, item, item], { - get (target, key) { - if (key === 'length') { - lengths++ - return { valueOf () { return ++coercions < 3 ? 4 : 2 } } - } - return target[key] - } - }) - t.assert.equal(stringify(input), JSON.stringify([item, item])) - t.assert.equal(lengths, 1) - t.assert.equal(coercions, 3) - }) - - test(`${type} arrays preserve numeric proxy lengths`, t => { - for (const length of [-1, NaN, 0, 1.5, 2]) { - let reads = 0 - const input = new Proxy([item, item], { - get (target, key) { - if (key === 'length') { reads++; return length } - return target[key] - } - }) - t.assert.equal(stringify(input), JSON.stringify(length > 0 ? [item, item] : [])) - t.assert.equal(reads, 1) - } - }) - - test(`${type} arrays work in standalone mode with source hooks`, t => { - const source = build(schema, { mode: 'standalone', largeArraySize: 1 }) - const module = { exports: {} } - // eslint-disable-next-line no-new-func - new Function('require', 'module', source)(require, module) - const input = [item, null, ...new Array(30).fill(item)] - input.toJSON = () => ['wrong'] - t.assert.equal(module.exports(input), stringify(input)) - }) -} - -for (const type of ['string', 'number', 'integer']) { - test(`${type} fallback coerces the first miss before reading the next item`, t => { - const events = [] - const input = [type === 'string' ? '1' : 1, { - toString () { events.push('coerce'); input[2] = 7; return '2' }, - valueOf () { events.push('coerce'); input[2] = 7; return 2 } - }, ...new Array(30).fill(3)] - const proxy = new Proxy(input, { - get (target, key) { events.push(key); return target[key] } - }) - const stringify = build({ type: 'array', items: { type } }, { largeArraySize: 1 }) - const expected = [1, 2, 7, ...new Array(29).fill(3)] - t.assert.equal(stringify(proxy), JSON.stringify(type === 'string' ? expected.map(String) : expected)) - t.assert.deepEqual(events, ['length', '0', '1', 'coerce', ...Array.from({ length: 30 }, (_, i) => String(i + 2))]) - }) -} - -test('fast paths stop before later getters when coercion throws', t => { - for (const type of ['string', 'number', 'integer']) { - let reads = 0 - const input = [type === 'string' ? '1' : 1, undefined, ...new Array(30).fill(3)] - Object.defineProperty(input, 2, { get () { reads++; return 3 } }) - const stringify = build({ type: 'array', items: { type } }, { largeArraySize: 1 }) - t.assert.throws(() => stringify(input)) - t.assert.equal(reads, 0) - } -}) diff --git a/test/array.test.js b/test/array.test.js index ded80238..47fec1d6 100644 --- a/test/array.test.js +++ b/test/array.test.js @@ -711,3 +711,46 @@ 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"]' + ) +}) diff --git a/test/largeArrayMechanism.semantics.test.js b/test/largeArrayMechanism.semantics.test.js deleted file mode 100644 index 7c8fa452..00000000 --- a/test/largeArrayMechanism.semantics.test.js +++ /dev/null @@ -1,62 +0,0 @@ -'use strict' - -const { test } = require('node:test') -const build = require('..') - -test('json-stringify mechanism diverges from default on null in non-nullable integer array', (t) => { - t.plan(2) - const schema = { type: 'array', items: { type: 'integer' } } - const def = build(schema, { largeArrayMechanism: 'default' }) - const js = build(schema, { largeArrayMechanism: 'json-stringify', largeArraySize: 1 }) - t.assert.equal(def([1, null, 2]), '[1,0,2]') - t.assert.equal(js([1, null, 2]), '[1,null,2]') -}) - -test('json-stringify mechanism diverges from default on extra object properties', (t) => { - t.plan(2) - const schema = { - type: 'array', - items: { - type: 'object', - properties: { - a: { type: 'integer' }, - b: { type: 'string' } - }, - additionalProperties: false - } - } - const def = build(schema, { largeArrayMechanism: 'default' }) - const js = build(schema, { largeArrayMechanism: 'json-stringify', largeArraySize: 1 }) - const input = [{ a: 1, b: 'x', extra: 'y' }] - t.assert.equal(def(input), '[{"a":1,"b":"x"}]') - t.assert.equal(js(input), '[{"a":1,"b":"x","extra":"y"}]') -}) - -test('json-stringify mechanism follows insertion order; default follows schema property order', (t) => { - t.plan(2) - const schema = { - type: 'array', - items: { - type: 'object', - properties: { - a: { type: 'integer' }, - b: { type: 'string' } - }, - required: ['a', 'b'] - } - } - const def = build(schema, { largeArrayMechanism: 'default' }) - const js = build(schema, { largeArrayMechanism: 'json-stringify', largeArraySize: 1 }) - const input = [{ b: 'x', a: 1 }] - t.assert.equal(def(input), '[{"a":1,"b":"x"}]') - t.assert.equal(js(input), '[{"b":"x","a":1}]') -}) - -test('typed arrays are not accepted as array inputs', (t) => { - t.plan(4) - const stringify = build({ type: 'array', items: { type: 'number' } }) - t.assert.equal(Array.isArray(new Float64Array([1, 2, 3])), false) - t.assert.equal(Array.isArray(new Int32Array([1, 2, 3])), false) - t.assert.throws(() => stringify(new Float64Array([1, 2, 3])), TypeError) - t.assert.throws(() => stringify(new Int32Array([1, 2, 3])), TypeError) -}) diff --git a/test/primitive-array-fastpath.test.js b/test/primitive-array-fastpath.test.js deleted file mode 100644 index e9c6879e..00000000 --- a/test/primitive-array-fastpath.test.js +++ /dev/null @@ -1,227 +0,0 @@ -'use strict' - -const { test } = require('node:test') - -const build = require('..') - -// The primitive-array fast path (whole-array JSON.stringify behind an -// element guard) must be byte-identical to the per-item serializer for -// every input class. These tests pin the exact bytes and errors for the -// edge inputs where the two mechanisms could diverge. - -test('array of numbers: fast path outputs byte-identical JSON', (t) => { - t.plan(8) - const stringify = build({ type: 'array', items: { type: 'number' } }) - - t.assert.equal(stringify([1, 2, 3]), '[1,2,3]') - t.assert.equal(stringify([1.5, -1.5, 0.1]), '[1.5,-1.5,0.1]') - t.assert.equal(stringify([-0]), '[0]') - t.assert.equal(stringify([Infinity, -Infinity]), '[null,null]') - t.assert.equal(stringify([1e21, 1e-7, 5e-324]), '[1e+21,1e-7,5e-324]') - t.assert.equal(stringify([Number.MAX_SAFE_INTEGER, Number.MIN_SAFE_INTEGER]), '[9007199254740991,-9007199254740991]') - t.assert.equal(stringify([]), '[]') - t.assert.equal(stringify(new Array(100).fill(42)), JSON.stringify(new Array(100).fill(42))) -}) - -test('array of numbers: NaN falls back and throws like the serializer', (t) => { - t.plan(3) - const stringify = build({ type: 'array', items: { type: 'number' } }) - - t.assert.throws(() => stringify([1, NaN, 3]), /The value "NaN" cannot be converted to a number\./) - t.assert.throws(() => stringify([1, undefined, 3]), /The value "undefined" cannot be converted to a number\./) - // holes read as undefined and must throw the same way - const holey = [1, 2, 3] - delete holey[1] - t.assert.throws(() => stringify(holey), /The value "undefined" cannot be converted to a number\./) -}) - -test('array of numbers: coercible non-numbers fall back to asNumber', (t) => { - t.plan(5) - const stringify = build({ type: 'array', items: { type: 'number' } }) - - t.assert.equal(stringify(['42']), '[42]') - t.assert.equal(stringify(['1.5']), '[1.5]') - t.assert.equal(stringify([null]), '[0]') - t.assert.equal(stringify([true, false]), '[1,0]') - t.assert.equal(stringify([10n]), '[10]') -}) - -test('array of integers: fast path outputs byte-identical JSON', (t) => { - t.plan(7) - const stringify = build({ type: 'array', items: { type: 'integer' } }) - - t.assert.equal(stringify([1, 2, 3]), '[1,2,3]') - t.assert.equal(stringify([-0]), '[0]') - t.assert.equal(stringify([1e21]), '[1e+21]') - t.assert.equal(stringify([Number.MAX_SAFE_INTEGER]), '[9007199254740991]') - t.assert.equal(stringify([]), '[]') - t.assert.equal(stringify(new Array(100).fill(7)), JSON.stringify(new Array(100).fill(7))) - // bigint items go through asInteger's bigint branch - t.assert.equal(stringify([10n, 9007199254740993n]), '[10,9007199254740993]') -}) - -test('array of integers: non-integers fall back and follow rounding', (t) => { - t.plan(7) - const stringify = build({ type: 'array', items: { type: 'integer' } }) - const ceilify = build({ type: 'array', items: { type: 'integer' } }, { rounding: 'ceil' }) - - t.assert.equal(stringify([1.5]), '[1]') - t.assert.equal(ceilify([1.5]), '[2]') - t.assert.equal(stringify([-1.5]), '[-1]') - t.assert.equal(stringify([1e-7]), '[0]') - t.assert.equal(ceilify([1e-7]), '[1]') - t.assert.throws(() => stringify([NaN]), /The value "NaN" cannot be converted to an integer\./) - t.assert.throws(() => stringify([Infinity]), /The value "Infinity" cannot be converted to an integer\./) -}) - -test('array of booleans: fast path and fallback', (t) => { - t.plan(6) - const stringify = build({ type: 'array', items: { type: 'boolean' } }) - - t.assert.equal(stringify([true, false, true]), '[true,false,true]') - t.assert.equal(stringify([]), '[]') - t.assert.equal(stringify(new Array(100).fill(true)), JSON.stringify(new Array(100).fill(true))) - // truthy/falsy non-booleans fall back to asBoolean coercion - t.assert.equal(stringify([1, 0]), '[true,false]') - t.assert.equal(stringify([null, undefined]), '[false,false]') - t.assert.equal(stringify(['x', '']), '[true,false]') -}) - -test('mixed guard-hit/miss arrays still produce exact baseline output', (t) => { - t.plan(4) - const stringify = build({ type: 'array', items: { type: 'number' } }) - - // guard breaks at index 1; slow loop must still serialize index 0 the same way - t.assert.equal(stringify([1, '2', 3]), '[1,2,3]') - t.assert.equal(stringify([1, 2, '3']), '[1,2,3]') - // objects with valueOf coerce through asNumber, they do not throw - t.assert.equal(stringify([1, 2, { valueOf: () => 42 }]), '[1,2,42]') - t.assert.equal(stringify([{ valueOf: () => 42 }, 2]), '[42,2]') -}) - -test('objects with toJSON in primitive arrays throw like the baseline', (t) => { - t.plan(2) - const numify = build({ type: 'array', items: { type: 'number' } }) - const boolify = build({ type: 'array', items: { type: 'boolean' } }) - - t.assert.throws(() => numify([{ toJSON: () => 42 }]), /The value "\[object Object\]" cannot be converted to a number\./) - // asBoolean coerces via truthiness; an object with toJSON is truthy - t.assert.equal(boolify([{ toJSON: () => false }]), '[true]') -}) - -test('nested primitive arrays inside objects use the fast path too', (t) => { - t.plan(5) - const stringify = build({ - type: 'object', - properties: { - ints: { type: 'array', items: { type: 'integer' } }, - nums: { type: 'array', items: { type: 'number' } }, - bools: { type: 'array', items: { type: 'boolean' } } - } - }) - - t.assert.equal( - stringify({ ints: [1, 2], nums: [1.5, Infinity], bools: [true] }), - '{"ints":[1,2],"nums":[1.5,null],"bools":[true]}' - ) - t.assert.equal(stringify({ ints: [], nums: [], bools: [] }), '{"ints":[],"nums":[],"bools":[]}') - t.assert.throws(() => stringify({ nums: [NaN] }), /The value "NaN" cannot be converted to a number\./) - // 1.5 is not an integer, but trunc-able: falls back and truncates like the baseline - t.assert.equal(stringify({ ints: [1.5] }), '{"ints":[1]}') - // missing properties stay omitted; present one keeps fast-path bytes - t.assert.equal(stringify({ ints: [1, '2'] }), '{"ints":[1,2]}') -}) - -test('large primitive arrays are byte-identical to JSON.stringify', (t) => { - t.plan(9) - const numify = build({ type: 'array', items: { type: 'number' } }) - const integify = build({ type: 'array', items: { type: 'integer' } }) - const boolify = build({ type: 'array', items: { type: 'boolean' } }) - - const sizes = [1e3, 2e4, 1e5] - const nums = [] - const ints = [] - const bools = [] - for (let i = 0; i < 1e5; i++) { - nums.push(i / 7) - ints.push(i - 5e4) - bools.push(i % 2 === 0) - } - for (const size of sizes) { - t.assert.equal(numify(nums.slice(0, size)), JSON.stringify(nums.slice(0, size))) - t.assert.equal(integify(ints.slice(0, size)), JSON.stringify(ints.slice(0, size))) - t.assert.equal(boolify(bools.slice(0, size)), JSON.stringify(bools.slice(0, size))) - } -}) - -test('largeArrayMechanism json-stringify keeps its own behavior', (t) => { - t.plan(3) - const stringify = build({ type: 'array', items: { type: 'number' } }, { largeArrayMechanism: 'json-stringify' }) - - // small arrays go through the per-item path and keep coercing - t.assert.equal(stringify(['42']), '[42]') - // large arrays are unconditionally natively stringified (documented opt-in trade-off) - const big = new Array(2e4).fill(1) - t.assert.equal(stringify(big), JSON.stringify(big)) - t.assert.throws(() => stringify([NaN]), /The value "NaN" cannot be converted to a number\./) -}) - -test('fast path does not apply to typed arrays (still TypeError)', (t) => { - t.plan(2) - const stringify = build({ type: 'array', items: { type: 'number' } }) - - t.assert.throws(() => stringify(new Float64Array([1, 2, 3])), /does not match schema definition\./) - t.assert.throws(() => stringify(new Int32Array([1, 2, 3])), /does not match schema definition\./) -}) - -test('excluded item schemas keep the classic path', (t) => { - t.plan(5) - const nullable = build({ type: 'array', items: { type: 'number', nullable: true } }) - t.assert.equal(nullable([1, null, 2]), '[1,null,2]') - - const typeArray = build({ type: 'array', items: { type: ['number', 'null'] } }) - t.assert.equal(typeArray([1, null, 2]), '[1,null,2]') - - const anyOf = build({ type: 'array', items: { anyOf: [{ type: 'number' }, { type: 'string' }] } }) - t.assert.equal(anyOf([1, 'a']), '[1,"a"]') - - const constItems = build({ type: 'array', items: { type: 'number', const: 42 } }) - t.assert.equal(constItems([42, 42]), '[42,42]') - - const strings = build({ type: 'array', items: { type: 'string' } }) - t.assert.equal(strings(['a', 'b']), '["a","b"]') -}) - -// The copy-then-stringify path only engages above a per-kind length cutoff -// (Node >= 22: number >= 32, integer >= 512; Node 20: both >= 1e5; boolean -// never). Exercise each kind on both sides of its cutoff with a miss in the -// middle, so the prefix/resume code runs for every kind on every runtime. -test('guard misses above and below each kind cutoff match the serializer', (t) => { - const cases = [ - ['number', 32, 1.5, '2', 2], - ['number', 31, 1.5, '2', 2], - ['integer', 512, 7, 1.5, 1], - ['integer', 511, 7, 1.5, 1], - ['integer', 600, 7, 10n, 10], - ['integer', 99999, 7, 1.5, 1], - ['integer', 100000, 7, 10n, 10], - ['number', 100000, 1.5, '2', 2], - ['boolean', 1000, true, 0, false] - ] - t.plan(cases.length * 3) - for (const [type, length, fill, miss, missOut] of cases) { - const stringify = build({ type: 'array', items: { type } }) - const clean = new Array(length).fill(fill) - t.assert.equal(stringify(clean), JSON.stringify(clean), `${type}/${length} clean`) - const mid = clean.slice() - mid[length >> 1] = miss - const expected = clean.slice() - expected[length >> 1] = missOut - t.assert.equal(stringify(mid), JSON.stringify(expected), `${type}/${length} mid miss`) - const last = clean.slice() - last[length - 1] = miss - const expectedLast = clean.slice() - expectedLast[length - 1] = missOut - t.assert.equal(stringify(last), JSON.stringify(expectedLast), `${type}/${length} last miss`) - } -}) diff --git a/test/string-array-fastpath.test.js b/test/string-array-fastpath.test.js deleted file mode 100644 index 5f1312ed..00000000 --- a/test/string-array-fastpath.test.js +++ /dev/null @@ -1,398 +0,0 @@ -'use strict' - -const { test } = require('node:test') - -const build = require('..') -const Serializer = require('../lib/serializer') -const OriginalLocation = require('../lib/location') - -const buildPath = require.resolve('..') -const locationPath = require.resolve('../lib/location') - -function loadBuildWithLocation (Location) { - require.cache[locationPath].exports = Location - delete require.cache[buildPath] - - try { - return require(buildPath) - } finally { - require.cache[locationPath].exports = OriginalLocation - delete require.cache[buildPath] - } -} - -class LocationWithoutSchemaId { - constructor (schema, _schemaId, jsonPointer = '#') { - this.schema = schema - this.schemaId = '' - this.jsonPointer = _schemaId ? _schemaId + jsonPointer : jsonPointer - } - - getPropertyLocation (propertyName) { - return new LocationWithoutSchemaId( - this.schema[propertyName], - '', - this.jsonPointer + '/' + propertyName - ) - } - - getSchemaRef () { - return this.schemaId + this.jsonPointer - } -} - -function stringArray (options) { - return build({ - type: 'array', - items: { type: 'string' } - }, options) -} - -test('asPlainStringArray matches native strings and preserves coercion on misses', (t) => { - const serializer = new Serializer() - const samples = [ - [], - [''], - ['hello'], - ['hello " world', 'a\\b', 'a"b\\c"'], - ['\n\r\t\b\f\u0000\u001f'], - ['A\uD800B', 'A\uDFFFB'], - ['emoji \uD83D\uDE00 end'], - ['a\u2028b\u2029c'], - ['x'.repeat(41), 'x'.repeat(42)], - ['x'.repeat(4999), 'y'.repeat(5000)], - [('c"\\').repeat(2000)] - ] - - for (const sample of samples) { - t.assert.equal(serializer.asPlainStringArray(sample), JSON.stringify(sample)) - } - - const misses = [ - [42], - [1.5], - [null], - [undefined], - [true], - [10n], - [Symbol('s')], - [new Date('2020-01-02T03:04:05.006Z')], - [new Date('invalid')], - [/a"b/], - [{ a: 1 }], - [{ toJSON () { return 'from-toJSON' }, toString () { return 'from-toString' } }], - [Object('boxed')], - [['x']], - ['ok', 1], - ['ok', 'still', null] - ] - - for (const sample of misses) { - const stringify = stringArray({ largeArraySize: 100 }) - let expected - let failure - try { - expected = stringify(sample) - } catch (error) { - failure = error - } - if (failure) { - t.assert.throws(() => serializer.asPlainStringArray(sample), failure.constructor) - } else { - t.assert.equal(serializer.asPlainStringArray(sample), expected) - } - } -}) - -test('asString matches JSON.stringify for string contents', (t) => { - const serializer = new Serializer() - const alphabet = ['a', '"', '\\', '\n', '\u0000', '\u0001', '\u2028', '\u2029', '\uD800', '\uDE00', '😀', ' '] - const lengths = [0, 1, 8, 41, 42, 80, 4999, 5000, 5001] - let seed = 0x12345678 - const next = () => { - seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0 - return seed - } - - for (const length of lengths) { - for (let sample = 0; sample < 15; sample++) { - let value = '' - for (let i = 0; i < length; i++) { - value += alphabet[next() % alphabet.length] - } - t.assert.equal(serializer.asString(value), JSON.stringify(value)) - } - } -}) - -test('large plain string arrays use the native fast path', (t) => { - const stringify = stringArray({ largeArraySize: 3 }) - const input = ['hello "world"', 'a\\b', 'a\u2028b', 'x'.repeat(80)] - input.toJSON = () => ['nope'] - - const original = JSON.stringify - let sawCopy = false - JSON.stringify = function (value, replacer, space) { - if ( - Array.isArray(value) && - value !== input && - value.length === input.length && - value[0] === input[0] && - typeof value.toJSON !== 'function' - ) { - sawCopy = true - } - return original.call(JSON, value, replacer, space) - } - - try { - t.assert.equal(stringify(input), original(input.slice())) - t.assert.equal(sawCopy, true) - } finally { - JSON.stringify = original - } -}) - -test('arrays below largeArraySize keep the per-element path', (t) => { - const stringify = stringArray({ largeArraySize: 4 }) - const input = ['a', 'b', 'c'] - const original = JSON.stringify - let sawArray = false - JSON.stringify = function (value, replacer, space) { - if (Array.isArray(value)) sawArray = true - return original.call(JSON, value, replacer, space) - } - - try { - t.assert.equal(stringify(input), '["a","b","c"]') - t.assert.equal(sawArray, false) - } finally { - JSON.stringify = original - } -}) - -test('fast path reads each element once and ignores array toJSON', (t) => { - const stringify = stringArray({ largeArraySize: 3 }) - let reads = 0 - const input = ['a', 'b', 'c'] - Object.defineProperty(input, 1, { - enumerable: true, - get () { - reads++ - return 'q"q' - } - }) - input.toJSON = () => ['z'] - - t.assert.equal(stringify(input), '["a","q\\"q","c"]') - t.assert.equal(reads, 1) -}) - -test('mixed string arrays keep coercion and thrown errors', (t) => { - const stringify = stringArray({ largeArraySize: 2 }) - const date = new Date('2020-01-02T03:04:05.006Z') - - t.assert.equal(stringify(['a', 42, 'b']), '["a","42","b"]') - t.assert.equal(stringify(['a', 1.5]), '["a","1.5"]') - t.assert.equal(stringify(['a', null, 'b']), '["a","","b"]') - t.assert.equal(stringify([true, false]), '["true","false"]') - t.assert.equal(stringify([10n]), '["10"]') - t.assert.equal(stringify([Symbol('s')]), '["Symbol(s)"]') - t.assert.equal(stringify([date]), `["${date.toISOString()}"]`) - t.assert.equal(stringify([/a"b/]), '["a\\"b"]') - t.assert.equal(stringify([{ a: 1 }]), '["[object Object]"]') - t.assert.equal(stringify([{ - toJSON () { return 'from-toJSON' }, - toString () { return 'from-toString' } - }]), '["from-toString"]') - t.assert.equal(stringify([Object('boxed')]), '["boxed"]') - t.assert.equal(stringify([['x', 'y']]), '["x,y"]') - t.assert.equal(stringify(['only']), '["only"]') - t.assert.throws(() => stringify([undefined]), TypeError) - t.assert.throws(() => stringify(['a', undefined]), TypeError) - t.assert.throws(() => stringify([new Date('invalid')]), RangeError) - t.assert.throws(() => stringify('nope'), TypeError) - t.assert.equal(stringify(null), '[]') - t.assert.equal(stringify([]), '[]') - - const sparse = new Array(3) - sparse[0] = 'a' - t.assert.throws(() => stringify(sparse), TypeError) -}) - -test('special strings in a large array match JSON.stringify', (t) => { - const stringify = stringArray({ largeArraySize: 2 }) - const input = [ - '', - 'plain', - 'hello " world', - 'a\\b', - 'trail\\', - '\n\r\t\u0000', - 'A\uD800B', - 'A\uDFFFB', - 'emoji \uD83D\uDE00', - 'a\u2028b\u2029c', - 'x'.repeat(5000), - ('q"\\').repeat(40) - ] - - t.assert.equal(stringify(input), JSON.stringify(input)) -}) - -test('plain string fast path is not used for other item schemas', (t) => { - const options = { largeArraySize: 2, mode: 'debug' } - const cases = [ - [{ type: 'number' }, [1, 2, 3], '[1,2,3]'], - [{ type: 'string', format: 'unsafe' }, ['a"b', 'c', 'd'], '["a"b","c","d"]'], - [{ type: 'string', nullable: true }, ['a', null, 'b"'], '["a",null,"b\\""]'], - [{ type: 'string', const: 'fixed' }, ['nope', 'also', 'no'], '["fixed","fixed","fixed"]'] - ] - - for (const [items, input, expected] of cases) { - const debug = build({ type: 'array', items }, options) - t.assert.equal(debug.code.includes('asPlainStringArray('), false) - const stringify = build({ type: 'array', items }, { largeArraySize: 2 }) - t.assert.equal(stringify(input), expected) - } - - const plain = build({ type: 'array', items: { type: 'string' } }, options) - t.assert.equal(plain.code.includes('asPlainStringArray('), true) - - const optedIn = build({ type: 'array', items: { type: 'string' } }, { - largeArraySize: 2, - largeArrayMechanism: 'json-stringify', - mode: 'debug' - }) - t.assert.equal(optedIn.code.includes('asPlainStringArray('), false) - const optedInRun = build({ type: 'array', items: { type: 'string' } }, { - largeArraySize: 2, - largeArrayMechanism: 'json-stringify' - }) - t.assert.equal(optedInRun(['a', 1, 'b']), '["a",1,"b"]') -}) - -test('combinators and formatted strings stay on the per-element path', (t) => { - const options = { largeArraySize: 2 } - const schemas = [ - { type: 'string', allOf: [{ type: 'string', minLength: 0 }] }, - { type: 'string', anyOf: [{ type: 'string', minLength: 0 }, { type: 'string', minLength: 1 }] }, - { type: 'string', oneOf: [{ type: 'string', minLength: 0 }] }, - { - type: 'string', - if: { type: 'string', minLength: 0 }, - then: { type: 'string' } - } - ] - - for (const items of schemas) { - const debug = build({ type: 'array', items }, { ...options, mode: 'debug' }) - t.assert.equal(debug.code.includes('asPlainStringArray('), false) - const stringify = build({ type: 'array', items }, options) - t.assert.equal(stringify(['a"b', 'c', 'd']), '["a\\"b","c","d"]') - } - - const dated = build({ - type: 'array', - items: { type: 'string', format: 'date-time' } - }, options) - const date = new Date('2020-01-02T03:04:05.006Z') - t.assert.equal(dated([date, date]), `["${date.toISOString()}","${date.toISOString()}"]`) - t.assert.equal(dated(['already-a-string', 'second-string']), '["already-a-string","second-string"]') -}) - -test('nested string arrays and object properties use the fast path per array', (t) => { - const nested = build({ - type: 'array', - items: { - type: 'array', - items: { type: 'string' } - } - }, { largeArraySize: 2 }) - - t.assert.equal( - nested([['a"b', 'c'], ['d', 'e']]), - '[["a\\"b","c"],["d","e"]]' - ) - t.assert.equal(nested([['a', 1], ['b', 'c']]), '[["a","1"],["b","c"]]') - - const wrapped = build({ - type: 'object', - properties: { - ids: { - type: 'array', - items: { type: 'string' } - } - } - }, { largeArraySize: 2 }) - - t.assert.equal(wrapped({ ids: ['a"b', 'c', 'd'] }), '{"ids":["a\\"b","c","d"]}') - t.assert.equal(wrapped({ ids: ['a', null] }), '{"ids":["a",""]}') -}) - -test('$ref to a plain string schema can fast-path and unsafe refs cannot', (t) => { - const plain = build({ - $id: 'root', - type: 'array', - items: { $ref: 'str#' } - }, { - largeArraySize: 2, - schema: { - str: { $id: 'str', type: 'string' } - } - }) - t.assert.equal(plain(['a"b', 'c', 'd']), '["a\\"b","c","d"]') - - const unsafe = build({ - $id: 'root-unsafe', - type: 'array', - items: { $ref: 'unsafe#' } - }, { - largeArraySize: 2, - mode: 'debug', - schema: { - unsafe: { $id: 'unsafe', type: 'string', format: 'unsafe' } - } - }) - t.assert.equal(unsafe.code.includes('asPlainStringArray('), false) - const unsafeRun = build({ - $id: 'root-unsafe-run', - type: 'array', - items: { $ref: 'unsafe#' } - }, { - largeArraySize: 2, - schema: { - unsafe: { $id: 'unsafe', type: 'string', format: 'unsafe' } - } - }) - t.assert.equal(unsafeRun(['a"b', 'c']), '["a"b","c"]') -}) - -test('inlined string arrays without a schema id keep both paths', (t) => { - const inlineBuild = loadBuildWithLocation(LocationWithoutSchemaId) - const stringify = inlineBuild({ - type: 'array', - items: { type: 'string' } - }, { largeArraySize: 2 }) - const debug = inlineBuild({ - type: 'array', - items: { type: 'string' } - }, { largeArraySize: 2, mode: 'debug' }) - - t.assert.equal(debug.code.includes('asPlainStringArray('), true) - t.assert.equal(stringify(['a"b', 'c', 'd']), '["a\\"b","c","d"]') - t.assert.equal(stringify(['a', 2]), '["a","2"]') - t.assert.equal(stringify(['only']), '["only"]') - - let reads = 0 - const input = ['a', 'b', 'c'] - Object.defineProperty(input, 0, { - enumerable: true, - get () { - reads++ - return 'z"z' - } - }) - t.assert.equal(stringify(input), '["z\\"z","b","c"]') - t.assert.equal(reads, 1) -}) From b4e1f275c0fca5243c17c935fc5352038b58bf5a Mon Sep 17 00:00:00 2001 From: DanielMcSheehy Date: Sun, 18 Oct 2026 07:59:32 -0400 Subject: [PATCH 4/6] test: cover the number/integer fast-path fallback The copy-then-resume path lives in generated code, so coverage does not see it. Exercise a non-conforming element at the start, middle and end of a large array for both integer and number, plus rounding, NaN and read-once getters across the fallback. --- test/array.test.js | 67 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/test/array.test.js b/test/array.test.js index 47fec1d6..92c64c0a 100644 --- a/test/array.test.js +++ b/test/array.test.js @@ -754,3 +754,70 @@ test('string array coerces a non-string item', (t) => { '["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 NaN 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.')) +}) + +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]) +}) From a8618e6af1da30f3036cc3574607cdb29524f43f Mon Sep 17 00:00:00 2001 From: DanielMcSheehy Date: Sun, 18 Oct 2026 08:24:59 -0400 Subject: [PATCH 5/6] perf: raise the number cutoff to 128 and Node 20 to 3e5; document the cutoffs Measured against main on GitHub Actions x64 runners for Node 20, 22, 24 and 26 (8 alternating rounds, median ops/s). Integer-valued elements under a number schema were below break-even on Node 26 until ~100 (-25% at 32), so numbers now wait for 128. Node 20 at 1e5 depended on the runner, while 3e5 is +200% on every runner, so Node 20 waits for 3e5. Integers stay at 512. No row on any runner is below noise with these cutoffs. Document the cutoffs, the fallback behaviour and the standalone caveat in the README, and leave a TODO to drop the Node 20 tier with Node 20. --- README.md | 46 +++++++++++++++++++++++++++++++++++++++++----- index.js | 36 ++++++++++++++++++++++-------------- 2 files changed, 63 insertions(+), 19 deletions(-) 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/index.js b/index.js index 2f9c6c09..ebaf9d6b 100644 --- a/index.js +++ b/index.js @@ -14,17 +14,25 @@ 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 -// with the output consumed (flattened) as a socket write would, in CPU-pinned -// containers on Node 20/22/24. On V8 >= 12.4 (Node 22) numbers win from -// ~100 elements because native float formatting beats per-item ToString, -// and integers from ~512 because small-int ToString is cached. Node 20's -// JSON.stringify lacks the fast integer-array path and loses 15-25% up to -// ~1e5 elements for integers and integer-valued numbers, so both kinds wait -// for 1e5 there. Booleans never win and keep the loop. Array lookup instead -// of a ternary keeps this fully covered on every Node. +// 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: 1e5, number: 1e5 }, { integer: 512, number: 32 }][+(nodeMajor >= 22)] +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 } @@ -932,14 +940,14 @@ function getPrimitiveArrayFastPathCondition (itemsSchema) { switch (itemsSchema.type) { case 'number': // asNumber(): NaN throws, Infinity serializes as null, every other - // number renders via ToString, exactly like JSON.stringify. Native - // float formatting wins from ~100 elements on Node 20/22/24. + // 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 copy only wins from ~500 - // elements, and only on V8 >= 12.4 (see INTEGER_ARRAY_FAST_PATH). + // 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 From 3c60b082402c86402f5e83e839bd7ee36d712265 Mon Sep 17 00:00:00 2001 From: DanielMcSheehy Date: Sun, 18 Oct 2026 13:47:12 -0400 Subject: [PATCH 6/6] test: cover a real NaN element in a large number array NaN is typeof 'number', so only the v === v half of the fast-path condition rejects it; the existing case used a non-numeric string and never reached that branch. Check NaN at the start, middle and end. --- test/array.test.js | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/test/array.test.js b/test/array.test.js index 92c64c0a..2c8d3393 100644 --- a/test/array.test.js +++ b/test/array.test.js @@ -797,7 +797,7 @@ test('large integer array applies the rounding option after the fallback', (t) = t.assert.equal(output.slice(-3), ',3]') }) -test('large number array still throws on NaN after the fallback', (t) => { +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' @@ -805,6 +805,18 @@ test('large number array still throws on NaN after the fallback', (t) => { 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)