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