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