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' })