diff --git a/index.js b/index.js index b4400d23..443cddda 100644 --- a/index.js +++ b/index.js @@ -16,6 +16,7 @@ let largeArrayMechanism = 'default' const serializerFns = ` const { asString, + asInteger, asNumber, asBoolean, asDateTime, @@ -24,8 +25,6 @@ const { asUnsafeString } = serializer -const asInteger = serializer.asInteger.bind(serializer) - ` const validRoundingMethods = new Set([ @@ -375,18 +374,16 @@ function buildInnerObject (context, location, objVar) { } } - code += 'json += JSON_STR_BEGIN_OBJECT\n' - const localUid = context.uid++ let addComma = '' - if (requiredProperties.length > 0) { - // If we have required properties, we know that at least one property will be serialized. - // We can avoid the runtime check for the comma. - - // The first property is required, so we don't need a comma. - // For the subsequent properties, we can blindly add a comma. + // The first sorted property is required, so it is always serialized: the + // comma of every subsequent property can be emitted unconditionally and + // folded into the property key literal. + const firstPropertyIsRequired = + propertiesKeys.length > 0 && requiredProperties.includes(propertiesKeys[0]) + if (firstPropertyIsRequired) { for (let i = 0; i < propertiesKeys.length; i++) { const key = propertiesKeys[i] const propertyLocation = propertiesLocation.getPropertyLocation(key) @@ -401,22 +398,20 @@ function buildInnerObject (context, location, objVar) { const defaultValue = resolvedLocation.schema.default const isRequired = requiredProperties.includes(key) - // i === 0 means it's the first property, and it IS required (due to sort). No comma. - // i > 0 means it follows a required property (or a sequence starting with one). Unconditional comma. - const currentAddComma = i === 0 ? '' : 'json += JSON_STR_COMMA' + // The comma (or the object opening brace for the first property) is + // folded into the key literal to serialize them with a single append. + const keyPrefix = i === 0 ? '{' : ',' code += ` const ${value} = ${objVar}[${sanitizedKey}] if (${value} !== undefined) { - ${currentAddComma} - json += ${JSON.stringify(sanitizedKey + ':')} + json += ${JSON.stringify(keyPrefix + sanitizedKey + ':')} ${buildValue(context, resolvedLocation, `${value}`)} }` if (defaultValue !== undefined) { code += ` else { - ${currentAddComma} - json += ${JSON.stringify(sanitizedKey + ':' + JSON.stringify(defaultValue))} + json += ${JSON.stringify(keyPrefix + sanitizedKey + ':' + JSON.stringify(defaultValue))} } ` } else if (isRequired) { @@ -431,6 +426,8 @@ function buildInnerObject (context, location, objVar) { addComma = 'json += JSON_STR_COMMA' } else { + code += 'json += JSON_STR_BEGIN_OBJECT\n' + const needsRuntimeComma = propertiesKeys.length > 1 || schema.patternProperties || (schema.additionalProperties !== undefined && schema.additionalProperties !== false) if (needsRuntimeComma) { @@ -449,18 +446,36 @@ function buildInnerObject (context, location, objVar) { const defaultValue = propertyLocation.schema.default const isRequired = requiredProperties.includes(key) // Should be false here but good to keep + // The comma is folded into the key literal, so each branch appends the + // separator and the key with a single string concatenation. + const emitKey = needsRuntimeComma + ? `if (addComma_${localUid}) { + json += ${JSON.stringify(',' + sanitizedKey + ':')} + } else { + addComma_${localUid} = true + json += ${JSON.stringify(sanitizedKey + ':')} + }` + : `json += ${JSON.stringify(sanitizedKey + ':')}` + code += ` const ${value} = ${objVar}[${sanitizedKey}] if (${value} !== undefined) { - ${addComma} - json += ${JSON.stringify(sanitizedKey + ':')} + ${emitKey} ${buildValue(context, propertyLocation, `${value}`)} }` if (defaultValue !== undefined) { + const defaultJson = JSON.stringify(defaultValue) + const emitDefault = needsRuntimeComma + ? `if (addComma_${localUid}) { + json += ${JSON.stringify(',' + sanitizedKey + ':' + defaultJson)} + } else { + addComma_${localUid} = true + json += ${JSON.stringify(sanitizedKey + ':' + defaultJson)} + }` + : `json += ${JSON.stringify(sanitizedKey + ':' + defaultJson)}` code += ` else { - ${addComma} - json += ${JSON.stringify(sanitizedKey + ':' + JSON.stringify(defaultValue))} + ${emitDefault} } ` } else if (isRequired) { diff --git a/lib/serializer.js b/lib/serializer.js index d8ff4b87..4972649a 100644 --- a/lib/serializer.js +++ b/lib/serializer.js @@ -3,6 +3,13 @@ // eslint-disable-next-line const STR_ESCAPE = /[\u0000-\u001f\u0022\u005c\ud800-\udfff]/ +// Lookup table of two-digit zero-padded strings ('00'..'99') used to format +// date components without allocating intermediate Date objects. +const PADDED_2 = [] +for (let i = 0; i < 100; i++) { + PADDED_2.push(String(i).padStart(2, '0')) +} + module.exports = class Serializer { constructor (options) { switch (options && options.rounding) { @@ -21,22 +28,24 @@ module.exports = class Serializer { break } this._options = options - } - asInteger (i) { - if (Number.isInteger(i)) { - return '' + i - } else if (typeof i === 'bigint') { - return i.toString() - } - /* eslint no-undef: "off" */ - const integer = this.parseInteger(i) - // check if number is Infinity or NaN - // eslint-disable-next-line no-self-compare - if (integer === Infinity || integer === -Infinity || integer !== integer) { - throw new Error(`The value "${i}" cannot be converted to an integer.`) + // An own-property closure avoids binding 'this' in the generated code, + // which is faster to call than a bound function. + const parseInteger = this.parseInteger + this.asInteger = function asInteger (i) { + if (Number.isInteger(i)) { + return '' + i + } else if (typeof i === 'bigint') { + return i.toString() + } + const integer = parseInteger(i) + // check if number is Infinity or NaN + // eslint-disable-next-line no-self-compare + if (integer === Infinity || integer === -Infinity || integer !== integer) { + throw new Error(`The value "${i}" cannot be converted to an integer.`) + } + return '' + integer } - return '' + integer } asNumber (i) { @@ -60,7 +69,17 @@ module.exports = class Serializer { asDateTime (date) { if (date === null) return '""' if (date instanceof Date) { - return '"' + date.toISOString() + '"' + const year = date.getUTCFullYear() + if (!(year >= 0 && year <= 9999)) { + // toISOString handles the expanded year format and + // throws a RangeError on invalid dates + return '"' + date.toISOString() + '"' + } + const yearStr = year >= 1000 ? '' + year : ('000' + year).slice(-4) + const msStr = ('' + (1000 + date.getUTCMilliseconds())).slice(1) + return '"' + yearStr + '-' + PADDED_2[date.getUTCMonth() + 1] + '-' + PADDED_2[date.getUTCDate()] + + 'T' + PADDED_2[date.getUTCHours()] + ':' + PADDED_2[date.getUTCMinutes()] + ':' + PADDED_2[date.getUTCSeconds()] + + '.' + msStr + 'Z"' } if (typeof date === 'string') { return '"' + date + '"' @@ -71,7 +90,14 @@ module.exports = class Serializer { asDate (date) { if (date === null) return '""' if (date instanceof Date) { - return '"' + new Date(date.getTime() - (date.getTimezoneOffset() * 60000)).toISOString().slice(0, 10) + '"' + const year = date.getFullYear() + if (!(year >= 0 && year <= 9999)) { + // Slow path for expanded years and invalid dates: shifting by the + // timezone offset makes toISOString print the local date + return '"' + new Date(date.getTime() - (date.getTimezoneOffset() * 60000)).toISOString().slice(0, 10) + '"' + } + const yearStr = year >= 1000 ? '' + year : ('000' + year).slice(-4) + return '"' + yearStr + '-' + PADDED_2[date.getMonth() + 1] + '-' + PADDED_2[date.getDate()] + '"' } if (typeof date === 'string') { return '"' + date + '"' @@ -82,7 +108,13 @@ module.exports = class Serializer { asTime (date) { if (date === null) return '""' if (date instanceof Date) { - return '"' + new Date(date.getTime() - (date.getTimezoneOffset() * 60000)).toISOString().slice(11, 19) + '"' + const hours = date.getHours() + // eslint-disable-next-line no-self-compare + if (hours !== hours) { + // Invalid date: let toISOString throw the same RangeError as before + return '"' + new Date(date.getTime() - (date.getTimezoneOffset() * 60000)).toISOString().slice(11, 19) + '"' + } + return '"' + PADDED_2[hours] + ':' + PADDED_2[date.getMinutes()] + ':' + PADDED_2[date.getSeconds()] + '"' } if (typeof date === 'string') { return '"' + date + '"' diff --git a/test/required.test.js b/test/required.test.js index 96d74705..3ec78839 100644 --- a/test/required.test.js +++ b/test/required.test.js @@ -202,3 +202,29 @@ test('required numbers', (t) => { }) }, { message: 'The value "aaa" cannot be converted to an integer.' }) }) + +test('required property not declared in properties does not break commas', (t) => { + t.plan(3) + + const schema = { + title: 'object with required field not in properties', + type: 'object', + properties: { + a: { + type: 'string' + }, + b: { + type: 'integer' + } + }, + required: ['zz'] + } + const stringify = build(schema) + + t.assert.equal(stringify({ zz: 1, b: 2 }), '{"b":2}') + t.assert.equal(stringify({ zz: 1, a: 'x', b: 2 }), '{"a":"x","b":2}') + + t.assert.throws(() => { + stringify({ a: 'x' }) + }, { message: '"zz" is required!' }) +})