diff --git a/README.md b/README.md index 596e6bd2..a49b4c7a 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,20 @@ JSON.stringify obj x 1,763,980 ops/sec ±1.30% (88 runs sampled) fast-json-stringify obj x 5,085,148 ops/sec ±1.56% (89 runs sampled) ``` +#### Table of contents: +- `Example` +- `API` + - `fastJsonStringify` + - `Specific use cases` + - `Required` + - `Missing fields` + - `Pattern Properties` + - `Additional Properties` +- `Acknowledgements` +- `License` + + + ## Example ```js @@ -48,9 +62,9 @@ console.log(stringify({ reg: /"([^"]|\\")*"/ })) ``` - + ## API - + ### fastJsonStringify(schema) Build a `stringify()` function based on @@ -68,6 +82,7 @@ Supported types: And nested ones, too. + #### Specific use cases | Instance | Serialized as | @@ -75,6 +90,7 @@ And nested ones, too. | `Date` | `string` via `toISOString()` | | `RegExp` | `string` | + #### Required You can set specific fields of an object as required in your schema, by adding the field name inside the `required` array in your schema. Example: @@ -95,6 +111,7 @@ const schema = { ``` If the object to stringify has not the required field(s), `fast-json-stringify` will throw an error. + #### Missing fields If a field *is present* in the schema (and is not required) but it *is not present* in the object to stringify, `fast-json-stringify` will not write it in the final string. Example: @@ -109,8 +126,7 @@ const stringify = fastJson({ mail: { type: 'string' } - }, - required: ['mail'] + } }) const obj = { @@ -120,6 +136,7 @@ const obj = { console.log(stringify(obj)) // '{"mail":"mail@example.com"}' ``` + #### Pattern properties `fast-json-stringify` supports pattern properties as defined inside JSON schema. *patternProperties* must be an object, where the key is a valid regex and the value is an object, declared in this way: `{ type: 'type' }`. @@ -151,13 +168,58 @@ const obj = { matchnum: 3 } -console.log(stringify(obj)) // '{"nickname":"nick","matchfoo":"42","otherfoo":"str","matchnum":3}' +console.log(stringify(obj)) // '{"matchfoo":"42","otherfoo":"str","matchnum":3,"nickname":"nick"}' +``` + + +#### Additional properties +`fast-json-stringify` supports additional properties as defined inside JSON schema. +*additionalProperties* must be an object or a boolean, declared in this way: `{ type: 'type' }`. +*additionalProperties* will work only for the properties that are not explicitly listed in the *properties* and *patternProperties* objects. + +If *additionalProperties* is not present or is setted to false, every property that is not explicitly listed in the *properties* and *patternProperties* objects, will be ignored, as said in Missing fields. +If *additionalProperties* is setted to *true*, it will be used `fast-safe-stringify` to stringify the additional properties. If you want to achieve maximum performances we strongly encourage you to use a fixed schema where possible. +Example: +```javascript +const stringify = fastJson({ + title: 'Example Schema', + type: 'object', + properties: { + nickname: { + type: 'string' + } + }, + patternProperties: { + 'num': { + type: 'number' + }, + '.*foo$': { + type: 'string' + } + }, + additionalProperties: { + type: 'string' + } +}) + +const obj = { + nickname: 'nick', + matchfoo: 42, + otherfoo: 'str' + matchnum: 3, + nomatchstr: 'valar morghulis', + nomatchint: 313 +} + +console.log(stringify(obj)) // '{"matchfoo":"42","otherfoo":"str","matchnum":3,"nomatchstr":"valar morghulis",nomatchint:"313","nickname":"nick"}' ``` + ## Acknowledgements This project was kindly sponsored by [nearForm](http://nearform.com). + ## License MIT diff --git a/example.js b/example.js index d6a60d6d..1835f52e 100644 --- a/example.js +++ b/example.js @@ -49,6 +49,9 @@ const stringify = fastJson({ 'test': { type: 'number' } + }, + additionalProperties: { + type: 'string' } }) @@ -63,5 +66,8 @@ console.log(stringify({ test: 42, strtest: '23', arr: [{ str: 'stark' }, { str: 'lannister' }], - obj: { bool: true } + obj: { bool: true }, + notmatch: 'valar morghulis', + notmatchobj: { a: true }, + notmatchnum: 42 })) diff --git a/index.js b/index.js index 760f497b..f6fdbaf9 100644 --- a/index.js +++ b/index.js @@ -1,5 +1,7 @@ 'use strict' +const fastSafeStringify = require('fast-safe-stringify') + function build (schema) { /* eslint no-new-func: "off" */ var code = ` @@ -51,7 +53,9 @@ function build (schema) { ; return ${main} ` - + if (schema.additionalProperties === true) { + return (new Function('fastSafeStringify', code))(fastSafeStringify) + } return (new Function(code))() } @@ -137,7 +141,7 @@ function $asRegExp (reg) { return '"' + reg + '"' } -function addPatternProperties (pp) { +function addPatternProperties (pp, ap) { let code = ` var keys = Object.keys(obj) for (var i = 0; i < keys.length; i++) { @@ -176,27 +180,87 @@ function addPatternProperties (pp) { ` } else { code += ` - throw new Error('Cannot coerce ' + obj[keys[i]] + ' to ${type}') + throw new Error('Cannot coerce ' + obj[keys[i]] + ' to ${type}') ` } + code += ` + continue } ` }) + if (ap) { + code += additionalProperty(ap) + } + code += ` } - if (Object.keys(properties).length === 0) json = json.substring(0, json.length - 1) ` return code } +function additionalProperty (ap) { + let code = '' + if (ap === true) { + return ` + json += $asString(keys[i]) + ':' + fastSafeStringify(obj[keys[i]]) + ',' + ` + } + let type = ap.type + if (type === 'object') { + code += buildObject(ap, '', 'buildObjectAP') + code += ` + json += $asString(keys[i]) + ':' + buildObjectAP(obj[keys[i]]) + ',' + ` + } else if (type === 'array') { + code += buildArray(ap, '', 'buildArrayAP') + code += ` + json += $asString(keys[i]) + ':' + buildArrayAP(obj[keys[i]]) + ',' + ` + } else if (type === 'null') { + code += ` + json += $asString(keys[i]) +':null,' + ` + } else if (type === 'string') { + code += ` + json += $asString(keys[i]) + ':' + $asString(obj[keys[i]]) + ',' + ` + } else if (type === 'number' || type === 'integer') { + code += ` + json += $asString(keys[i]) + ':' + $asNumber(obj[keys[i]]) + ',' + ` + } else if (type === 'boolean') { + code += ` + json += $asString(keys[i]) + ':' + $asBoolean(obj[keys[i]]) + ',' + ` + } else { + code += ` + throw new Error('Cannot coerce ' + obj[keys[i]] + ' to ${type}') + ` + } + return code +} + +function addAdditionalProperties (ap) { + return ` + var keys = Object.keys(obj) + for (var i = 0; i < keys.length; i++) { + if (properties[keys[i]]) continue + ${additionalProperty(ap)} + } + ` +} + function buildObject (schema, code, name) { code += ` function ${name} (obj) { var json = '{' ` + if (schema.patternProperties) { - code += addPatternProperties(schema.patternProperties) + code += addPatternProperties(schema.patternProperties, schema.additionalProperties) + } else if (schema.additionalProperties && !schema.patternProperties) { + code += addAdditionalProperties(schema.additionalProperties) } var laterCode = '' @@ -232,7 +296,9 @@ function buildObject (schema, code, name) { ` }) + // Removes the comma if is the last element of the string (in case there are not properties) code += ` + if (json[json.length - 1] === ',') json = json.substring(0, json.length - 1) json += '}' return json } diff --git a/package.json b/package.json index ef86878a..7bcb6c9f 100644 --- a/package.json +++ b/package.json @@ -29,5 +29,8 @@ "pre-commit": "^1.1.3", "standard": "^8.2.0", "tap": "^7.1.2" + }, + "dependencies": { + "fast-safe-stringify": "^1.1.0" } } diff --git a/test/additionalProperties.test.js b/test/additionalProperties.test.js new file mode 100644 index 00000000..783374e1 --- /dev/null +++ b/test/additionalProperties.test.js @@ -0,0 +1,182 @@ +'use strict' + +const test = require('tap').test +const build = require('..') + +test('additionalProperties', (t) => { + t.plan(1) + const stringify = build({ + title: 'additionalProperties', + type: 'object', + properties: { + str: { + type: 'string' + } + }, + additionalProperties: { + type: 'string' + } + }) + + let obj = { str: 'test', foo: 42, ofoo: true, foof: 'string', objfoo: {a: true} } + t.equal('{"foo":"42","ofoo":"true","foof":"string","objfoo":"[object Object]","str":"test"}', stringify(obj)) +}) + +test('additionalProperties should not change properties', (t) => { + t.plan(1) + const stringify = build({ + title: 'patternProperties should not change properties', + type: 'object', + properties: { + foo: { + type: 'string' + } + }, + additionalProperties: { + type: 'number' + } + }) + + const obj = { foo: '42', ofoo: 42 } + t.equal('{"ofoo":42,"foo":"42"}', stringify(obj)) +}) + +test('additionalProperties should not change properties and patternProperties', (t) => { + t.plan(1) + const stringify = build({ + title: 'patternProperties should not change properties', + type: 'object', + properties: { + foo: { + type: 'string' + } + }, + patternProperties: { + foo: { + type: 'string' + } + }, + additionalProperties: { + type: 'number' + } + }) + + const obj = { foo: '42', ofoo: 42, test: '42' } + t.equal('{"ofoo":"42","test":42,"foo":"42"}', stringify(obj)) +}) + +test('additionalProperties set to true, use of fast-safe-stringify', (t) => { + t.plan(1) + const stringify = build({ + title: 'check string coerce', + type: 'object', + properties: {}, + additionalProperties: true + }) + + const obj = { foo: true, ofoo: 42, arrfoo: ['array', 'test'], objfoo: { a: 'world' } } + t.equal('{"foo":true,"ofoo":42,"arrfoo":["array","test"],"objfoo":{"a":"world"}}', stringify(obj)) +}) + +test('additionalProperties - string coerce', (t) => { + t.plan(1) + const stringify = build({ + title: 'check string coerce', + type: 'object', + properties: {}, + additionalProperties: { + type: 'string' + } + }) + + const obj = { foo: true, ofoo: 42, arrfoo: ['array', 'test'], objfoo: { a: 'world' } } + t.equal('{"foo":"true","ofoo":"42","arrfoo":"array,test","objfoo":"[object Object]"}', stringify(obj)) +}) + +test('additionalProperties - number coerce', (t) => { + t.plan(1) + const stringify = build({ + title: 'check number coerce', + type: 'object', + properties: {}, + additionalProperties: { + type: 'number' + } + }) + + const obj = { foo: true, ofoo: '42', xfoo: 'string', arrfoo: [1, 2], objfoo: { num: 42 } } + t.equal('{"foo":1,"ofoo":42,"xfoo":null,"arrfoo":null,"objfoo":null}', stringify(obj)) +}) + +test('additionalProperties - boolean coerce', (t) => { + t.plan(1) + const stringify = build({ + title: 'check boolean coerce', + type: 'object', + properties: {}, + additionalProperties: { + type: 'boolean' + } + }) + + const obj = { foo: 'true', ofoo: 0, arrfoo: [1, 2], objfoo: { a: true } } + t.equal('{"foo":true,"ofoo":false,"arrfoo":true,"objfoo":true}', stringify(obj)) +}) + +test('additionalProperties - object coerce', (t) => { + t.plan(1) + const stringify = build({ + title: 'check object coerce', + type: 'object', + properties: {}, + additionalProperties: { + type: 'object', + properties: { + answer: { + type: 'number' + } + } + } + }) + + const obj = { objfoo: { answer: 42 } } + t.equal('{"objfoo":{"answer":42}}', stringify(obj)) +}) + +test('additionalProperties - array coerce', (t) => { + t.plan(1) + const stringify = build({ + title: 'check array coerce', + type: 'object', + properties: {}, + additionalProperties: { + type: 'array', + items: { + type: 'string' + } + } + }) + + const obj = { foo: 'true', ofoo: 0, arrfoo: [1, 2], objfoo: { tyrion: 'lannister' } } + t.equal('{"foo":["t","r","u","e"],"ofoo":[],"arrfoo":["1","2"],"objfoo":[]}', stringify(obj)) +}) + +test('additionalProperties - throw on unknown type', (t) => { + t.plan(1) + const stringify = build({ + title: 'check array coerce', + type: 'object', + properties: {}, + additionalProperties: { + type: 'strangetype' + } + }) + + const obj = { foo: 'true', ofoo: 0, arrfoo: [1, 2], objfoo: { tyrion: 'lannister' } } + try { + stringify(obj) + t.fail() + } catch (e) { + t.pass() + } +})