From 611b0dd7de6e8fa030ff782d3e6431c86d29f138 Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Tue, 22 Sep 2026 10:03:11 +0200 Subject: [PATCH] perf: project array items and let V8 stringify them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit V8 13.8 (Node.js 25) added a fast path to JSON.stringify that outruns the string concatenation we generate. It only applies to plain fast-mode objects with no accessors, no toJSON and no Date values, so a user's own objects rarely qualify — measured on Node 26, JSON.stringify drops from 6.7M ops/cpu-s on a plain object to 1.4M with a getter, 1.7M on a null-prototype object and 2.2M on a class with toJSON. But the objects we could build from them always qualify. So on a supporting V8, arrays of two or more items are now serialized by projecting each item into a new object holding exactly the schema's properties, already coerced, and handing the resulting array to JSON.stringify. Schema filtering and coercion are unchanged, and the concatenation path takes over for anything the projection does not model: anyOf/oneOf/allOf/if, $ref, patternProperties, additionalProperties, const, default, tuples, unsafe strings, integer-like and __proto__ keys, objects nested more than two levels deep, and BigInt values, which are detected at runtime and abandon the projection. Paired CPU-time measurements on Node 26, median of 11 alternating rounds: array of 4 objects 1.51x array of 1k numbers 2.03x array of 32 objects 1.65x array of 1k strings 2.37x array of 1k objects 1.80x array of 1k nested 1.08x array of 20k objects 1.83x single object 1.01x Against JSON.stringify itself, the 20k-object array goes from 0.42x to 0.79x and the 1k-object array from 0.53x to 0.89x; the rest of the remaining gap is the schema filtering JSON.stringify does not do. Node 24 and older generate byte-identical code to before. A differential test over 189090 schema/value/length combinations confirms the two paths agree byte-for-byte, including which errors are thrown. The detection reads process.versions.v8 and can be overridden with the new `arrayProjection` option, which also lets the tests exercise both paths on every runtime. --- README.md | 22 ++ index.js | 435 ++++++++++++++++++++++ test/array-projection.test.js | 491 +++++++++++++++++++++++++ test/code-generation-fallbacks.test.js | 6 +- types/index.d.ts | 7 + types/index.tst.ts | 5 + 6 files changed, 965 insertions(+), 1 deletion(-) create mode 100644 test/array-projection.test.js diff --git a/README.md b/README.md index dd042d73..61015585 100644 --- a/README.md +++ b/README.md @@ -135,6 +135,8 @@ are rejected before compilation. - `inlineValidators`: when using standalone mode, embed Ajv-generated validator functions in the output instead of compiling schemas at runtime. [More details](#standalone) - `largeArrayMechanism`: set the mechanism that should be used to handle large (by default `20000` or more items) arrays. [More details](#largearrays) +- `arrayProjection`: override the automatic detection of V8's fast +`JSON.stringify` path for arrays. [More details](#arrayprojection) - `compileValidators`: when `true`, the `ajv` validators used by `anyOf`, `oneOf` and `if/then/else` are compiled during `build()` instead of lazily, on the first serialization that reaches them. This makes `build()` slower but removes a potentially large one-off cost @@ -642,6 +644,26 @@ integer-like values, such as: - `'2e4'` - _note this will be converted to `2`, not `20000`_ - `1.5` - _note this will be converted to `1`_ + +#### Array Projection + +V8 13.8 (Node.js 25) added a fast path to `JSON.stringify` that outruns the +string concatenation `fast-json-stringify` generates. It only applies to plain +objects with no accessors, no `toJSON` and no `Date` values, so the user's own +objects rarely qualify — but the objects `fast-json-stringify` *could* build +from them always do. + +On a supporting V8, arrays of two or more items are therefore serialized by +projecting each item into a new object holding exactly the schema's properties, +already coerced, and handing the resulting array to `JSON.stringify`. The output +is identical, schema filtering and coercion still apply, and the concatenation +path takes over for anything the projection does not model (`anyOf`, `$ref`, +`patternProperties`, tuples, deeply nested objects, `BigInt` values, ...). + +The behaviour is detected from `process.versions.v8`. Set `arrayProjection` to +`false` to always concatenate, or to `true` to project regardless of the running +V8 — both produce the same output, so the option only affects speed. + #### 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. diff --git a/index.js b/index.js index 490c9a85..f70fe568 100644 --- a/index.js +++ b/index.js @@ -337,6 +337,13 @@ function build (schema, options) { recursiveSchemas: new Set(), recursivePaths: new Set(), buildingSet: new Set(), + projectionFunctions: [], + projectionFnsBySchema: new Map(), + projectionCounter: 0, + usesProjection: false, + arrayProjection: options.arrayProjection === undefined + ? V8_HAS_FAST_STRINGIFY + : options.arrayProjection, uid: 0 } @@ -364,6 +371,10 @@ function build (schema, options) { } } + if (options.arrayProjection !== undefined && typeof options.arrayProjection !== 'boolean') { + throw new Error(`Unsupported array projection option ${options.arrayProjection}`) + } + if (options.largeArrayMechanism) { if (validLargeArrayMechanisms.has(options.largeArrayMechanism)) { largeArrayMechanism = options.largeArrayMechanism @@ -406,6 +417,10 @@ function build (schema, options) { const JSON_STR_NULL = 'null' ` + if (context.usesProjection) { + contextFunctionCode += projectionFns + context.projectionFunctions.join('\n') + } + // If we have only the invocation of the 'anonymous0' function, we would // basically just wrap the 'anonymous0' function in the 'main' function and // and the overhead of the intermediate variable 'json'. We can avoid the @@ -859,6 +874,16 @@ function buildArray (context, location, input) { const jsonPointer = location.jsonPointer || '' const fullPath = `${schemaId}#${jsonPointer}` + // On a V8 with the fast JSON.stringify path, projecting the items into plain + // fast-mode objects and handing the result to JSON.stringify beats building + // the string ourselves, once the array is long enough to amortise the + // allocations. Shorter arrays fall through to the concatenation below. + const itemProjection = context.arrayProjection && + largeArrayMechanism === 'default' && + !Array.isArray(itemsSchema) + ? buildArrayItemProjection(context, itemsLocation) + : null + if (context.recursivePaths.has(fullPath) || context.buildingSet.has(schema) || schemaId !== '') { const functionName = generateFuncName(context) context.functionsNamesBySchema.set(schema, functionName) @@ -879,6 +904,24 @@ function buildArray (context, location, input) { const arrayLength = obj.length ` + if (itemProjection !== null) { + functionCode += ` + if (arrayLength >= ${PROJECTION_MIN_ARRAY_LENGTH}) { + projectionBailed = false + const projected = new Array(arrayLength) + for (let i = 0; i < arrayLength; i++) { + const value = obj[i] + let ${itemProjection.outVar} + ${itemProjection.code} + projected[i] = ${itemProjection.outVar} + } + if (projectionBailed === false) { + return JSON.stringify(projected) + } + } + ` + } + if (!schema.additionalItems && Array.isArray(itemsSchema)) { functionCode += ` if (arrayLength > ${itemsSchema.length}) { @@ -976,6 +1019,29 @@ function buildArray (context, location, input) { inlinedCode += `if (arrayLength_${objVar} >= ${largeArraySize}) json += JSON.stringify(${objVar})\n else {` } + let projectedFlag = null + if (itemProjection !== null) { + projectedFlag = `projected_${context.uid++}` + inlinedCode += ` + let ${projectedFlag} = false + if (arrayLength_${objVar} >= ${PROJECTION_MIN_ARRAY_LENGTH}) { + projectionBailed = false + const projection_${projectedFlag} = new Array(arrayLength_${objVar}) + for (let i = 0; i < arrayLength_${objVar}; i++) { + const value = ${objVar}[i] + let ${itemProjection.outVar} + ${itemProjection.code} + projection_${projectedFlag}[i] = ${itemProjection.outVar} + } + if (projectionBailed === false) { + json += JSON.stringify(projection_${projectedFlag}) + ${projectedFlag} = true + } + } + if (${projectedFlag} === false) { + ` + } + inlinedCode += ` json += JSON_STR_BEGIN_ARRAY ` @@ -1030,6 +1096,10 @@ function buildArray (context, location, input) { json += JSON_STR_END_ARRAY ` + if (projectedFlag !== null) { + inlinedCode += '}' + } + if (largeArrayMechanism === 'json-stringify') { inlinedCode += '}' } @@ -1039,6 +1109,371 @@ function buildArray (context, location, input) { return inlinedCode } +/* --------------------------------------------------------------------------- + * Array projection fast path. + * + * V8 >= 13.8 ships a fast path for JSON.stringify that is faster than the + * string concatenation we generate, but it only applies to "simple" values: + * fast-mode objects with no accessors, no toJSON, no Dates, no prototype + * surprises. Rather than hand the user's object to JSON.stringify (which would + * lose schema filtering and coercion, and would usually miss the fast path + * anyway), we generate a *projection* function: it builds a brand new object + * literal holding exactly the schema's properties, already coerced. That object + * is fast-mode by construction, so JSON.stringify takes its fast path. + * + * This only pays off when the projection cost is amortised over many values, so + * it is applied to arrays above a length threshold only. + * ------------------------------------------------------------------------- */ + +/* c8 ignore start - depends on the V8 the tests happen to run on */ +const V8_HAS_FAST_STRINGIFY = (() => { + const parts = process.versions.v8.split('.') + const major = Number(parts[0]) + const minor = Number(parts[1]) + return major > 13 || (major === 13 && minor >= 8) +})() +/* c8 ignore stop */ + +// A single-element array is faster to concatenate than to project. +const PROJECTION_MIN_ARRAY_LENGTH = 2 + +// Each level of nesting allocates another object per item. Past a shallow +// depth the allocation and GC cost outweighs the faster JSON.stringify. +const PROJECTION_MAX_DEPTH = 2 + +const ARRAY_INDEX_KEY = /^(?:0|[1-9]\d*)$/ + +// Keywords that change what is emitted in ways the projection does not model. +function isProjectionBlocked (schema) { + return schema.$ref !== undefined || + schema.allOf !== undefined || + schema.anyOf !== undefined || + schema.oneOf !== undefined || + schema.if !== undefined || + schema.not !== undefined || + schema.const !== undefined || + schema.default !== undefined || + schema.patternProperties !== undefined +} + +function projectionTypeGuard (type, input) { + switch (type) { + case 'string': + return `typeof ${input} === "string" || + ${input} === null || + ${input} instanceof Date || + ${input} instanceof RegExp || + ( + typeof ${input} === "object" && + typeof ${input}.toString === "function" && + ${input}.toString !== Object.prototype.toString + )` + case 'array': + return `Array.isArray(${input})` + case 'integer': + return `Number.isInteger(${input}) || ${input} === null` + case 'object': + return `(typeof ${input} === "object" && !Array.isArray(${input})) || ${input} === null` + default: + return `typeof ${input} === "${type}" || ${input} === null` + } +} + +// Emits statements assigning the projected value of `input` to `out`. +// Returns null when this schema cannot be projected, in which case the caller +// falls back to the concatenation codegen. +function buildProjectionValue (context, location, input, out, depth) { + const schema = location.schema + + if (schema === null || typeof schema !== 'object' || Array.isArray(schema)) return null + if (isProjectionBlocked(schema)) return null + + let type = schema.type + if (type === undefined) { + type = inferTypeByKeyword(schema) + if (!type) return null + } + + if (Array.isArray(type)) { + // Only `[T, 'null']` is modelled; anything wider needs the full runtime + // dispatch that buildMultiTypeSerializer generates. + if (type.length !== 2 || !type.includes('null')) return null + const innerType = type.find((t) => t !== 'null') + + const inner = buildProjectionTyped(context, location, input, out, innerType, depth) + if (inner === null) return null + + return ` + if (${input} === null) { + ${out} = null + } else if (${projectionTypeGuard(innerType, input)}) { + ${inner} + } else { + throw new TypeError(\`The value of '${getSafeSchemaRef(context, location)}' does not match schema definition.\`) + } + ` + } + + const inner = buildProjectionTyped(context, location, input, out, type, depth) + if (inner === null) return null + + if (schema.nullable === true) { + return ` + if (${input} === null) { + ${out} = null + } else { + ${inner} + } + ` + } + return inner +} + +function buildProjectionTyped (context, location, input, out, type, depth) { + const schema = location.schema + + switch (type) { + case 'null': + return `${out} = null` + case 'boolean': + return `${out} = projectBoolean(${input})` + case 'integer': + return `${out} = projectInteger(${input})` + case 'number': + return `${out} = projectNumber(${input})` + case 'string': + switch (schema.format) { + case undefined: + return `${out} = projectString(${input})` + case 'date-time': + return `${out} = projectDateTime(${input})` + case 'date': + return `${out} = projectDate(${input})` + case 'time': + return `${out} = projectTime(${input})` + // 'unsafe' emits the string without escaping, which JSON.stringify + // would not reproduce. + default: + return null + } + case 'object': { + const fnName = buildObjectProjectionFunction(context, location, depth + 1) + if (fnName === null) return null + return `${out} = ${fnName}(${input})` + } + case 'array': { + const fnName = buildArrayProjectionFunction(context, location, depth + 1) + if (fnName === null) return null + return `${out} = ${fnName}(${input})` + } + /* c8 ignore next 2 - isValidSchema() has already rejected any other type */ + default: + return null + } +} + +function buildObjectProjectionFunction (context, location, depth) { + const schema = location.schema + + if (context.projectionFnsBySchema.has(schema)) { + return context.projectionFnsBySchema.get(schema) + } + // A schema reachable from itself would need a recursive projection; the + // concatenation path already handles those. + if (schema.additionalProperties) return null + if (depth > PROJECTION_MAX_DEPTH) return null + + const properties = schema.properties || {} + const requiredProperties = schema.required || [] + const propertiesKeys = Object.keys(properties) + + for (const key of propertiesKeys) { + // Integer-like keys are reordered by the JS object itself, and `__proto__` + // in an object literal sets the prototype instead of a property. + if (ARRAY_INDEX_KEY.test(key) || key === '__proto__') return null + } + for (const key of requiredProperties) { + if (!propertiesKeys.includes(key)) return null + } + + // Mirror buildInnerObject: required properties are emitted first. + const sortedKeys = propertiesKeys.slice().sort((key1, key2) => { + const required1 = requiredProperties.includes(key1) + const required2 = requiredProperties.includes(key2) + return required1 === required2 ? 0 : required1 ? -1 : 1 + }) + + const propertiesLocation = location.getPropertyLocation('properties') + let body = '' + const entries = [] + + for (const key of sortedKeys) { + const propertyLocation = propertiesLocation.getPropertyLocation(key) + const sanitizedKey = JSON.stringify(key) + const valueVar = `pv_${context.uid++}` + const outVar = `po_${context.uid++}` + + const valueCode = buildProjectionValue(context, propertyLocation, valueVar, outVar, depth) + if (valueCode === null) return null + + body += ` + const ${valueVar} = obj[${sanitizedKey}] + let ${outVar} + if (${valueVar} === undefined) { + ${requiredProperties.includes(key) + ? `throw new Error('${sanitizedKey.replace(/'/g, '\\\'')} is required!')` + : ''} + } else { + ${valueCode} + } + ` + entries.push(`${sanitizedKey}: ${outVar}`) + } + + const functionName = `projectObject_${context.projectionCounter++}` + context.projectionFnsBySchema.set(schema, functionName) + + const nullResult = schema.nullable === true ? 'null' : '{}' + + context.projectionFunctions.push(` + function ${functionName} (input) { + const obj = ${toJSON('input')} + if (obj === null) return ${nullResult} + ${body} + return { ${entries.join(',\n')} } + } + `) + + return functionName +} + +function buildArrayProjectionFunction (context, location, depth) { + const schema = location.schema + + if (context.projectionFnsBySchema.has(schema)) { + return context.projectionFnsBySchema.get(schema) + } + // Tuple `items` and `additionalItems` are not modelled. + if (Array.isArray(schema.items) || schema.additionalItems !== undefined) return null + + const itemsLocation = location.getPropertyLocation('items') + if (itemsLocation.schema === undefined) return null + if (itemsLocation.schema.$ref) return null + + const outVar = `po_${context.uid++}` + const itemCode = buildProjectionValue(context, itemsLocation, 'value', outVar, depth) + if (itemCode === null) return null + + const functionName = `projectArray_${context.projectionCounter++}` + context.projectionFnsBySchema.set(schema, functionName) + + const nullResult = schema.nullable === true ? 'null' : '[]' + + context.projectionFunctions.push(` + function ${functionName} (obj) { + if (obj === null) return ${nullResult} + if (!Array.isArray(obj)) { + throw new TypeError(\`The value of '${getSafeSchemaRef(context, location)}' does not match schema definition.\`) + } + const arrayLength = obj.length + const projected = new Array(arrayLength) + for (let i = 0; i < arrayLength; i++) { + const value = obj[i] + let ${outVar} + ${itemCode} + projected[i] = ${outVar} + } + return projected + } + `) + + return functionName +} + +const projectionFns = ` +let projectionBailed = false + +function projectBoolean (value) { + return value && true || false // eslint-disable-line +} + +function projectInteger (value) { + if (Number.isInteger(value)) return value + // JSON.stringify throws on BigInt, so the whole projection is abandoned and + // the concatenation path (which prints it verbatim) takes over. + if (typeof value === 'bigint') { + projectionBailed = true + return 0 + } + const integer = serializer.parseInteger(value) + // eslint-disable-next-line no-self-compare + if (integer === Infinity || integer === -Infinity || integer !== integer) { + throw new Error(\`The value "\${value}" cannot be converted to an integer.\`) + } + return integer +} + +function projectNumber (value) { + const num = Number(value) + // eslint-disable-next-line no-self-compare + if (num !== num) { + throw new Error(\`The value "\${value}" cannot be converted to a number.\`) + } + return num +} + +function projectString (value) { + if (typeof value === 'string') return value + if (value === null) return '' + if (value instanceof Date) return value.toISOString() + if (value instanceof RegExp) return value.source + return value.toString() +} + +function projectDateTime (value) { + if (value === null) return '' + if (value instanceof Date) return value.toISOString() + if (typeof value === 'string') return value + throw new Error(\`The value "\${value}" cannot be converted to a date-time.\`) +} + +function projectDate (value) { + if (value === null) return '' + if (value instanceof Date) return new Date(value.getTime() - (value.getTimezoneOffset() * 60000)).toISOString().slice(0, 10) + if (typeof value === 'string') return value + throw new Error(\`The value "\${value}" cannot be converted to a date.\`) +} + +function projectTime (value) { + if (value === null) return '' + if (value instanceof Date) return new Date(value.getTime() - (value.getTimezoneOffset() * 60000)).toISOString().slice(11, 19) + if (typeof value === 'string') return value + throw new Error(\`The value "\${value}" cannot be converted to a time.\`) +} +` + +// Builds the per-item projection for an array. Returns `{ code, outVar }`, or +// null when the item schema is not projectable — the partially built +// projection functions are rolled back in that case. +function buildArrayItemProjection (context, itemsLocation) { + const savedFunctions = context.projectionFunctions.length + const savedCounter = context.projectionCounter + const savedFns = [...context.projectionFnsBySchema.entries()] + + const outVar = `po_${context.uid++}` + const code = buildProjectionValue(context, itemsLocation, 'value', outVar, 0) + + if (code === null) { + context.projectionFunctions.length = savedFunctions + context.projectionCounter = savedCounter + context.projectionFnsBySchema = new Map(savedFns) + return null + } + + context.usesProjection = true + return { code, outVar } +} + function buildArrayTypeCondition (type, accessor) { let condition switch (type) { diff --git a/test/array-projection.test.js b/test/array-projection.test.js new file mode 100644 index 00000000..ac1109da --- /dev/null +++ b/test/array-projection.test.js @@ -0,0 +1,491 @@ +'use strict' + +// The projection fast path is enabled by default only on V8 >= 13.8. These +// tests force it on so the same assertions run on every supported runtime; the +// output must be identical either way. + +const { test } = require('node:test') + +const buildDefault = require('..') + +const build = (schema, options) => buildDefault(schema, { ...options, arrayProjection: true }) + +const itemSchema = { + type: 'object', + properties: { + firstName: { type: 'string' }, + lastName: { type: ['string', 'null'] }, + age: { type: 'integer' } + } +} + +test('projected arrays drop properties outside the schema', (t) => { + t.plan(1) + + const stringify = build({ type: 'array', items: itemSchema }) + const input = new Array(8).fill({ firstName: 'Matteo', lastName: 'Collina', age: 32, secret: 'nope' }) + + t.assert.equal( + stringify(input), + '[' + new Array(8).fill('{"firstName":"Matteo","lastName":"Collina","age":32}').join(',') + ']' + ) +}) + +test('projected arrays keep the schema property order, required first', (t) => { + t.plan(1) + + const stringify = build({ + type: 'array', + items: { + type: 'object', + properties: { a: { type: 'string' }, b: { type: 'string' }, c: { type: 'string' } }, + required: ['c'] + } + }) + + t.assert.equal(stringify([{ a: '1', b: '2', c: '3' }, { a: '4', b: '5', c: '6' }]), '[{"c":"3","a":"1","b":"2"},{"c":"6","a":"4","b":"5"}]') +}) + +test('projected arrays omit undefined optional properties', (t) => { + t.plan(1) + + const stringify = build({ type: 'array', items: itemSchema }) + + t.assert.equal(stringify([{ firstName: 'Matteo' }, { age: 32 }]), '[{"firstName":"Matteo"},{"age":32}]') +}) + +test('projected arrays still throw on a missing required property', (t) => { + t.plan(1) + + const stringify = build({ + type: 'array', + items: { type: 'object', properties: { a: { type: 'string' } }, required: ['a'] } + }) + + t.assert.throws(() => stringify([{ a: 'ok' }, {}, { a: 'ok' }]), /"a" is required!/) +}) + +test('a bigint anywhere in the array falls back to the concatenation path', (t) => { + t.plan(2) + + const stringify = build({ type: 'array', items: { type: 'object', properties: { id: { type: 'integer' } } } }) + + t.assert.equal(stringify([{ id: 1 }, { id: 2 }]), '[{"id":1},{"id":2}]') + t.assert.equal(stringify([{ id: 1 }, { id: 9007199254740993n }]), '[{"id":1},{"id":9007199254740993}]') +}) + +test('projected arrays honour toJSON on the items', (t) => { + t.plan(1) + + const stringify = build({ type: 'array', items: { type: 'object', properties: { a: { type: 'string' } } } }) + + class Item { + toJSON () { return { a: 'fromToJSON' } } + } + + t.assert.equal(stringify([new Item(), new Item()]), '[{"a":"fromToJSON"},{"a":"fromToJSON"}]') +}) + +test('projected arrays escape strings the same way', (t) => { + t.plan(1) + + const stringify = build({ type: 'array', items: { type: 'string' } }) + const input = ['quote " here', 'back\\slash', 'ctrlchar', '\ud800lone surrogate', 'plain'] + + t.assert.equal(stringify(input), JSON.stringify(input)) +}) + +test('projected arrays coerce like the concatenation path', (t) => { + t.plan(1) + + const stringify = build({ + type: 'array', + items: { + type: 'object', + properties: { + s: { type: 'string' }, + i: { type: 'integer' }, + n: { type: 'number' }, + b: { type: 'boolean' } + } + } + }) + + const input = new Array(4).fill({ s: 42, i: 3.7, n: '2.5', b: 'truthy' }) + + t.assert.equal( + stringify(input), + '[' + new Array(4).fill('{"s":"42","i":3,"n":2.5,"b":true}').join(',') + ']' + ) +}) + +test('projected arrays render dates through the schema format', (t) => { + t.plan(1) + + const stringify = build({ + type: 'array', + items: { type: 'object', properties: { at: { type: 'string', format: 'date-time' } } } + }) + + const at = new Date('2020-01-02T03:04:05.678Z') + + t.assert.equal(stringify([{ at }, { at }]), '[{"at":"2020-01-02T03:04:05.678Z"},{"at":"2020-01-02T03:04:05.678Z"}]') +}) + +test('nullable items and null property values are preserved', (t) => { + t.plan(1) + + const stringify = build({ + type: 'array', + items: { + type: 'object', + nullable: true, + properties: { a: { type: 'string', nullable: true }, b: { type: ['integer', 'null'] } } + } + }) + + t.assert.equal(stringify([null, { a: null, b: null }, { a: 'x', b: 1 }]), '[null,{"a":null,"b":null},{"a":"x","b":1}]') +}) + +test('integer-like property names keep their schema order', (t) => { + t.plan(1) + + // "b" is required so it is serialized first, but an object literal would + // hoist the integer-like "1" ahead of it. This schema must not be projected. + const stringify = build({ + type: 'array', + items: { type: 'object', properties: { b: { type: 'string' }, 1: { type: 'string' } }, required: ['b'] } + }) + + t.assert.equal(stringify([{ b: 'bee', 1: 'one' }, { b: 'bee', 1: 'one' }]), '[{"b":"bee","1":"one"},{"b":"bee","1":"one"}]') +}) + +test('additionalProperties are still serialized', (t) => { + t.plan(1) + + const stringify = build({ + type: 'array', + items: { type: 'object', properties: { a: { type: 'string' } }, additionalProperties: true } + }) + + t.assert.equal(stringify([{ a: 'x', extra: 1 }, { a: 'y', extra: 2 }]), '[{"a":"x","extra":1},{"a":"y","extra":2}]') +}) + +test('arrays nested inside projected items are projected too', (t) => { + t.plan(1) + + const stringify = build({ + type: 'array', + items: { type: 'object', properties: { tags: { type: 'array', items: { type: 'string' } } } } + }) + + t.assert.equal(stringify([{ tags: ['a', 'b'] }, { tags: [] }]), '[{"tags":["a","b"]},{"tags":[]}]') +}) + +test('short arrays take the concatenation path and agree with long ones', (t) => { + t.plan(4) + + const stringify = build({ type: 'array', items: itemSchema }) + const row = { firstName: 'Matteo', lastName: null, age: 32 } + const expected = '{"firstName":"Matteo","lastName":null,"age":32}' + + for (const length of [0, 1, 2, 16]) { + t.assert.equal(stringify(new Array(length).fill(row)), '[' + new Array(length).fill(expected).join(',') + ']') + } +}) + +test('union types with null are projected', (t) => { + t.plan(2) + + const stringify = build({ + type: 'array', + items: { + type: 'object', + properties: { + o: { type: ['object', 'null'], properties: { a: { type: 'string' } } }, + l: { type: ['array', 'null'], items: { type: 'integer' } }, + b: { type: ['boolean', 'null'] }, + n: { type: ['number', 'null'] } + } + } + }) + + t.assert.equal(stringify([{ o: { a: 'x' }, l: [1, 2], b: true, n: 1.5 }, { o: null, l: null, b: null, n: null }]), + '[{"o":{"a":"x"},"l":[1,2],"b":true,"n":1.5},{"o":null,"l":null,"b":null,"n":null}]') + + t.assert.throws(() => stringify([{ b: 'not a boolean' }, {}]), /does not match schema definition/) +}) + +test('a union whose non-null branch is not projectable declines', (t) => { + t.plan(1) + + const stringify = build({ + type: 'array', + items: { + type: 'object', + properties: { + o: { type: ['object', 'null'], properties: { a: { type: 'string' } }, additionalProperties: true } + } + } + }) + + t.assert.equal(stringify([{ o: { a: 'one', x: 2 } }, { o: null }]), '[{"o":{"a":"one","x":2}},{"o":null}]') +}) + +test('the unsafe string format declines projection', (t) => { + t.plan(1) + + const stringify = build({ + type: 'array', + items: { type: 'object', properties: { a: { type: 'string', format: 'unsafe' } } } + }) + + t.assert.equal(stringify([{ a: 'no "escaping"' }, { a: 'x' }]), '[{"a":"no "escaping""},{"a":"x"}]') +}) + +test('a schema object reused for two properties is projected once', (t) => { + t.plan(1) + + const shared = { type: 'object', properties: { v: { type: 'string' } } } + const sharedList = { type: 'array', items: { type: 'integer' } } + const stringify = build({ + type: 'array', + items: { type: 'object', properties: { a: shared, b: shared, c: sharedList, d: sharedList } } + }) + + t.assert.equal( + stringify([{ a: { v: '1' }, b: { v: '2' }, c: [1], d: [2] }, { a: { v: '3' }, b: { v: '4' }, c: [3], d: [4] }]), + '[{"a":{"v":"1"},"b":{"v":"2"},"c":[1],"d":[2]},{"a":{"v":"3"},"b":{"v":"4"},"c":[3],"d":[4]}]' + ) +}) + +test('an item object without properties projects to an empty object', (t) => { + t.plan(1) + + const stringify = build({ type: 'array', items: { type: 'object' } }) + + t.assert.equal(stringify([{ a: 1 }, { b: 2 }]), '[{},{}]') +}) + +test('a required property missing from properties declines projection', (t) => { + t.plan(1) + + const stringify = build({ + type: 'array', + items: { type: 'object', properties: { a: { type: 'string' } }, required: ['b'] } + }) + + t.assert.throws(() => stringify([{ a: 'x' }, { a: 'y' }]), /"b" is required!/) +}) + +test('mixed required and optional properties keep required first', (t) => { + t.plan(1) + + const stringify = build({ + type: 'array', + items: { + type: 'object', + properties: { a: { type: 'string' }, b: { type: 'string' }, c: { type: 'string' }, d: { type: 'string' } }, + required: ['c', 'd'] + } + }) + + t.assert.equal(stringify([{ a: '1', b: '2', c: '3', d: '4' }, { a: '5', b: '6', c: '7', d: '8' }]), + '[{"c":"3","d":"4","a":"1","b":"2"},{"c":"7","d":"8","a":"5","b":"6"}]') +}) + +test('a required property declared first still sorts ahead', (t) => { + t.plan(1) + + const stringify = build({ + type: 'array', + items: { + type: 'object', + properties: { c: { type: 'string' }, a: { type: 'string' } }, + required: ['c'] + } + }) + + t.assert.equal(stringify([{ a: '1', c: '2' }, { a: '3', c: '4' }]), '[{"c":"2","a":"1"},{"c":"4","a":"3"}]') +}) + +test('nested tuple arrays and additionalItems decline projection', (t) => { + t.plan(2) + + const tuple = build({ + type: 'array', + items: { type: 'object', properties: { t: { type: 'array', items: [{ type: 'string' }, { type: 'integer' }] } } } + }) + t.assert.equal(tuple([{ t: ['a', 1] }, { t: ['b', 2] }]), '[{"t":["a",1]},{"t":["b",2]}]') + + const additional = build({ + type: 'array', + items: { type: 'object', properties: { t: { type: 'array', items: [{ type: 'string' }], additionalItems: true } } } + }) + t.assert.equal(additional([{ t: ['a', 1] }, { t: ['b', 2] }]), '[{"t":["a",1]},{"t":["b",2]}]') +}) + +test('a nested array without items declines projection', (t) => { + t.plan(1) + + const stringify = build({ + type: 'array', + items: { type: 'object', properties: { t: { type: 'array' } } } + }) + + t.assert.equal(stringify([{ t: ['a', 1] }, { t: [] }]), '[{"t":["a",1]},{"t":[]}]') +}) + +test('a nested array of non-projectable items declines projection', (t) => { + t.plan(1) + + const stringify = build({ + type: 'array', + items: { + type: 'object', + properties: { t: { type: 'array', items: { anyOf: [{ type: 'string' }, { type: 'integer' }] } } } + } + }) + + t.assert.equal(stringify([{ t: ['a', 1] }, { t: [2] }]), '[{"t":["a",1]},{"t":[2]}]') +}) + +test('a nullable nested array renders null', (t) => { + t.plan(1) + + const stringify = build({ + type: 'array', + items: { type: 'object', properties: { t: { type: 'array', nullable: true, items: { type: 'string' } } } } + }) + + t.assert.equal(stringify([{ t: null }, { t: ['a'] }]), '[{"t":null},{"t":["a"]}]') +}) + +test('a nested array is rejected when the value is not an array', (t) => { + t.plan(1) + + const stringify = build({ + type: 'array', + items: { type: 'object', properties: { t: { type: 'array', items: { type: 'string' } } } } + }) + + t.assert.throws(() => stringify([{ t: 'not an array' }, { t: ['a'] }]), /does not match schema definition/) +}) + +test('arrayProjection can be turned off', (t) => { + t.plan(2) + + const schema = { type: 'array', items: itemSchema } + const input = new Array(8).fill({ firstName: 'Matteo', lastName: 'Collina', age: 32 }) + const expected = '[' + new Array(8).fill('{"firstName":"Matteo","lastName":"Collina","age":32}').join(',') + ']' + + t.assert.equal(buildDefault(schema, { arrayProjection: false })(input), expected) + t.assert.equal(buildDefault(schema, { arrayProjection: true })(input), expected) +}) + +test('arrayProjection must be a boolean', (t) => { + t.plan(1) + + t.assert.throws( + () => buildDefault({ type: 'array', items: itemSchema }, { arrayProjection: 'yes' }), + /Unsupported array projection option yes/ + ) +}) + +test('an array nested in an object is projected in place', (t) => { + t.plan(2) + + const stringify = build({ + type: 'object', + properties: { + rows: { type: 'array', items: itemSchema }, + total: { type: 'integer' } + } + }) + + const row = { firstName: 'Matteo', lastName: 'Collina', age: 32 } + const expectedRow = '{"firstName":"Matteo","lastName":"Collina","age":32}' + + t.assert.equal(stringify({ rows: [row, row], total: 2 }), `{"rows":[${expectedRow},${expectedRow}],"total":2}`) + // below the projection threshold, so the concatenation path runs instead + t.assert.equal(stringify({ rows: [row], total: 1 }), `{"rows":[${expectedRow}],"total":1}`) +}) + +test('null-typed and date/time formatted properties are projected', (t) => { + t.plan(1) + + const stringify = build({ + type: 'array', + items: { + type: 'object', + properties: { + nothing: { type: 'null' }, + day: { type: 'string', format: 'date' }, + clock: { type: 'string', format: 'time' } + } + } + }) + + const at = new Date(2020, 0, 2, 3, 4, 5) + const expected = '{"nothing":null,"day":"2020-01-02","clock":"03:04:05"}' + + t.assert.equal(stringify([{ nothing: null, day: at, clock: at }, { nothing: null, day: at, clock: at }]), + `[${expected},${expected}]`) +}) + +test('boolean item schemas decline projection', (t) => { + t.plan(1) + + const stringify = build({ + type: 'array', + items: { type: 'object', properties: { a: { type: 'array', items: true } } } + }) + + t.assert.equal(stringify([{ a: [1, 'x'] }, { a: [] }]), '[{"a":[1,"x"]},{"a":[]}]') +}) + +test('a type union without null declines projection', (t) => { + t.plan(1) + + const stringify = build({ + type: 'array', + items: { type: 'object', properties: { a: { type: ['string', 'integer'] } } } + }) + + t.assert.equal(stringify([{ a: 'x' }, { a: 1 }]), '[{"a":"x"},{"a":1}]') +}) + +test('objects nested deeper than the projection depth decline', (t) => { + t.plan(1) + + const stringify = build({ + type: 'array', + items: { + type: 'object', + properties: { + l1: { + type: 'object', + properties: { + l2: { type: 'object', properties: { l3: { type: 'object', properties: { v: { type: 'string' } } } } } + } + } + } + } + }) + + t.assert.equal(stringify([{ l1: { l2: { l3: { v: 'x' } } } }, { l1: { l2: { l3: { v: 'y' } } } }]), + '[{"l1":{"l2":{"l3":{"v":"x"}}}},{"l1":{"l2":{"l3":{"v":"y"}}}}]') +}) + +test('a nested array whose items are a $ref declines projection', (t) => { + t.plan(1) + + const stringify = build({ + type: 'array', + items: { type: 'object', properties: { a: { type: 'array', items: { $ref: 'item#' } } } } + }, { + schema: { item: { type: 'string' } } + }) + + t.assert.equal(stringify([{ a: ['x', 'y'] }, { a: ['z'] }]), '[{"a":["x","y"]},{"a":["z"]}]') +}) diff --git a/test/code-generation-fallbacks.test.js b/test/code-generation-fallbacks.test.js index 6dbcf703..4d492bee 100644 --- a/test/code-generation-fallbacks.test.js +++ b/test/code-generation-fallbacks.test.js @@ -77,10 +77,11 @@ test('inline object generation without schema IDs', t => { }) test('inline array generation without schema IDs', t => { - t.plan(6) + t.plan(8) const build = loadBuildWithLocation(LocationWithoutSchemaId) const stringify = build({ type: 'array', items: { type: 'string' } }, { largeArrayMechanism: 'default' }) + const stringifyProjected = build({ type: 'array', items: { type: 'string' } }, { arrayProjection: true }) const stringifyNullable = build({ type: 'array', nullable: true }) const stringifyTuple = build({ type: 'array', @@ -107,6 +108,9 @@ test('inline array generation without schema IDs', t => { t.assert.throws(() => stringifyFixedTuple(['one', 'two']), /Item at 1/) t.assert.equal(stringifyLargeArray([1, 2]), '[1,2]') t.assert.throws(() => stringify('not-an-array'), /does not match schema definition/) + // long enough to project, and short enough to fall through to concatenation + t.assert.equal(stringifyProjected(['one', 'two']), '["one","two"]') + t.assert.equal(stringifyProjected(['one']), '["one"]') }) test('code generation reference fallbacks', t => { diff --git a/types/index.d.ts b/types/index.d.ts index 5d874fc1..eab830fa 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -192,6 +192,13 @@ declare namespace build { * @default 'default' */ largeArrayMechanism?: 'default' | 'json-stringify' + /** + * Serialize arrays by projecting their items into plain objects and + * handing the result to `JSON.stringify`, which is faster than string + * concatenation on V8 13.8 and newer. Defaults to whether the running V8 + * supports it; set it explicitly to override the detection. + */ + arrayProjection?: boolean /** * Eagerly compile the Ajv validators used by `anyOf`, `oneOf` and * `if/then/else` at build time instead of on the first serialization diff --git a/types/index.tst.ts b/types/index.tst.ts index 4228af47..fa0b1aaa 100644 --- a/types/index.tst.ts +++ b/types/index.tst.ts @@ -262,6 +262,11 @@ build({}, { largeArraySize: '2e4' }) build({}, { largeArraySize: 2n }) expect(build).type.not.toBeCallableWith({} as Schema, { largeArraySize: ['asdf'] }) +// arrayProjection +build({}, { arrayProjection: true }) +build({}, { arrayProjection: false }) +expect(build).type.not.toBeCallableWith({} as Schema, { arrayProjection: 'yes' }) + // maxDepth build({}, { maxDepth: 100 }) expect(build).type.not.toBeCallableWith({} as Schema, { maxDepth: '500' })