Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 36 additions & 21 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ let largeArrayMechanism = 'default'
const serializerFns = `
const {
asString,
asInteger,
asNumber,
asBoolean,
asDateTime,
Expand All @@ -24,8 +25,6 @@ const {
asUnsafeString
} = serializer

const asInteger = serializer.asInteger.bind(serializer)

`

const validRoundingMethods = new Set([
Expand Down Expand Up @@ -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)
Expand All @@ -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) {
Expand All @@ -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) {
Expand All @@ -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) {
Expand Down
66 changes: 49 additions & 17 deletions lib/serializer.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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) {
Expand All @@ -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 + '"'
Expand All @@ -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 + '"'
Expand All @@ -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 + '"'
Expand Down
26 changes: 26 additions & 0 deletions test/required.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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!' })
})
Loading