From c7c04bd2242ae407ce4060489effb41cd744056e Mon Sep 17 00:00:00 2001 From: uzlopak Date: Thu, 27 Jul 2023 14:02:25 +0200 Subject: [PATCH 1/4] optimizer v3 --- index.js | 57 ++++++------ lib/optimize.js | 98 +++++++++++++++++++++ lib/serializer.js | 188 ++++++++++++++++++++++++++++++---------- test/debug-mode.test.js | 4 +- test/optimize.test.js | 113 ++++++++++++++++++++++++ 5 files changed, 382 insertions(+), 78 deletions(-) create mode 100644 lib/optimize.js create mode 100644 test/optimize.test.js diff --git a/index.js b/index.js index a2d07dcf..ab73cd0e 100644 --- a/index.js +++ b/index.js @@ -11,6 +11,7 @@ const Serializer = require('./lib/serializer') const Validator = require('./lib/validator') const RefResolver = require('./lib/ref-resolver') const Location = require('./lib/location') +const optimize = require('./lib/optimize') let largeArraySize = 2e4 let largeArrayMechanism = 'default' @@ -27,6 +28,18 @@ const validLargeArrayMechanisms = [ 'json-stringify' ] +const serializerFns = ` +const { + asString, + asInteger, + asNumber, + asBoolean, + asDateTime, + asDate, + asTime, +} = serializer +` + const addComma = '!addComma && (addComma = true) || (json += \',\')' function isValidSchema (schema, name) { @@ -119,21 +132,8 @@ function build (schema, options) { const location = new Location(schema, context.rootSchemaId) const code = buildValue(context, location, 'input') - let contextFunctionCode - - // 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 - // wrapping and the unnecessary memory allocation by aliasing 'anonymous0' to - // 'main' - if (code === 'json += anonymous0(input)') { - contextFunctionCode = ` - ${context.functions.join('\n')} - const main = anonymous0 - return main - ` - } else { - contextFunctionCode = ` + let contextFunctionCode = ` + ${serializerFns} function main (input) { let json = '' ${code} @@ -142,7 +142,8 @@ function build (schema, options) { ${context.functions.join('\n')} return main ` - } + + contextFunctionCode = optimize(contextFunctionCode) const serializer = new Serializer(options) const validator = new Validator(options.ajv) @@ -263,7 +264,7 @@ function buildExtraObjectPropertiesSerializer (context, location) { code += ` if (/${propertyKey.replace(/\\*\//g, '\\/')}/.test(key)) { ${addComma} - json += serializer.asString(key) + ':' + json += asString(key) + ':' ${buildValue(context, propertyLocation, 'value')} continue } @@ -278,13 +279,13 @@ function buildExtraObjectPropertiesSerializer (context, location) { if (additionalPropertiesSchema === true) { code += ` ${addComma} - json += serializer.asString(key) + ':' + JSON.stringify(value) + json += asString(key) + ':' + JSON.stringify(value) ` } else { const propertyLocation = location.getPropertyLocation('additionalProperties') code += ` ${addComma} - json += serializer.asString(key) + ':' + json += asString(key) + ':' ${buildValue(context, propertyLocation, 'value')} ` } @@ -504,8 +505,8 @@ function buildObject (context, location) { } let functionCode = ` + // ${schemaRef} function ${functionName} (input) { - // ${schemaRef} ` functionCode += ` @@ -549,8 +550,8 @@ function buildArray (context, location) { } let functionCode = ` + // ${schemaRef} function ${functionName} (obj) { - // ${schemaRef} ` functionCode += ` @@ -743,21 +744,21 @@ function buildSingleTypeSerializer (context, location, input) { return 'json += \'null\'' case 'string': { if (schema.format === 'date-time') { - return `json += serializer.asDateTime(${input})` + return `json += asDateTime(${input})` } else if (schema.format === 'date') { - return `json += serializer.asDate(${input})` + return `json += asDate(${input})` } else if (schema.format === 'time') { - return `json += serializer.asTime(${input})` + return `json += asTime(${input})` } else { - return `json += serializer.asString(${input})` + return `json += asString(${input})` } } case 'integer': - return `json += serializer.asInteger(${input})` + return `json += asInteger(${input})` case 'number': - return `json += serializer.asNumber(${input})` + return `json += asNumber(${input})` case 'boolean': - return `json += serializer.asBoolean(${input})` + return `json += asBoolean(${input})` case 'object': { const funcName = buildObject(context, location) return `json += ${funcName}(${input})` diff --git a/lib/optimize.js b/lib/optimize.js new file mode 100644 index 00000000..408e19be --- /dev/null +++ b/lib/optimize.js @@ -0,0 +1,98 @@ +'use strict' + +const returnFnRE = /^\s+return ([.a-zA-Z0-9]+)\(\w+\)$/ +const fnRE = /^\s*function\s+/ +const fnNameRE = /^\s+function ([a-zA-Z0-9_]+) \(input\) {$/ +const jsonConcatRE = /^\s*json\s*\+=/ +const letJsonRE = /^\s*let json =/ +const returnJsonRE = /^\s*return json\s*$/ +const returnEmptyStringRE = /^\s*return '' \+/ +const closingCurlyBracketRE = /^\s*}\s*$/ +/** + * @param {Array} code + * @returns {Array} + */ +function optimize (raw) { + let code = raw.split('\n') + code = optimizeJsonConcat(code) + code = optimizeLetJson(code) + code = optimizeDirectReturn(code) + code = optimizeReturnEmptyString(code) + code = optimizeDirectAssignWrappedFns(code) + return code.join('\n') +} + +function optimizeJsonConcat (code) { + const optimizedJsonConcat = [] + + for (let i = 0; i < code.length; i++) { + if (i > 0 && jsonConcatRE.test(code[i]) && jsonConcatRE.test(code[i - 1])) { + const mergedEntry = code[i - 1] + ' +' + code[i].substring(code[i].indexOf('json +=') + 7) + optimizedJsonConcat.pop() // Remove the previous entry + optimizedJsonConcat.push(mergedEntry) + } else { + optimizedJsonConcat.push(code[i]) + } + } + + return optimizedJsonConcat +} + +function optimizeLetJson(code) { + const optimizedLetJsonCode = [] + for (let i = 0; i < code.length; i++) { + if (i > 0 && jsonConcatRE.test(code[i]) && letJsonRE.test(code[i - 1])) { + const mergedEntry = code[i - 1] + ' +' + code[i].substring(code[i].indexOf('json +=') + 7) + optimizedLetJsonCode.pop() // Remove the previous entry + optimizedLetJsonCode.push(mergedEntry) + } else { + optimizedLetJsonCode.push(code[i]) + } + } + return optimizedLetJsonCode +} + +function optimizeDirectReturn(code) { + const optimizedDirectReturnCode = [] + for (let i = 0; i < code.length; i++) { + if (i > 0 && returnJsonRE.test(code[i]) && letJsonRE.test(code[i - 1])) { + const mergedEntry = code[i].slice(0, code[i].indexOf('return') + 6) + code[i - 1].substring(code[i - 1].indexOf('let json =') + 10) + optimizedDirectReturnCode.pop() // Remove the previous entry + optimizedDirectReturnCode.push(mergedEntry) + } else { + optimizedDirectReturnCode.push(code[i]) + } + } + return optimizedDirectReturnCode +} + +function optimizeReturnEmptyString(code) { + for (let i = 0; i < code.length; i++) { + if (returnEmptyStringRE.test(code[i])) { + code[i] = code[i].replace('return \'\' +', 'return') + } + } + return code +} + +function optimizeDirectAssignWrappedFns (code) { + const optimizedDirectAssignFns = [] + for (let i = 0; i < code.length; i++) { + if ( + fnRE.test(code[i]) && + returnFnRE.test(code[i + 1]) && + closingCurlyBracketRE.test(code[i + 2]) + ) { + const serializerFnName = code[i + 1].match(returnFnRE)[1] + const fnName = code[i].match(fnNameRE)[1] + const whitespace = code[i].slice(0, code[i].indexOf('f')) + optimizedDirectAssignFns[i] = `${whitespace}const ${fnName} = ${serializerFnName}` + i += 2 + } else { + optimizedDirectAssignFns.push(code[i]) + } + } + + return optimizedDirectAssignFns +} +module.exports = optimize diff --git a/lib/serializer.js b/lib/serializer.js index 922be357..739d1862 100644 --- a/lib/serializer.js +++ b/lib/serializer.js @@ -3,53 +3,11 @@ // eslint-disable-next-line const STR_ESCAPE = /[\u0000-\u001f\u0022\u005c\ud800-\udfff]|[\ud800-\udbff](?![\udc00-\udfff])|(?:[^\ud800-\udbff]|^)[\udc00-\udfff]/ -module.exports = class Serializer { +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 (typeof i === 'number') { - if (i === Infinity || i === -Infinity) { - throw new Error(`The value "${i}" cannot be converted to an integer.`) - } - if (Number.isInteger(i)) { - return '' + i - } - if (Number.isNaN(i)) { - throw new Error(`The value "${i}" cannot be converted to an integer.`) - } - return this.parseInteger(i) - } else if (i === null) { - return '0' - } else if (typeof i === 'bigint') { - return i.toString() - } else { - /* eslint no-undef: "off" */ - const integer = this.parseInteger(i) - if (Number.isFinite(integer)) { - return '' + integer - } else { - throw new Error(`The value "${i}" cannot be converted to an integer.`) - } - } - } - asNumber (i) { const num = Number(i) if (Number.isNaN(num)) { @@ -113,11 +71,12 @@ module.exports = class Serializer { } } + // Fast escape chars check if (str.length < 42) { - return this.asStringSmall(str) + return Serializer.asStringSmall(str) } else if (STR_ESCAPE.test(str) === false) { return '"' + str + '"' - } else { + } else { return JSON.stringify(str) } } @@ -128,7 +87,7 @@ module.exports = class Serializer { // every string that contain surrogate needs JSON.stringify() // 34 and 92 happens all the time, so we // have a fast case for them - asStringSmall (str) { + static asStringSmall (str) { const len = str.length let result = '' let last = -1 @@ -161,7 +120,140 @@ module.exports = class Serializer { return this._options } - static restoreFromState (state) { - return new Serializer(state) +} + +class SerializerFloor extends Serializer { + asInteger (i) { + if (typeof i === 'number') { + if (i === Infinity || i === -Infinity) { + throw new Error(`The value "${i}" cannot be converted to an integer.`) + } + if (Number.isInteger(i)) { + return '' + i + } + if (Number.isNaN(i)) { + throw new Error(`The value "${i}" cannot be converted to an integer.`) + } + return '' + Math.floor(i) + } else if (i === null) { + return '0' + } else if (typeof i === 'bigint') { + return i.toString() + } else { + /* eslint no-undef: "off" */ + const integer = Math.floor(i) + if (Number.isFinite(integer)) { + return '' + integer + } else { + throw new Error(`The value "${i}" cannot be converted to an integer.`) + } + } + } +} + +class SerializerCeil extends Serializer { + asInteger (i) { + if (typeof i === 'number') { + if (i === Infinity || i === -Infinity) { + throw new Error(`The value "${i}" cannot be converted to an integer.`) + } + if (Number.isInteger(i)) { + return '' + i + } + if (Number.isNaN(i)) { + throw new Error(`The value "${i}" cannot be converted to an integer.`) + } + return '' + Math.ceil(i) + } else if (i === null) { + return '0' + } else if (typeof i === 'bigint') { + return i.toString() + } else { + /* eslint no-undef: "off" */ + const integer = Math.ceil(i) + if (Number.isFinite(integer)) { + return '' + integer + } else { + throw new Error(`The value "${i}" cannot be converted to an integer.`) + } + } + } +} + +class SerializerTrunc extends Serializer { + asInteger (i) { + if (typeof i === 'number') { + if (i === Infinity || i === -Infinity) { + throw new Error(`The value "${i}" cannot be converted to an integer.`) + } + if (Number.isInteger(i)) { + return '' + i + } + if (Number.isNaN(i)) { + throw new Error(`The value "${i}" cannot be converted to an integer.`) + } + return '' + Math.trunc(i) + } else if (i === null) { + return '0' + } else if (typeof i === 'bigint') { + return i.toString() + } else { + /* eslint no-undef: "off" */ + const integer = Math.trunc(i) + if (Number.isFinite(integer)) { + return '' + integer + } else { + throw new Error(`The value "${i}" cannot be converted to an integer.`) + } + } } } + +class SerializerRound extends Serializer { + asInteger (i) { + if (typeof i === 'number') { + if (i === Infinity || i === -Infinity) { + throw new Error(`The value "${i}" cannot be converted to an integer.`) + } + if (Number.isInteger(i)) { + return '' + i + } + if (Number.isNaN(i)) { + throw new Error(`The value "${i}" cannot be converted to an integer.`) + } + return '' + Math.round(i) + } else if (i === null) { + return '0' + } else if (typeof i === 'bigint') { + return i.toString() + } else { + /* eslint no-undef: "off" */ + const integer = Math.round(i) + if (Number.isFinite(integer)) { + return '' + integer + } else { + throw new Error(`The value "${i}" cannot be converted to an integer.`) + } + } + } +} + +function SerializerFactory (options) { + switch (options && options.rounding) { + case 'floor': + return new SerializerFloor(options) + case 'ceil': + return new SerializerCeil(options) + case 'round': + return new SerializerRound(options) + case 'trunc': + default: + return new SerializerTrunc(options) + } +} + +module.exports = SerializerFactory +module.exports.Serializer = Serializer +module.exports.restoreFromState = function restoreFromState (state) { + return SerializerFactory(state) +} \ No newline at end of file diff --git a/test/debug-mode.test.js b/test/debug-mode.test.js index b02a08fb..15c2f8f2 100644 --- a/test/debug-mode.test.js +++ b/test/debug-mode.test.js @@ -5,9 +5,9 @@ const fjs = require('..') const Ajv = require('ajv').default const Validator = require('../lib/validator') -const Serializer = require('../lib/serializer') +const { Serializer } = require('../lib/serializer') -function build (opts) { +function build(opts) { return fjs({ title: 'default string', type: 'object', diff --git a/test/optimize.test.js b/test/optimize.test.js new file mode 100644 index 00000000..8651fa03 --- /dev/null +++ b/test/optimize.test.js @@ -0,0 +1,113 @@ +'use strict' + +const test = require('tap').test +const optimize = require('../lib/optimize') + +test('optimize consecutive "json +=" lines', (t) => { + t.plan(1) + + const unoptimized = ` + json += "A" + json += "B" + ` + + const optimized = optimize(unoptimized) + + t.equal(optimized, ` + json += "A" + "B" + `) +}) + +test('optimize consecutive "let json" and following "json +="', (t) => { + t.plan(1) + + const unoptimized = ` + let json = "A" + json += "B" + ` + + const optimized = optimize(unoptimized) + + t.equal(optimized, ` + let json = "A" + "B" + `) +}) + +test('optimize consecutive "let json" and following "return json"', (t) => { + t.plan(1) + + const unoptimized = ` + let json = "A" + return json + ` + + const optimized = optimize(unoptimized) + + t.equal(optimized, ` + return "A" + `) +}) + +test('optimize return \'\' + ...', (t) => { + t.plan(1) + + const unoptimized = ` + let json = '' + json += 'B' + return json + ` + + const optimized = optimize(unoptimized) + + t.equal(optimized, ` + return 'B' + `) +}) + +test('optimize function x (input) { return asX() } to const x = asX', (t) => { + t.plan(1) + + const unoptimized = ` + function main (input) { + return anonymous(input) + } + ` + + const optimized = optimize(unoptimized) + + t.equal(optimized, ` + const main = anonymous + `) +}) + +test('optimize function x (input) { return asX() } to const x = asX', (t) => { + t.plan(1) + + const unoptimized = ` + function main (input) { + return anonymous0(input) + } + ` + + const optimized = optimize(unoptimized) + + t.equal(optimized, ` + const main = anonymous0 + `) +}) + +test('optimize all cases at once', (t) => { + t.plan(1) + + const unoptimized = ` + let json = '' + json += "B" + return json + ` + + const optimized = optimize(unoptimized) + + t.equal(optimized, ` + return "B" + `) +}) From d3790652e84fde0363ba22ef7f37dcbeb4b3623b Mon Sep 17 00:00:00 2001 From: uzlopak Date: Thu, 27 Jul 2023 19:49:46 +0200 Subject: [PATCH 2/4] handle consecutive json concats --- lib/optimize.js | 4 ++-- test/optimize.test.js | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/lib/optimize.js b/lib/optimize.js index 408e19be..3116b152 100644 --- a/lib/optimize.js +++ b/lib/optimize.js @@ -27,8 +27,8 @@ function optimizeJsonConcat (code) { for (let i = 0; i < code.length; i++) { if (i > 0 && jsonConcatRE.test(code[i]) && jsonConcatRE.test(code[i - 1])) { - const mergedEntry = code[i - 1] + ' +' + code[i].substring(code[i].indexOf('json +=') + 7) - optimizedJsonConcat.pop() // Remove the previous entry + const lastEntry = optimizedJsonConcat.pop() + const mergedEntry = lastEntry + ' +' + code[i].substring(code[i].indexOf('json +=') + 7) optimizedJsonConcat.push(mergedEntry) } else { optimizedJsonConcat.push(code[i]) diff --git a/test/optimize.test.js b/test/optimize.test.js index 8651fa03..7617a1e7 100644 --- a/test/optimize.test.js +++ b/test/optimize.test.js @@ -18,6 +18,24 @@ test('optimize consecutive "json +=" lines', (t) => { `) }) +test('optimize consecutive "json +=" lines', (t) => { + t.plan(1) + + const unoptimized = ` + json += "A" + json += "B" + json += "C" + Math.random() + ` + + const optimized = optimize(unoptimized) + + t.equal(optimized, ` + json += "A" + "B" + "C" + Math.random() + `) +}) + test('optimize consecutive "let json" and following "json +="', (t) => { t.plan(1) From a1ecb11efe5d450a02ba8dc8cb3f97b43d94a67f Mon Sep 17 00:00:00 2001 From: uzlopak Date: Thu, 27 Jul 2023 20:53:32 +0200 Subject: [PATCH 3/4] fix linting --- lib/optimize.js | 6 +++--- lib/serializer.js | 5 ++--- test/debug-mode.test.js | 2 +- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/lib/optimize.js b/lib/optimize.js index 3116b152..283e6c50 100644 --- a/lib/optimize.js +++ b/lib/optimize.js @@ -38,7 +38,7 @@ function optimizeJsonConcat (code) { return optimizedJsonConcat } -function optimizeLetJson(code) { +function optimizeLetJson (code) { const optimizedLetJsonCode = [] for (let i = 0; i < code.length; i++) { if (i > 0 && jsonConcatRE.test(code[i]) && letJsonRE.test(code[i - 1])) { @@ -52,7 +52,7 @@ function optimizeLetJson(code) { return optimizedLetJsonCode } -function optimizeDirectReturn(code) { +function optimizeDirectReturn (code) { const optimizedDirectReturnCode = [] for (let i = 0; i < code.length; i++) { if (i > 0 && returnJsonRE.test(code[i]) && letJsonRE.test(code[i - 1])) { @@ -66,7 +66,7 @@ function optimizeDirectReturn(code) { return optimizedDirectReturnCode } -function optimizeReturnEmptyString(code) { +function optimizeReturnEmptyString (code) { for (let i = 0; i < code.length; i++) { if (returnEmptyStringRE.test(code[i])) { code[i] = code[i].replace('return \'\' +', 'return') diff --git a/lib/serializer.js b/lib/serializer.js index 739d1862..868761a6 100644 --- a/lib/serializer.js +++ b/lib/serializer.js @@ -76,7 +76,7 @@ class Serializer { return Serializer.asStringSmall(str) } else if (STR_ESCAPE.test(str) === false) { return '"' + str + '"' - } else { + } else { return JSON.stringify(str) } } @@ -119,7 +119,6 @@ class Serializer { getState () { return this._options } - } class SerializerFloor extends Serializer { @@ -256,4 +255,4 @@ module.exports = SerializerFactory module.exports.Serializer = Serializer module.exports.restoreFromState = function restoreFromState (state) { return SerializerFactory(state) -} \ No newline at end of file +} diff --git a/test/debug-mode.test.js b/test/debug-mode.test.js index 15c2f8f2..68048101 100644 --- a/test/debug-mode.test.js +++ b/test/debug-mode.test.js @@ -7,7 +7,7 @@ const Ajv = require('ajv').default const Validator = require('../lib/validator') const { Serializer } = require('../lib/serializer') -function build(opts) { +function build (opts) { return fjs({ title: 'default string', type: 'object', From 7f9ee315ecda343396fc7c6e46f79b299e995442 Mon Sep 17 00:00:00 2001 From: uzlopak Date: Tue, 1 Aug 2023 19:47:20 +0200 Subject: [PATCH 4/4] fix linting --- index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/index.js b/index.js index f7043aed..80a500f1 100644 --- a/index.js +++ b/index.js @@ -542,7 +542,7 @@ function buildObject (context, location) { schemaRef = schemaRef.replace(context.rootSchemaId, '') } - let functionCode = ` + const functionCode = ` // ${schemaRef} function ${functionName} (input) { const obj = ${toJSON('input')}