diff --git a/.pnp.js b/.pnp.js index 2de341c73..6182a193a 100755 --- a/.pnp.js +++ b/.pnp.js @@ -46,6 +46,10 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "name": "@lowdefy/graphql", "reference": "workspace:packages/graphql" }, + { + "name": "@lowdefy/serializer", + "reference": "workspace:packages/serializer" + }, { "name": "@lowdefy/type", "reference": "workspace:packages/type" @@ -60,6 +64,7 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ["@lowdefy/graphql", ["workspace:packages/graphql"]], ["@lowdefy/poc", ["workspace:."]], ["@lowdefy/poc-express", ["workspace:packages/express"]], + ["@lowdefy/serializer", ["workspace:packages/serializer"]], ["@lowdefy/type", ["workspace:packages/type"]], ["izlrmfxlki", ["workspace:packages/button"]] ], @@ -4254,6 +4259,28 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "linkType": "SOFT", }] ]], + ["@lowdefy/serializer", [ + ["workspace:packages/serializer", { + "packageLocation": "./packages/serializer/", + "packageDependencies": [ + ["@lowdefy/serializer", "workspace:packages/serializer"], + ["@babel/cli", "virtual:10c70c57b8c041994c932fbc6065790a1801b95ea6f8bb47c7334c5a9a57ca5035df7a515298306323a03d95e4c0d72d9488454f45837690b0620ca3a249c7f5#npm:7.8.4"], + ["@babel/compat-data", "npm:7.9.6"], + ["@babel/core", "npm:7.9.6"], + ["@babel/preset-env", "virtual:10c70c57b8c041994c932fbc6065790a1801b95ea6f8bb47c7334c5a9a57ca5035df7a515298306323a03d95e4c0d72d9488454f45837690b0620ca3a249c7f5#npm:7.9.6"], + ["@lowdefy/type", "workspace:packages/type"], + ["babel-jest", "virtual:10c70c57b8c041994c932fbc6065790a1801b95ea6f8bb47c7334c5a9a57ca5035df7a515298306323a03d95e4c0d72d9488454f45837690b0620ca3a249c7f5#npm:24.9.0"], + ["eslint", "npm:6.8.0"], + ["eslint-config-airbnb", "virtual:10c70c57b8c041994c932fbc6065790a1801b95ea6f8bb47c7334c5a9a57ca5035df7a515298306323a03d95e4c0d72d9488454f45837690b0620ca3a249c7f5#npm:18.2.0"], + ["eslint-config-prettier", "virtual:73f25cc0d3f57943fa9b1d737e4809af7a52a784e0ac5fed74b4e1e083308ab7ae2fd45a5424a8bc7ff7caab067690c9357630d657cbd636d6037acc1557fdc2#npm:6.12.0"], + ["eslint-plugin-prettier", "virtual:73f25cc0d3f57943fa9b1d737e4809af7a52a784e0ac5fed74b4e1e083308ab7ae2fd45a5424a8bc7ff7caab067690c9357630d657cbd636d6037acc1557fdc2#npm:3.1.4"], + ["jest", "npm:24.9.0"], + ["jest-diff", "npm:24.9.0"], + ["prettier", "npm:2.1.2"] + ], + "linkType": "SOFT", + }] + ]], ["@lowdefy/type", [ ["npm:1.0.0", { "packageLocation": "./.yarn/cache/@lowdefy-type-npm-1.0.0-5c152b70b4-06668d6f58.zip/node_modules/@lowdefy/type/", diff --git a/packages/serializer/.babelrc b/packages/serializer/.babelrc new file mode 100644 index 000000000..1650aad60 --- /dev/null +++ b/packages/serializer/.babelrc @@ -0,0 +1,13 @@ +{ + "presets": [ + [ + "@babel/preset-env", + { + "targets": { + "node": "10", + "esmodules": true + } + } + ] + ] +} diff --git a/packages/serializer/jest.config.js b/packages/serializer/jest.config.js new file mode 100644 index 000000000..b3db30fb9 --- /dev/null +++ b/packages/serializer/jest.config.js @@ -0,0 +1,19 @@ +module.exports = { + // Automatically clear mock calls and instances between every test + clearMocks: true, + + // The directory where Jest should output its coverage files + coverageDirectory: 'coverage', + + // An array of regexp pattern strings used to skip coverage collection + coveragePathIgnorePatterns: ['/node_modules/', '/dist/'], + + // A list of reporter names that Jest uses when writing coverage reports + coverageReporters: ['text'], + + // The test environment that will be used for testing + testEnvironment: 'node', + + // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped + testPathIgnorePatterns: ['/node_modules/', '/dist/'], +}; diff --git a/packages/serializer/package.json b/packages/serializer/package.json new file mode 100644 index 000000000..cf4b5e172 --- /dev/null +++ b/packages/serializer/package.json @@ -0,0 +1,29 @@ +{ + "name": "@lowdefy/serializer", + "version": "1.0.0", + "license": "MIT", + "main": "dist/index.js", + "scripts": { + "build": "babel src --out-dir dist", + "test": "jest --coverage", + "prepare": "yarn build", + "prepublishOnly": "yarn build" + }, + "dependencies": { + "@lowdefy/type": "1.0.1" + }, + "devDependencies": { + "@babel/cli": "7.8.4", + "@babel/compat-data": "7.9.6", + "@babel/core": "7.9.6", + "@babel/preset-env": "7.9.6", + "babel-jest": "24.9.0", + "eslint": "6.8.0", + "eslint-config-airbnb": "18.2.0", + "eslint-config-prettier": "6.12.0", + "eslint-plugin-prettier": "3.1.4", + "jest": "24.9.0", + "jest-diff": "24.9.0", + "prettier": "2.1.2" + } +} diff --git a/packages/serializer/src/index.js b/packages/serializer/src/index.js new file mode 100644 index 000000000..a9b52e77d --- /dev/null +++ b/packages/serializer/src/index.js @@ -0,0 +1,111 @@ +/* eslint-disable no-param-reassign */ +import type from '@lowdefy/type'; + +import stableStringify from './stableStringify'; + +const makeReplacer = (customReplacer, isoStringDates) => (key, value) => { + let dateReplacer = (date) => ({ _date: date.valueOf() }); + if (isoStringDates) { + dateReplacer = (date) => ({ _date: date.toISOString() }); + } + let newValue = value; + if (customReplacer) { + newValue = customReplacer(key, value); + } + if (type.isObject(newValue)) { + Object.keys(newValue).forEach((k) => { + if (type.isDate(newValue[k])) { + // shallow copy original value before reassigning a value in order not to mutate original value + newValue = { ...newValue }; + newValue[k] = dateReplacer(newValue[k]); + } + }); + return newValue; + } + if (type.isArray(newValue)) { + return newValue.map((item) => { + if (type.isDate(item)) { + return dateReplacer(item); + } + return item; + }); + } + return newValue; +}; + +const makeReviver = (customReviver) => (key, value) => { + let newValue = value; + if (customReviver) { + newValue = customReviver(key, value); + } + if (type.isObject(newValue) && !type.isUndefined(newValue._date)) { + if (type.isInt(newValue._date)) { + return new Date(newValue._date); + } + if (newValue._date === 'now') { + return newValue; + } + const result = new Date(newValue._date); + if (!type.isDate(result)) { + return newValue; + } + return result; + } + return newValue; +}; + +const serialize = (json, options = {}) => { + if (type.isUndefined(json)) return json; + if (type.isDate(json)) { + if (options.isoStringDates) { + return { _date: json.toISOString() }; + } + return { _date: json.valueOf() }; + } + return JSON.parse(JSON.stringify(json, makeReplacer(options.replacer, options.isoStringDates))); +}; + +const serializeToString = (json, options = {}) => { + if (type.isUndefined(json)) return json; + + if (type.isDate(json)) { + if (options.isoStringDates) { + return `{ "_date": "${json.toISOString()}" }`; + } + return `{ "_date": ${json.valueOf()} }`; + } + if (options.stable) { + return stableStringify(json, { + replacer: makeReplacer(options.replacer), + space: options.space, + }); + } + return JSON.stringify( + json, + makeReplacer(options.replacer, options.isoStringDates), + options.space + ); +}; + +const deserialize = (json, options = {}) => { + if (type.isUndefined(json)) return json; + return JSON.parse(JSON.stringify(json), makeReviver(options.reviver)); +}; + +const deserializeFromString = (str, options = {}) => { + if (type.isUndefined(str)) return str; + return JSON.parse(str, makeReviver(options.reviver)); +}; + +const copy = (json, options = {}) => { + if (type.isUndefined(json)) return undefined; + if (type.isDate(json)) return new Date(json.valueOf()); + + return JSON.parse( + JSON.stringify(json, makeReplacer(options.replacer)), + makeReviver(options.reviver) + ); +}; + +const serializer = { copy, serialize, serializeToString, deserialize, deserializeFromString }; +export default serializer; diff --git a/packages/serializer/src/stableStringify.js b/packages/serializer/src/stableStringify.js new file mode 100644 index 000000000..6a4ffc39d --- /dev/null +++ b/packages/serializer/src/stableStringify.js @@ -0,0 +1,95 @@ +/* eslint-disable no-continue */ +/* eslint-disable no-plusplus */ +/* eslint-disable consistent-return */ +/* eslint-disable no-param-reassign */ + +import type from '@lowdefy/type'; + +// https://github.com/substack/json-stable-stringify +// https://github.com/substack/json-stable-stringify/LICENCE + +// This software is released under the MIT license: + +// Permission is hereby granted, free of charge, to any person obtaining a copy of +// this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to +// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +// the Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +function stableStringify(obj, opts) { + if (!opts) opts = {}; + if (typeof opts === 'function') opts = { cmp: opts }; + let space = opts.space || ''; + if (typeof space === 'number') space = Array(space + 1).join(' '); + const cycles = typeof opts.cycles === 'boolean' ? opts.cycles : false; + const replacer = opts.replacer || ((key, value) => value); + const cmp = + opts.cmp && + ((f) => { + return (node) => { + return (a, b) => { + const aobj = { key: a, value: node[a] }; + const bobj = { key: b, value: node[b] }; + return f(aobj, bobj); + }; + }; + })(opts.cmp); + + const seen = []; + return (function stringify(parent, key, node, level) { + const indent = space ? `\n${new Array(level + 1).join(space)}` : ''; + const colonSeparator = space ? ': ' : ':'; + + if (node && node.toJSON && typeof node.toJSON === 'function') { + node = node.toJSON(); + } + + node = replacer.call(parent, key, node); + + if (node === undefined) { + return; + } + if (typeof node !== 'object' || node === null) { + return JSON.stringify(node); + } + if (type.isArray(node)) { + const out = []; + for (let i = 0; i < node.length; i++) { + const item = stringify(node, i, node[i], level + 1) || JSON.stringify(null); + out.push(indent + space + item); + } + return `[${out.join(',')}${indent}]`; + } + if (seen.indexOf(node) !== -1) { + if (cycles) return JSON.stringify('__cycle__'); + throw new TypeError('Converting circular structure to JSON'); + } else seen.push(node); + + const keys = Object.keys(node).sort(cmp && cmp(node)); + const out = []; + for (let i = 0; i < keys.length; i++) { + const ky = keys[i]; + const value = stringify(node, ky, node[ky], level + 1); + + if (!value) continue; + + const keyValue = JSON.stringify(ky) + colonSeparator + value; + out.push(indent + space + keyValue); + } + seen.splice(seen.indexOf(node), 1); + return `{${out.join(',')}${indent}}`; + })({ '': obj }, '', obj, 0); +} + +export default stableStringify; diff --git a/packages/serializer/test/serializer.test.js b/packages/serializer/test/serializer.test.js new file mode 100644 index 000000000..b1b50c28b --- /dev/null +++ b/packages/serializer/test/serializer.test.js @@ -0,0 +1,452 @@ +import type from '@lowdefy/type'; + +import serializer from '../src/index'; + +test('serialize convert object js date to _date', () => { + let object = { + a: new Date(0), + }; + expect(serializer.serialize(object)).toEqual({ a: { _date: 0 } }); + object = { + a: { b: { c: new Date(120) } }, + }; + expect(serializer.serialize(object)).toEqual({ a: { b: { c: { _date: 120 } } } }); +}); + +test('serialize convert array js date to _date', () => { + let object = { + a: [new Date(0)], + }; + expect(serializer.serialize(object)).toEqual({ a: [{ _date: 0 }] }); + object = { + a: [{ b: new Date(0), c: [null, new Date(10)], d: [{ e: new Date(20) }] }], + }; + expect(serializer.serialize(object)).toEqual({ + a: [{ b: { _date: 0 }, c: [null, { _date: 10 }], d: [{ e: { _date: 20 } }] }], + }); +}); + +test('serialize should not change a string date', () => { + let object = { + a: '2019-11-18T09:51:30.152Z', + }; + expect(serializer.serialize(object)).toEqual(object); + object = ['2019-11-18T09:51:30.152Z']; + expect(serializer.serialize(object)).toEqual(object); +}); + +test('serialize a date should return a serialized date', () => { + expect(serializer.serialize(new Date(0))).toEqual({ _date: 0 }); +}); + +test('serialize array of dates should return a serialized array', () => { + expect(serializer.serialize([new Date(0), new Date(1), new Date(2)])).toEqual([ + { _date: 0 }, + { _date: 1 }, + { _date: 2 }, + ]); +}); + +test('serialize primitives should pass', () => { + expect(serializer.serialize('a')).toEqual('a'); + expect(serializer.serialize(0)).toEqual(0); + expect(serializer.serialize(1)).toEqual(1); + expect(serializer.serialize(-0.1)).toEqual(-0.1); + expect(serializer.serialize(false)).toEqual(false); + expect(serializer.serialize(true)).toEqual(true); + expect(serializer.serialize(null)).toEqual(null); + expect(serializer.serialize(undefined)).toEqual(undefined); +}); + +test('serializeToString convert object js date to _date', () => { + let object = { + a: new Date(0), + }; + expect(serializer.serializeToString(object)).toMatchInlineSnapshot(`"{\\"a\\":{\\"_date\\":0}}"`); + object = { + a: { b: { c: new Date(120) } }, + }; + expect(serializer.serializeToString(object)).toMatchInlineSnapshot( + `"{\\"a\\":{\\"b\\":{\\"c\\":{\\"_date\\":120}}}}"` + ); +}); + +test('serializeToString convert array js date to _date', () => { + let object = { + a: [new Date(0)], + }; + expect(serializer.serializeToString(object)).toMatchInlineSnapshot( + `"{\\"a\\":[{\\"_date\\":0}]}"` + ); + object = { + a: [{ b: new Date(0), c: [null, new Date(10)], d: [{ e: new Date(20) }] }], + }; + expect(serializer.serializeToString(object)).toMatchInlineSnapshot( + `"{\\"a\\":[{\\"b\\":{\\"_date\\":0},\\"c\\":[null,{\\"_date\\":10}],\\"d\\":[{\\"e\\":{\\"_date\\":20}}]}]}"` + ); +}); + +test('serializeToString should not change a string date', () => { + let object = { + a: '2019-11-18T09:51:30.152Z', + }; + expect(serializer.serializeToString(object)).toMatchInlineSnapshot( + `"{\\"a\\":\\"2019-11-18T09:51:30.152Z\\"}"` + ); + object = ['2019-11-18T09:51:30.152Z']; + expect(serializer.serializeToString(object)).toMatchInlineSnapshot( + `"[\\"2019-11-18T09:51:30.152Z\\"]"` + ); +}); + +test('serializeToString a date should return a serialized date', () => { + expect(serializer.serializeToString(new Date(0))).toMatchInlineSnapshot(`"{ \\"_date\\": 0 }"`); +}); + +test('serialize array of dates should return a serialized array', () => { + expect( + serializer.serializeToString([new Date(0), new Date(1), new Date(2)]) + ).toMatchInlineSnapshot(`"[{\\"_date\\":0},{\\"_date\\":1},{\\"_date\\":2}]"`); +}); + +test('serializeToString primitives should pass', () => { + expect(serializer.serializeToString('a')).toMatchInlineSnapshot(`"\\"a\\""`); + expect(serializer.serializeToString(0)).toMatchInlineSnapshot(`"0"`); + expect(serializer.serializeToString(1)).toMatchInlineSnapshot(`"1"`); + expect(serializer.serializeToString(-0.1)).toMatchInlineSnapshot(`"-0.1"`); + expect(serializer.serializeToString(false)).toMatchInlineSnapshot(`"false"`); + expect(serializer.serializeToString(true)).toMatchInlineSnapshot(`"true"`); + expect(serializer.serializeToString(null)).toMatchInlineSnapshot(`"null"`); + expect(serializer.serializeToString(undefined)).toEqual(undefined); +}); + +test('serializeToString stable option', () => { + let object = { + a: 'a', + b: 'b', + c: 'c', + }; + expect(serializer.serializeToString(object, { stable: true })).toEqual( + '{"a":"a","b":"b","c":"c"}' + ); + object = { + c: 'c', + b: 'b', + a: 'a', + }; + expect(serializer.serializeToString(object, { stable: true })).toEqual( + '{"a":"a","b":"b","c":"c"}' + ); + object = { + c: 'c', + b: 'b', + a: 'a', + }; + expect(serializer.serializeToString(object, { stable: false })).toEqual( + '{"c":"c","b":"b","a":"a"}' + ); +}); + +test('serializeToString stable and space option', () => { + const object = { + c: 'c', + b: 'b', + a: 'a', + }; + expect(serializer.serializeToString(object, { stable: true, space: 2 })).toEqual(`{ + "a": "a", + "b": "b", + "c": "c" +}`); +}); + +test('serializeToString space option', () => { + const object = { + c: 'c', + b: 'b', + a: 'a', + }; + expect(serializer.serializeToString(object, { space: 2 })).toEqual(`{ + "c": "c", + "b": "b", + "a": "a" +}`); +}); + +test('deserialize convert _date object to js date', () => { + let object = { + a: { _date: 0 }, + }; + expect(serializer.deserialize(object)).toEqual({ a: new Date(0) }); + object = { + a: { b: { c: { _date: 120 } } }, + }; + expect(serializer.deserialize(object)).toEqual({ a: { b: { c: new Date(120) } } }); + object = { + a: { _date: '1970-01-01T00:00:00.000Z' }, + }; + expect(serializer.deserialize(object)).toEqual({ a: new Date(0) }); +}); + +test('deserialize convert _date in array to js date', () => { + let object = { + a: [{ _date: 0 }], + }; + expect(serializer.deserialize(object)).toEqual({ a: [new Date(0)] }); + object = { + a: [{ b: { _date: 0 }, c: [null, { _date: 10 }], d: [{ e: { _date: 20 } }] }], + }; + expect(serializer.deserialize(object)).toEqual({ + a: [{ b: new Date(0), c: [null, new Date(10)], d: [{ e: new Date(20) }] }], + }); +}); + +test('deserialize should not change a string date', () => { + let object = { + a: '2019-11-18T09:51:30.152Z', + }; + expect(serializer.deserialize(object)).toEqual(object); + object = ['2019-11-18T09:51:30.152Z']; + expect(serializer.deserialize(object)).toEqual(object); +}); + +test('deserialize a _date date should return a js date', () => { + expect(serializer.deserialize({ _date: 0 })).toEqual(new Date(0)); + expect(serializer.deserialize({ _date: '1970-01-01T00:00:00.000Z' })).toEqual(new Date(0)); +}); + +test('deserialize array of _date dates should return a js date array', () => { + expect(serializer.deserialize([{ _date: 0 }, { _date: 1 }, { _date: 2 }])).toEqual([ + new Date(0), + new Date(1), + new Date(2), + ]); + expect(serializer.deserialize({ a: [{ _date: '1970-01-01T00:00:00.000Z' }] })).toEqual({ + a: [new Date(0)], + }); +}); + +test('deserialize primitives should pass', () => { + expect(serializer.deserialize('a')).toEqual('a'); + expect(serializer.deserialize(0)).toEqual(0); + expect(serializer.deserialize(1)).toEqual(1); + expect(serializer.deserialize(-0.1)).toEqual(-0.1); + expect(serializer.deserialize(false)).toEqual(false); + expect(serializer.deserialize(true)).toEqual(true); + expect(serializer.deserialize(null)).toEqual(null); + expect(serializer.deserialize(undefined)).toEqual(undefined); +}); + +test('deserializeFromString convert object js date to _date', () => { + let object = `{ "a":{ "_date":0}}`; + expect(serializer.deserializeFromString(object)).toEqual({ + a: new Date(0), + }); + object = `{"a":{"b":{"c":{"_date":120}}}}`; + expect(serializer.deserializeFromString(object)).toEqual({ + a: { b: { c: new Date(120) } }, + }); + object = `{ "a":{ "_date":"1970-01-01T00:00:00.000Z"}}`; + expect(serializer.deserializeFromString(object)).toEqual({ + a: new Date(0), + }); +}); + +test('deserializeFromString convert array js date to _date', () => { + let object = `{"a":[{"_date":0}]}`; + expect(serializer.deserializeFromString(object)).toEqual({ + a: [new Date(0)], + }); + object = `{"a":[{"b":{"_date":0},"c":[null,{"_date":10}],"d":[{"e":{"_date":20}}]}]}`; + expect(serializer.deserializeFromString(object)).toEqual({ + a: [{ b: new Date(0), c: [null, new Date(10)], d: [{ e: new Date(20) }] }], + }); +}); + +test('deserializeFromString should not change a string date', () => { + let object = `{"a":"2019-11-18T09:51:30.152Z"}`; + expect(serializer.deserializeFromString(object)).toEqual({ + a: '2019-11-18T09:51:30.152Z', + }); + object = `["2019-11-18T09:51:30.152Z"]`; + expect(serializer.deserializeFromString(object)).toEqual(['2019-11-18T09:51:30.152Z']); +}); + +test('deserializeFromString a date should return a serialized date', () => { + expect(serializer.deserializeFromString(`{"_date":0}`)).toEqual(new Date(0)); +}); + +test('deserializeFromString array of dates should return a serialized array', () => { + expect(serializer.deserializeFromString(`[{"_date":0},{"_date":1},{"_date":2}]`)).toEqual([ + new Date(0), + new Date(1), + new Date(2), + ]); + expect(serializer.deserializeFromString(`[{"_date":"1970-01-01T00:00:00.000Z"}]`)).toEqual([ + new Date(0), + ]); +}); + +test('deserializeFromString primitives should pass', () => { + expect(serializer.deserializeFromString(`"a"`)).toEqual('a'); + expect(serializer.deserializeFromString(`0`)).toEqual(0); + expect(serializer.deserializeFromString(`1`)).toEqual(1); + expect(serializer.deserializeFromString(`-0.1`)).toEqual(-0.1); + expect(serializer.deserializeFromString(`false`)).toEqual(false); + expect(serializer.deserializeFromString(`true`)).toEqual(true); + expect(serializer.deserializeFromString(`null`)).toEqual(null); + expect(() => { + serializer.deserializeFromString(`undefined}`); + }).toThrow(); // does not work with undefined +}); + +test('Do not modify original object', () => { + const a = { a: { b: [{ c: new Date(0) }] } }; + expect(serializer.deserializeFromString(serializer.serializeToString(a))).toEqual(a); +}); + +test('Undefined values in object', () => { + const a = { a: undefined }; + expect(serializer.serialize(a)).toEqual({}); + expect(serializer.serializeToString(a)).toEqual('{}'); +}); + +test('deserializeFromString undefined', () => { + const a = undefined; + expect(serializer.deserializeFromString(a)).toEqual(undefined); +}); + +test('copy an object', () => { + const object = { + date: new Date(0), + array: [1, 'a', true, new Date(1)], + string: 'hello', + number: 42, + }; + expect(serializer.copy(object)).toEqual({ + date: new Date(0), + array: [1, 'a', true, new Date(1)], + string: 'hello', + number: 42, + }); +}); + +test('copy undefined', () => { + expect(serializer.copy(undefined)).toEqual(undefined); +}); + +test('copy a date', () => { + expect(serializer.copy(new Date(0))).toEqual(new Date(0)); +}); + +test('copy with custom reviver', () => { + const reviver = (key, value) => (key === 'a' ? 'x' : value); + expect(serializer.copy({ a: 1, b: 2, c: new Date(120) }, { reviver })).toEqual({ + a: 'x', + b: 2, + c: new Date(120), + }); +}); + +test('deserializeFromString with custom reviver', () => { + const reviver = (key, value) => (key === 'a' ? 'x' : value); + expect( + serializer.deserializeFromString('{ "a": 1, "b": 2, "c": {"_date": 120 } }', { reviver }) + ).toEqual({ + a: 'x', + b: 2, + c: new Date(120), + }); +}); + +test('deserialize with custom reviver', () => { + const reviver = (key, value) => (key === 'a' ? 'x' : value); + expect(serializer.deserialize({ a: 1, b: 2, c: { _date: 120 } }, { reviver })).toEqual({ + a: 'x', + b: 2, + c: new Date(120), + }); +}); + +test('copy with custom replacer', () => { + const replacer = (key, value) => (key === 'a' ? 'x' : value); + expect(serializer.copy({ a: 1, b: 2, c: new Date(120) }, { replacer })).toEqual({ + a: 'x', + b: 2, + c: new Date(120), + }); +}); + +test('serializeToString with custom replacer', () => { + const replacer = (key, value) => (key === 'a' ? 'x' : value); + expect(serializer.serializeToString({ a: 1, b: 2, c: new Date(120) }, { replacer })).toEqual( + '{"a":"x","b":2,"c":{"_date":120}}' + ); +}); + +test('serialize with custom replacer', () => { + const replacer = (key, value) => (key === 'a' ? 'x' : value); + expect(serializer.serialize({ a: 1, b: 2, c: new Date(120) }, { replacer })).toEqual({ + a: 'x', + b: 2, + c: { _date: 120 }, + }); +}); + +test('should not deserialize _date now', () => { + expect(serializer.copy({ _date: 'now' })).toEqual({ _date: 'now' }); + expect(serializer.copy([{ _date: 'now' }])).toEqual([{ _date: 'now' }]); + expect(serializer.copy({ a: { _date: 'now' } })).toEqual({ a: { _date: 'now' } }); + + expect(serializer.deserialize({ _date: 'now' })).toEqual({ _date: 'now' }); + expect(serializer.deserialize([{ _date: 'now' }])).toEqual([{ _date: 'now' }]); + expect(serializer.deserialize({ a: { _date: 'now' } })).toEqual({ a: { _date: 'now' } }); + + expect(serializer.deserializeFromString('{ "_date": "now" }')).toEqual({ _date: 'now' }); + expect(serializer.deserializeFromString('[{ "_date": "now" }]')).toEqual([{ _date: 'now' }]); + expect(serializer.deserializeFromString('{ "a": { "_date": "now" } }')).toEqual({ + a: { _date: 'now' }, + }); +}); + +test('deserialize should pass invalid date through without error', () => { + expect(serializer.copy({ _date: 'invalid' })).toEqual({ _date: 'invalid' }); + expect(serializer.copy([{ _date: 'invalid' }])).toEqual([{ _date: 'invalid' }]); + expect(serializer.copy({ a: { _date: 'invalid' } })).toEqual({ a: { _date: 'invalid' } }); + + expect(serializer.deserialize({ _date: 'invalid' })).toEqual({ _date: 'invalid' }); + expect(serializer.deserialize([{ _date: 'invalid' }])).toEqual([{ _date: 'invalid' }]); + expect(serializer.deserialize({ a: { _date: 'invalid' } })).toEqual({ a: { _date: 'invalid' } }); + + expect(serializer.deserializeFromString('{ "_date": "invalid" }')).toEqual({ _date: 'invalid' }); + expect(serializer.deserializeFromString('[{ "_date": "invalid" }]')).toEqual([ + { _date: 'invalid' }, + ]); + expect(serializer.deserializeFromString('{ "a": { "_date": "invalid" } }')).toEqual({ + a: { _date: 'invalid' }, + }); +}); + +test('serialize isoStringDates', () => { + expect(serializer.serialize(new Date(0), { isoStringDates: true })).toEqual({ + _date: '1970-01-01T00:00:00.000Z', + }); + expect(serializer.serialize({ a: new Date(0) }, { isoStringDates: true })).toEqual({ + a: { _date: '1970-01-01T00:00:00.000Z' }, + }); + expect(serializer.serialize({ a: [new Date(0)] }, { isoStringDates: true })).toEqual({ + a: [{ _date: '1970-01-01T00:00:00.000Z' }], + }); +}); + +test('serializeToString isoStringDates', () => { + expect(serializer.serializeToString(new Date(0), { isoStringDates: true })).toEqual( + '{ "_date": "1970-01-01T00:00:00.000Z" }' + ); + expect(serializer.serializeToString({ a: new Date(0) }, { isoStringDates: true })).toEqual( + '{"a":{"_date":"1970-01-01T00:00:00.000Z"}}' + ); + expect(serializer.serializeToString({ a: [new Date(0)] }, { isoStringDates: true })).toEqual( + '{"a":[{"_date":"1970-01-01T00:00:00.000Z"}]}' + ); +}); diff --git a/packages/serializer/test/stableStringify.test.js b/packages/serializer/test/stableStringify.test.js new file mode 100644 index 000000000..a156a200d --- /dev/null +++ b/packages/serializer/test/stableStringify.test.js @@ -0,0 +1,244 @@ +// https://github.com/substack/json-stable-stringify +import stableStringify from '../src/stableStringify'; + +test('sort keys', () => { + let object = { + a: 'a', + b: 'b', + c: 'c', + }; + expect(stableStringify(object)).toEqual('{"a":"a","b":"b","c":"c"}'); + object = { + c: 'c', + b: 'b', + a: 'a', + }; + expect(stableStringify(object)).toEqual('{"a":"a","b":"b","c":"c"}'); +}); + +test('toJSON function', () => { + const obj = { + one: 1, + two: 2, + toJSON: () => { + return { one: 1 }; + }, + }; + expect(stableStringify(obj)).toEqual('{"one":1}'); +}); + +test('toJSON returns string', () => { + const obj = { + one: 1, + two: 2, + toJSON: () => { + return 'one'; + }, + }; + expect(stableStringify(obj)).toEqual('"one"'); +}); + +test('toJSON returns array', () => { + const obj = { + one: 1, + two: 2, + toJSON: () => { + return ['one']; + }, + }; + expect(stableStringify(obj)).toEqual('["one"]'); +}); + +// https://github.com/epoberezkin/fast-json-stable-stableStringify/blob/master/test/str.js + +test('simple object', () => { + const obj = { c: 6, b: [4, 5], a: 3, z: null }; + expect(stableStringify(obj)).toEqual('{"a":3,"b":[4,5],"c":6,"z":null}'); +}); + +test('object with undefined', () => { + const obj = { a: 3, z: undefined }; + expect(stableStringify(obj)).toEqual('{"a":3}'); +}); + +test('object with null', () => { + const obj = { a: 3, z: null }; + expect(stableStringify(obj)).toEqual('{"a":3,"z":null}'); +}); + +test('object with NaN and Infinity', () => { + const obj = { a: 3, b: NaN, c: Infinity }; + expect(stableStringify(obj)).toEqual('{"a":3,"b":null,"c":null}'); +}); + +test('array with undefined', () => { + const obj = [4, undefined, 6]; + expect(stableStringify(obj)).toEqual('[4,null,6]'); +}); + +test('object with empty string', () => { + const obj = { a: 3, z: '' }; + expect(stableStringify(obj)).toEqual('{"a":3,"z":""}'); +}); + +test('array with empty string', () => { + const obj = [4, '', 6]; + expect(stableStringify(obj)).toEqual('[4,"",6]'); +}); + +test('nested', () => { + const obj = { c: 8, b: [{ z: 6, y: 5, x: 4 }, 7], a: 3 }; + expect(stableStringify(obj)).toEqual('{"a":3,"b":[{"x":4,"y":5,"z":6},7],"c":8}'); +}); + +test('cyclic (default)', () => { + const one = { a: 1 }; + const two = { a: 2, one }; + one.two = two; + try { + stableStringify(one); + } catch (ex) { + expect(ex.toString()).toEqual('TypeError: Converting circular structure to JSON'); + } +}); + +test('cyclic (specifically allowed)', () => { + const one = { a: 1 }; + const two = { a: 2, one }; + one.two = two; + expect(stableStringify(one, { cycles: true })).toEqual('{"a":1,"two":{"a":2,"one":"__cycle__"}}'); +}); + +test('repeated non-cyclic value', () => { + const one = { x: 1 }; + const two = { a: one, b: one }; + expect(stableStringify(two)).toEqual('{"a":{"x":1},"b":{"x":1}}'); +}); + +test('acyclic but with reused obj-property pointers', () => { + const x = { a: 1 }; + const y = { b: x, c: x }; + expect(stableStringify(y)).toEqual('{"b":{"a":1},"c":{"a":1}}'); +}); + +test('custom comparison function', () => { + const obj = { c: 8, b: [{ z: 6, y: 5, x: 4 }, 7], a: 3 }; + const s = stableStringify(obj, (a, b) => { + return a.key < b.key ? 1 : -1; + }); + expect(s).toEqual('{"c":8,"b":[{"z":6,"y":5,"x":4},7],"a":3}'); +}); + +test('replace root', () => { + const obj = { a: 1, b: 2, c: false }; + const replacer = () => { + return 'one'; + }; + + expect(stableStringify(obj, { replacer })).toEqual('"one"'); +}); + +test('replace numbers', () => { + const obj = { a: 1, b: 2, c: false }; + const replacer = (key, value) => { + if (value === 1) return 'one'; + if (value === 2) return 'two'; + return value; + }; + + expect(stableStringify(obj, { replacer })).toEqual('{"a":"one","b":"two","c":false}'); +}); + +test('replace with object', () => { + const obj = { a: 1, b: 2, c: false }; + const replacer = (key, value) => { + if (key === 'b') return { d: 1 }; + if (value === 1) return 'one'; + return value; + }; + + expect(stableStringify(obj, { replacer })).toEqual('{"a":"one","b":{"d":"one"},"c":false}'); +}); + +test('replace with undefined', () => { + const obj = { a: 1, b: 2, c: false }; + const replacer = (key, value) => { + if (value === false) return undefined; + return value; + }; + + expect(stableStringify(obj, { replacer })).toEqual('{"a":1,"b":2}'); +}); + +test('replace with array', () => { + const obj = { a: 1, b: 2, c: false }; + const replacer = (key, value) => { + if (key === 'b') return ['one', 'two']; + return value; + }; + + expect(stableStringify(obj, { replacer })).toEqual('{"a":1,"b":["one","two"],"c":false}'); +}); + +test('replace array item', () => { + const obj = { a: 1, b: 2, c: [1, 2] }; + const replacer = (key, value) => { + if (value === 1) return 'one'; + if (value === 2) return 'two'; + return value; + }; + + expect(stableStringify(obj, { replacer })).toEqual('{"a":"one","b":"two","c":["one","two"]}'); +}); + +test('space parameter', () => { + const obj = { one: 1, two: 2 }; + expect(stableStringify(obj, { space: ' ' })).toMatchInlineSnapshot(` + "{ + \\"one\\": 1, + \\"two\\": 2 + }" + `); +}); + +test('space parameter (with tabs)', () => { + const obj = { one: 1, two: 2 }; + expect(stableStringify(obj, { space: '\t' })).toMatchInlineSnapshot(` + "{ + \\"one\\": 1, + \\"two\\": 2 + }" + `); +}); + +test('space parameter (with a number)', () => { + const obj = { one: 1, two: 2 }; + expect(stableStringify(obj, { space: 3 })).toMatchInlineSnapshot(` + "{ + \\"one\\": 1, + \\"two\\": 2 + }" + `); +}); + +test('space parameter (nested objects)', () => { + const obj = { one: 1, two: { b: 4, a: [2, 3] } }; + expect(stableStringify(obj, { space: ' ' })).toMatchInlineSnapshot(` + "{ + \\"one\\": 1, + \\"two\\": { + \\"a\\": [ + 2, + 3 + ], + \\"b\\": 4 + } + }" + `); +}); + +test('space parameter (same as native)', () => { + // for this test, properties need to be in alphabetical order + const obj = { one: 1, two: { a: [2, 3], b: 4 } }; + expect(stableStringify(obj, { space: ' ' })).toEqual(JSON.stringify(obj, null, ' ')); +}); diff --git a/yarn.lock b/yarn.lock index dcfe4fbfe..6095c5d67 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2820,6 +2820,26 @@ __metadata: languageName: unknown linkType: soft +"@lowdefy/serializer@workspace:packages/serializer": + version: 0.0.0-use.local + resolution: "@lowdefy/serializer@workspace:packages/serializer" + dependencies: + "@babel/cli": 7.8.4 + "@babel/compat-data": 7.9.6 + "@babel/core": 7.9.6 + "@babel/preset-env": 7.9.6 + "@lowdefy/type": 1.0.1 + babel-jest: 24.9.0 + eslint: 6.8.0 + eslint-config-airbnb: 18.2.0 + eslint-config-prettier: 6.12.0 + eslint-plugin-prettier: 3.1.4 + jest: 24.9.0 + jest-diff: 24.9.0 + prettier: 2.1.2 + languageName: unknown + linkType: soft + "@lowdefy/type@1.0.1, @lowdefy/type@workspace:packages/type": version: 0.0.0-use.local resolution: "@lowdefy/type@workspace:packages/type"