diff --git a/index.js b/index.js index 58305344..d620573c 100644 --- a/index.js +++ b/index.js @@ -4,7 +4,6 @@ const { RefResolver } = require('json-schema-ref-resolver') -const Serializer = require('./lib/serializer') const Validator = require('./lib/validator') const Location = require('./lib/location') const validate = require('./lib/schema-validator') @@ -15,20 +14,171 @@ const SINGLE_TICK = /'/g let largeArraySize = 2e4 let largeArrayMechanism = 'default' -const serializerFns = ` -const { - asString, - asNumber, - asBoolean, - asDateTime, - asDate, - asTime, - asUnsafeString -} = serializer +function inlineAsInteger (options, input) { + let roundingFn = 'Math.trunc' + if (options && options.rounding) { + switch (options.rounding) { + case 'floor': + roundingFn = 'Math.floor' + break + case 'ceil': + roundingFn = 'Math.ceil' + break + case 'round': + roundingFn = 'Math.round' + break + } + } + + return ` + // #region inlineAsInteger + if (Number.isInteger(${input})) { + json += ${input} + } else if (typeof ${input} === 'bigint') { + json += ${input}.toString() + } else { + const integer = ${roundingFn}(${input}) + if (integer === Infinity || integer === -Infinity || integer !== integer) { + throw new Error('The value "' + ${input} + '" cannot be converted to an integer.') + } + json += integer + } + // #endregion inlineAsInteger + ` +} + +function inlineAsNumber (input) { + return ` + // #region inlineAsNumber + const num = Number(${input}) + if (num !== num) { + throw new Error('The value "' + ${input} + '" cannot be converted to a number.') + } else if (num === Infinity || num === -Infinity) { + json += JSON_STR_NULL + } else { + json += num + } + // #endregion inlineAsNumber + ` +} + +function inlineAsBoolean (input) { + return `// #region inlineAsBoolean + json += ${input} ? 'true' : 'false' + // #endregion inlineAsBoolean` +} + +function inlineAsDateTime (input) { + return ` + // #region inlineAsDateTime + if (${input} === null) { + json += JSON_STR_EMPTY_STRING + } else if (${input} instanceof Date) { + json += JSON_STR_QUOTE + ${input}.toISOString() + JSON_STR_QUOTE + } else if (typeof ${input} === 'string') { + json += JSON_STR_QUOTE + ${input} + JSON_STR_QUOTE + } else { + throw new Error('The value "' + ${input} + '" cannot be converted to a date-time.') + } + // #endregion inlineAsDateTime + ` +} + +function inlineAsDate (input) { + return ` + // #region inlineAsDate + if (${input} === null) { + json += JSON_STR_EMPTY_STRING + } else if (${input} instanceof Date) { + json += JSON_STR_QUOTE + new Date(${input}.getTime() - (${input}.getTimezoneOffset() * 60000)).toISOString().slice(0, 10) + JSON_STR_QUOTE + } else if (typeof ${input} === 'string') { + json += JSON_STR_QUOTE + ${input} + JSON_STR_QUOTE + } else { + throw new Error('The value "' + ${input} + '" cannot be converted to a date.') + } + // #endregion inlineAsDate + ` +} -const asInteger = serializer.asInteger.bind(serializer) +function inlineAsTime (input) { + return ` + // #region inlineAsTime + if (${input} === null) { + json += JSON_STR_EMPTY_STRING + } else if (${input} instanceof Date) { + json += JSON_STR_QUOTE + new Date(${input}.getTime() - (${input}.getTimezoneOffset() * 60000)).toISOString().slice(11, 19) + JSON_STR_QUOTE + } else if (typeof ${input} === 'string') { + json += JSON_STR_QUOTE + ${input} + JSON_STR_QUOTE + } else { + throw new Error('The value "' + ${input} + '" cannot be converted to a time.') + } + // #endregion inlineAsTime + ` +} -` +function inlineAsString (input) { + return ` + // #region inlineAsString + if (typeof ${input} !== 'string') { + if (${input} === null) { + json += JSON_STR_EMPTY_STRING + } else if (${input} instanceof Date) { + json += JSON_STR_QUOTE + ${input}.toISOString() + JSON_STR_QUOTE + } else if (${input} instanceof RegExp) { + const _str = ${input}.source + ${inlineAsStringInternal('_str')} + } else { + const _str = String(${input}) + ${inlineAsStringInternal('_str')} + } + } else { + ${inlineAsStringInternal(input)} + } + // #endregion inlineAsString + ` +} + +function inlineAsStringInternal (input) { + return ` + // #region inlineAsStringInternal + { + const len = ${input}.length + if (len === 0) { + json += JSON_STR_EMPTY_STRING + } else if (len < 42) { + let result = '' + let last = -1 + let point = 255 + for (let i = 0; i < len; i++) { + point = ${input}.charCodeAt(i) + if (point === 0x22 || point === 0x5c) { + last === -1 && (last = 0) + result += ${input}.slice(last, i) + '\\\\' + last = i + } else if (point < 32 || (point >= 0xD800 && point <= 0xDFFF)) { + json += JSON.stringify(${input}) + result = null + break + } + } + if (result !== null) { + json += JSON_STR_QUOTE + (last === -1 ? ${input} : (result + ${input}.slice(last))) + JSON_STR_QUOTE + } + } else if (len < 5000 && STR_ESCAPE.test(${input}) === false) { + json += JSON_STR_QUOTE + ${input} + JSON_STR_QUOTE + } else { + json += JSON.stringify(${input}) + } + } + // #endregion inlineAsStringInternal + ` +} + +function inlineAsUnsafeString (input) { + return `// #region inlineAsUnsafeString +json += JSON_STR_QUOTE + ${input} + JSON_STR_QUOTE +// #endregion inlineAsUnsafeString` +} const validRoundingMethods = new Set([ 'floor', @@ -172,7 +322,7 @@ function build (schema, options) { const code = buildValue(context, location, 'input') let contextFunctionCode = ` - ${serializerFns} + const STR_ESCAPE = /[\\u0000-\\u001f\\u0022\\u005c\\ud800-\\udfff]/ const JSON_STR_BEGIN_OBJECT = '{' const JSON_STR_END_OBJECT = '}' const JSON_STR_BEGIN_ARRAY = '[' @@ -209,7 +359,6 @@ function build (schema, options) { ` } - const serializer = new Serializer(options) const validator = new Validator(options.ajv) for (const schemaId of context.validatorSchemasIds) { @@ -229,7 +378,7 @@ function build (schema, options) { if (options.mode === 'debug') { return { validator, - serializer, + serializer: { getState: () => options }, code: `validator\nserializer\n${contextFunctionCode}`, ajv: validator.ajv } @@ -240,10 +389,10 @@ function build (schema, options) { if (options.mode === 'standalone') { const buildStandaloneCode = require('./lib/standalone') - return buildStandaloneCode(contextFunc, context, serializer, validator) + return buildStandaloneCode(contextFunc, context, { getState: () => options }, validator) } - return contextFunc(validator, serializer) + return contextFunc(validator, null) } const objectKeywords = [ @@ -324,7 +473,8 @@ function buildExtraObjectPropertiesSerializer (context, location, addComma, objV code += ` if (/${propertyKey.replace(/\\*\//g, '\\/')}/.test(key)) { ${addComma} - json += asString(key) + JSON_STR_COLONS + ${inlineAsString('key')} + json += JSON_STR_COLONS ${buildValue(context, propertyLocation, 'value')} continue } @@ -339,13 +489,15 @@ function buildExtraObjectPropertiesSerializer (context, location, addComma, objV if (additionalPropertiesSchema === true) { code += ` ${addComma} - json += asString(key) + JSON_STR_COLONS + JSON.stringify(value) + ${inlineAsString('key')} + json += JSON_STR_COLONS + JSON.stringify(value) ` } else { const propertyLocation = location.getPropertyLocation('additionalProperties') code += ` ${addComma} - json += asString(key) + JSON_STR_COLONS + ${inlineAsString('key')} + json += JSON_STR_COLONS ${buildValue(context, propertyLocation, 'value')} ` } @@ -428,7 +580,9 @@ function buildInnerObject (context, location, objVar) { } if (schema.patternProperties || schema.additionalProperties) { + code += '// #region extraObjectProperties\n' code += buildExtraObjectPropertiesSerializer(context, location, addComma, objVar) + code += '// #endregion extraObjectProperties\n' } code += ` @@ -526,8 +680,9 @@ function buildObject (context, location, input) { const obj = ${toJSON('input')} if (obj === null) return ${nullable ? 'JSON_STR_NULL' : 'JSON_STR_EMPTY_OBJECT'} let json = '' - + // #region buildInnerObject ${buildInnerObject(context, location, 'obj')} + // #endregion buildInnerObject return json } ` @@ -543,7 +698,9 @@ function buildObject (context, location, input) { if (${objVar} === null) { json += ${nullable ? 'JSON_STR_NULL' : 'JSON_STR_EMPTY_OBJECT'} } else { + // #region buildInnerObject ${buildInnerObject(context, location, objVar)} + // #endregion buildInnerObject } ` context.buildingSet.delete(schema) @@ -864,37 +1021,23 @@ function buildSingleTypeSerializer (context, location, input) { return 'json += JSON_STR_NULL' case 'string': { if (schema.format === 'date-time') { - return `json += asDateTime(${input})` + return inlineAsDateTime(input) } else if (schema.format === 'date') { - return `json += asDate(${input})` + return inlineAsDate(input) } else if (schema.format === 'time') { - return `json += asTime(${input})` + return inlineAsTime(input) } else if (schema.format === 'unsafe') { - return `json += asUnsafeString(${input})` + return inlineAsUnsafeString(input) } else { - return ` - if (typeof ${input} !== 'string') { - if (${input} === null) { - json += JSON_STR_EMPTY_STRING - } else if (${input} instanceof Date) { - json += JSON_STR_QUOTE + ${input}.toISOString() + JSON_STR_QUOTE - } else if (${input} instanceof RegExp) { - json += asString(${input}.source) - } else { - json += asString(${input}.toString()) - } - } else { - json += asString(${input}) - } - ` + return inlineAsString(input) } } case 'integer': - return `json += asInteger(${input})` + return inlineAsInteger(context.options, input) case 'number': - return `json += asNumber(${input})` + return inlineAsNumber(input) case 'boolean': - return `json += asBoolean(${input})` + return inlineAsBoolean(input) case 'object': { return buildObject(context, location, input) } @@ -1215,11 +1358,17 @@ function buildValue (context, location, input) { } if (schema.const !== undefined) { + code += '// #region buildConstSerializer\n' code += buildConstSerializer(location, input) + code += '// #endregion buildConstSerializer\n' } else if (Array.isArray(type)) { + code += '// #region buildMultiTypeSerializer\n' code += buildMultiTypeSerializer(context, location, input) + code += '// #endregion buildMultiTypeSerializer\n' } else { + code += '// #region buildSingleTypeSerializer\n' code += buildSingleTypeSerializer(context, location, input) + code += '// #endregion buildSingleTypeSerializer\n' } if (nullable) { diff --git a/lib/serializer.js b/lib/serializer.js deleted file mode 100644 index d8ff4b87..00000000 --- a/lib/serializer.js +++ /dev/null @@ -1,141 +0,0 @@ -'use strict' - -// eslint-disable-next-line -const STR_ESCAPE = /[\u0000-\u001f\u0022\u005c\ud800-\udfff]/ - -module.exports = class Serializer { - constructor (options) { - switch (options && options.rounding) { - case 'floor': - this.parseInteger = Math.floor - break - case 'ceil': - this.parseInteger = Math.ceil - break - case 'round': - this.parseInteger = Math.round - break - case 'trunc': - default: - this.parseInteger = Math.trunc - 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.`) - } - return '' + integer - } - - asNumber (i) { - // fast cast to number - const num = Number(i) - // check if number is NaN - // eslint-disable-next-line no-self-compare - if (num !== num) { - throw new Error(`The value "${i}" cannot be converted to a number.`) - } else if (num === Infinity || num === -Infinity) { - return 'null' - } else { - return '' + num - } - } - - asBoolean (bool) { - return bool && 'true' || 'false' // eslint-disable-line - } - - asDateTime (date) { - if (date === null) return '""' - if (date instanceof Date) { - return '"' + date.toISOString() + '"' - } - if (typeof date === 'string') { - return '"' + date + '"' - } - throw new Error(`The value "${date}" cannot be converted to a date-time.`) - } - - asDate (date) { - if (date === null) return '""' - if (date instanceof Date) { - return '"' + new Date(date.getTime() - (date.getTimezoneOffset() * 60000)).toISOString().slice(0, 10) + '"' - } - if (typeof date === 'string') { - return '"' + date + '"' - } - throw new Error(`The value "${date}" cannot be converted to a date.`) - } - - asTime (date) { - if (date === null) return '""' - if (date instanceof Date) { - return '"' + new Date(date.getTime() - (date.getTimezoneOffset() * 60000)).toISOString().slice(11, 19) + '"' - } - if (typeof date === 'string') { - return '"' + date + '"' - } - throw new Error(`The value "${date}" cannot be converted to a time.`) - } - - asString (str) { - const len = str.length - if (len === 0) { - return '""' - } else if (len < 42) { - // magically escape strings for json - // relying on their charCodeAt - // everything below 32 needs JSON.stringify() - // every string that contain surrogate needs JSON.stringify() - // 34 and 92 happens all the time, so we - // have a fast case for them - let result = '' - let last = -1 - let point = 255 - for (let i = 0; i < len; i++) { - point = str.charCodeAt(i) - if ( - point === 0x22 || // '"' - point === 0x5c // '\' - ) { - last === -1 && (last = 0) - result += str.slice(last, i) + '\\' - last = i - } else if (point < 32 || (point >= 0xD800 && point <= 0xDFFF)) { - // The current character is non-printable characters or a surrogate. - return JSON.stringify(str) - } - } - return (last === -1 && ('"' + str + '"')) || ('"' + result + str.slice(last) + '"') - } else if (len < 5000 && STR_ESCAPE.test(str) === false) { - // Only use the regular expression for shorter input. The overhead is otherwise too much. - return '"' + str + '"' - } else { - return JSON.stringify(str) - } - } - - asUnsafeString (str) { - return '"' + str + '"' - } - - getState () { - return this._options - } - - static restoreFromState (state) { - return new Serializer(state) - } -} diff --git a/lib/standalone.js b/lib/standalone.js index 0ba3ac3f..c32ccc41 100644 --- a/lib/standalone.js +++ b/lib/standalone.js @@ -12,23 +12,19 @@ function buildStandaloneCode (contextFunc, context, serializer, validator) { // Don't need to keep external schemas once compiled // validatorState will hold external schemas if it needs them + // eslint-disable-next-line no-unused-vars const { schema, ...serializerState } = serializer.getState() return ` 'use strict' - const Serializer = require('fast-json-stringify/lib/serializer') - const serializerState = ${JSON.stringify(serializerState)} - const serializer = Serializer.restoreFromState(serializerState) - ${ajvDependencyCode} - module.exports = ${contextFunc.toString()}(validator, serializer)` + module.exports = ${contextFunc.toString()}(validator, null)` } module.exports = buildStandaloneCode module.exports.dependencies = { - Serializer: require('./serializer'), Validator: require('./validator') } diff --git a/test/asNumber.test.js b/test/asNumber.test.js deleted file mode 100644 index 94a40b43..00000000 --- a/test/asNumber.test.js +++ /dev/null @@ -1,13 +0,0 @@ -'use strict' - -const { test } = require('node:test') - -test('asNumber should convert BigInt', (t) => { - t.plan(1) - const Serializer = require('../lib/serializer') - const serializer = new Serializer() - - const number = serializer.asNumber(11753021440n) - - t.assert.equal(number, '11753021440') -}) diff --git a/test/debug-mode.test.js b/test/debug-mode.test.js index 3e998a73..1769e7fb 100644 --- a/test/debug-mode.test.js +++ b/test/debug-mode.test.js @@ -5,7 +5,6 @@ const fjs = require('..') const Ajv = require('ajv').default const Validator = require('../lib/validator') -const Serializer = require('../lib/serializer') function build (opts) { return fjs({ @@ -27,7 +26,7 @@ test('activate debug mode', t => { t.assert.ok(typeof debugMode === 'object') t.assert.ok(debugMode.ajv instanceof Ajv) t.assert.ok(debugMode.validator instanceof Validator) - t.assert.ok(debugMode.serializer instanceof Serializer) + t.assert.ok(typeof debugMode.serializer === 'object') t.assert.ok(typeof debugMode.code === 'string') }) @@ -40,7 +39,7 @@ test('activate debug mode truthy', t => { t.assert.ok(typeof debugMode.code === 'string') t.assert.ok(debugMode.ajv instanceof Ajv) t.assert.ok(debugMode.validator instanceof Validator) - t.assert.ok(debugMode.serializer instanceof Serializer) + t.assert.ok(typeof debugMode.serializer === 'object') }) test('to string auto-consistent', t => { @@ -50,7 +49,7 @@ test('to string auto-consistent', t => { t.assert.ok(typeof debugMode === 'object') t.assert.ok(typeof debugMode.code === 'string') t.assert.ok(debugMode.ajv instanceof Ajv) - t.assert.ok(debugMode.serializer instanceof Serializer) + t.assert.ok(typeof debugMode.serializer === 'object') t.assert.ok(debugMode.validator instanceof Validator) const compiled = fjs.restore(debugMode) @@ -79,7 +78,7 @@ test('to string auto-consistent with ajv', t => { t.assert.ok(typeof debugMode.code === 'string') t.assert.ok(debugMode.ajv instanceof Ajv) t.assert.ok(debugMode.validator instanceof Validator) - t.assert.ok(debugMode.serializer instanceof Serializer) + t.assert.ok(typeof debugMode.serializer === 'object') const compiled = fjs.restore(debugMode) const tobe = JSON.stringify({ str: 'Foo' })