diff --git a/__tests__/key.test.ts b/__tests__/key.test.ts new file mode 100644 index 0000000..c036731 --- /dev/null +++ b/__tests__/key.test.ts @@ -0,0 +1,80 @@ +import {escapeKey, splitKey} from '../src/key'; + +describe('escapeKey()', () => { + it.each([ + [ + 'key does not contain a separator or slash', + 'object_property_name', + '~', + 'object_property_name', + ], + [ + 'key contains the separator character', + 'object~property~name', + '~', + 'object\\~property\\~name', + ], + [ + 'key contains the separator character, using a multi-character separator', + 'object$$property$$name', + '$$', + 'object\\$$property\\$$name', + ], + [ + 'key contains the escape character', + 'object\\property\\name', + '~', + 'object\\\\property\\\\name', + ], + ])( + 'handles when %s', + (description: string, key: string, separator: string, expected: string) => { + expect(escapeKey(key, separator)).toEqual(expected); + } + ); +}); + +describe('splitKey()', () => { + it.each([ + [ + 'key does not contain any escape sequences', + 'object~property~name', + '~', + ['object', 'property', 'name'], + ], + [ + 'key does not contain any escape sequences, using a multi-character separator', + 'object$$property$$name', + '$$', + ['object', 'property', 'name'], + ], + [ + 'key contains an escaped separator', + 'object~property\\~name', + '~', + ['object', 'property~name'], + ], + [ + 'keys contains an escaped separator, using a multi-character separator', + 'object$$property\\$$name', + '$$', + ['object', 'property$$name'], + ], + [ + 'it contains an escaped slash', + 'object~property\\\\name', + '~', + ['object', 'property\\name'], + ], + ])( + 'handles when %s', + ( + description: string, + key: string, + separator: string, + expected: Array + ) => { + expect(splitKey(key, separator)).toEqual(expected); + } + ); +}); diff --git a/__tests__/order.test.ts b/__tests__/order.test.ts index 38a2425..28aee45 100644 --- a/__tests__/order.test.ts +++ b/__tests__/order.test.ts @@ -6,10 +6,11 @@ describe('order()', () => { const expectObject = ( obj: T, map: PropertyMap | null, - res: T - ) => expect(order(obj, map, '.')).toEqual(res); + res: string + ) => expect(JSON.stringify(order(obj, map, '.'))).toBe(res); - it('returns nothing for a blank JSON string', () => expectObject({}, {}, {})); + it('returns nothing for a blank JSON string', () => + expectObject({}, {}, '{}')); it('throws error if separator is an empty string', () => { expect(() => order({}, {}, '')).toThrowError( @@ -17,37 +18,41 @@ describe('order()', () => { ); }); + it('throws error if separator is a slash', () => { + expect(() => order({}, {}, '\\')).toThrowError('Separator cannot be "\\".'); + }); + it('ignores properties not found in source', () => - expectObject({}, {$: ['a']}, {})); + expectObject({}, {$: ['a']}, '{}')); it('returns regular json string if map is undefined', () => - expectObject({a: '1', b: '2'}, null, {a: '1', b: '2'})); + expectObject({a: '1', b: '2'}, null, '{"a":"1","b":"2"}')); it('ignores properties not found in map', () => - expectObject({a: '1', b: '2'}, {$: ['b']}, {b: '2'})); + expectObject({a: '1', b: '2'}, {$: ['b']}, '{"b":"2"}')); it('returns first level object properties in order', () => - expectObject({a: 2, b: 1}, {$: ['b', 'a']}, {b: 1, a: 2})); + expectObject({a: 2, b: 1}, {$: ['b', 'a']}, '{"b":1,"a":2}')); it('returns first level array value in order', () => - expectObject({a: ['2', 1, true]}, {$: ['a']}, {a: ['2', 1, true]})); + expectObject({a: ['2', 1, true]}, {$: ['a']}, '{"a":["2",1,true]}')); it('returns nested [array] > [object] properties in expected order', () => expectObject( {a: [1, {c: '3', d: '2'}]}, {'$': ['a'], '$.a.1': ['d', 'c']}, - {a: [1, {d: '2', c: '3'}]} + '{"a":[1,{"d":"2","c":"3"}]}' )); it('ignores nested [array] > [object] properties not found in map', () => expectObject( {a: [1, {b: 2, c: 3}, 4]}, {'$': ['a'], '$.a.1': ['c']}, - {a: [1, {c: 3}, 4]} + '{"a":[1,{"c":3},4]}' )); it('ignores nested [array] > [object] properties not found in map', () => - expectObject({a: [1, {b: 2, c: 3}, 4]}, {$: ['a']}, {a: [1, {}, 4]})); + expectObject({a: [1, {b: 2, c: 3}, 4]}, {$: ['a']}, '{"a":[1,{},4]}')); it('handles multi-character prefix', () => { expect( @@ -127,7 +132,7 @@ describe('order()', () => { '$.a.e': ['g', 'f'], '$.a.b': ['d', 'c'], }, - {i: 7, a: {e: {g: 5, f: 4}, h: 6, b: {d: 4, c: 3}}} + '{"i":7,"a":{"e":{"g":5,"f":4},"h":6,"b":{"d":4,"c":3}}}' )); it('returns nested [object] > [array] > [object] > [array] > [object] properties in expected order', () => @@ -161,6 +166,103 @@ describe('order()', () => { '$.a.b.1.d.0': ['f', 'e'], '$.a.b.1.d.0.f': ['h', 'g'], }, - {i: 7, a: {b: [8, {d: [{f: {h: 'h', g: true}, e: 12}, 10], c: 9}, 11]}} + '{"i":7,"a":{"b":[8,{"d":[{"f":{"h":"h","g":true},"e":12},10],"c":9},11]}}' )); + + it('handles keys with no name', () => { + expectObject( + { + '': { + b: 'str', + a: 'str', + c: 'str', + }, + }, + { + '$': [''], + '$.': ['c', 'b', 'a'], + }, + '{"":{"c":"str","b":"str","a":"str"}}' + ); + }); + + it('handles escape sequences in the object', () => { + expectObject( + { + '.a': { + b: {t: 'str'}, + c: {u: 'str'}, + a: {s: 'str'}, + }, + '\\': { + a: {v: 'str'}, + }, + '\\.': { + a: {w: 'str'}, + b: {x: 'str'}, + }, + '.': { + b: {y: 'str'}, + a: {z: 'str'}, + }, + }, + { + '$': ['.', '\\.', '\\', '.a'], + '$.\\.': ['a', 'b'], + '$.\\..a': ['z'], + '$.\\..b': ['y'], + '$.\\\\\\.': ['b', 'a'], + '$.\\\\\\..b': ['x'], + '$.\\\\\\..a': ['w'], + '$.\\\\': ['a'], + '$.\\\\.a': ['v'], + '$.\\.a': ['c', 'b', 'a'], + '$.\\.a.c': ['u'], + '$.\\.a.b': ['t'], + '$.\\.a.a': ['s'], + }, + '{".":{"a":{"z":"str"},"b":{"y":"str"}},' + + '"\\\\.":{"b":{"x":"str"},"a":{"w":"str"}},' + + '"\\\\":{"a":{"v":"str"}},' + + '".a":{"c":{"u":"str"},"b":{"t":"str"},"a":{"s":"str"}}}' + ); + }); + + it('handles escape sequences in child properties of the object', () => { + expectObject( + { + property: { + '..': {'.': 4, '..': 3}, + '.': {'..': 0, '...': 2, '.': 1}, + '...': {'.': 5}, + }, + }, + { + '$': ['property'], + '$.property': ['.', '..', '...'], + '$.property.\\.': ['..', '.', '...'], + '$.property.\\.\\.': ['..', '.'], + '$.property.\\.\\.\\.': ['.'], + }, + '{"property":{".":{"..":0,".":1,"...":2},"..":{"..":3,".":4},"...":{".":5}}}' + ); + }); + + it('numeric key order defined in map is lost', () => { + // Numeric keys aren't ordered per map but instead appear first in ascending order. + // See: https://tc39.es/ecma262/#sec-ordinaryownpropertykeys + expectObject( + { + 4: 'str', + a: 'str', + 3: 'str', + b: 'str', + 2: 'str', + }, + { + $: ['a', '4', 'b', '3', '2'], + }, + '{"2":"str","3":"str","4":"str","a":"str","b":"str"}' + ); + }); }); diff --git a/__tests__/parse.test.ts b/__tests__/parse.test.ts index 3ddb847..b4d4555 100644 --- a/__tests__/parse.test.ts +++ b/__tests__/parse.test.ts @@ -19,6 +19,12 @@ describe('parse ', () => { ); }); + it('throws error if separator is a slash', () => { + expect(() => parse('', '$', '\\')).toThrowError( + 'Separator cannot be "\\".' + ); + }); + it('handles top level values for of primitive types', () => { const input = ` { @@ -220,4 +226,84 @@ describe('parse ', () => { expectMap(input, map); }); + + it('handles keys with no name', () => { + const input = ` + { + "": { + "c": "str", + "b": "str", + "a": "str" + } + }`; + + const map = { + '$': [''], + '$.': ['c', 'b', 'a'], + }; + + expectMap(input, map); + }); + + it('escapes slashes as well as the separator when it exists in the object keys', () => { + // slashes in the encoded JSON are double escapes, so "\\\\" is actually equivalent to "\". + const input = ` + { + ".": { + "a": {"z": "str"}, + "b": {"y": "str"} + }, + "\\\\.": { + "b": {"x": "str"}, + "a": {"w": "str"} + }, + "\\\\": { + "a": {"v": "str"} + }, + ".a": { + "c": {"u": "str"}, + "b": {"t": "str"}, + "a": {"s": "str"} + } + }`; + + // all below slashes are escaped so "\\" is actually equivalent to "\". + const map = { + '$': ['.', '\\.', '\\', '.a'], + '$.\\.': ['a', 'b'], + '$.\\..a': ['z'], + '$.\\..b': ['y'], + '$.\\\\\\.': ['b', 'a'], + '$.\\\\\\..b': ['x'], + '$.\\\\\\..a': ['w'], + '$.\\\\': ['a'], + '$.\\\\.a': ['v'], + '$.\\.a': ['c', 'b', 'a'], + '$.\\.a.c': ['u'], + '$.\\.a.b': ['t'], + '$.\\.a.a': ['s'], + }; + + expectMap(input, map); + }); + + it('handles keys with different types of values', () => { + const input = ` + { + "a": "a", + "b": 2, + "c": 2.3, + "d": true, + "e": false, + "f": null, + "g": {}, + "h": [] + }`; + + const map = { + $: ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h'], + }; + + expectMap(input, map); + }); }); diff --git a/__tests__/stringify.test.ts b/__tests__/stringify.test.ts index 44b50ac..89b8ac4 100644 --- a/__tests__/stringify.test.ts +++ b/__tests__/stringify.test.ts @@ -163,4 +163,22 @@ describe('stringify ', () => { }, '{"i":7,"a":{"b":[8,{"d":[{"f":{"h":"h","g":true},"e":12},10],"c":9},11]}}' )); + + it('handles keys with different types of values', () => + expectString( + { + a: 'a', + b: 2, + c: 2.3, + d: true, + e: false, + f: null, + g: {}, + h: [], + }, + { + $: ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h'], + }, + '{"a":"a","b":2,"c":2.3,"d":true,"e":false,"f":null,"g":{},"h":[]}' + )); }); diff --git a/package.json b/package.json index d2752a6..86d5713 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "test:watch": "jest --watch", "test:ci": "jest --ci --coverage", "compile": "tsc -p tsconfig.build.json", - "lint": "eslint **/*.{t,j}s", + "lint": "eslint \"**/*.{t,j}s\"", "lint:fix": "yarn lint --fix", "semantic-release": "semantic-release" }, @@ -65,6 +65,7 @@ "typescript": "4.1.3" }, "dependencies": { + "escape-string-regexp": "^4.0.0", "lodash.clonedeep": "^4.5.0" } } diff --git a/src/key.ts b/src/key.ts new file mode 100644 index 0000000..b948902 --- /dev/null +++ b/src/key.ts @@ -0,0 +1,45 @@ +import escapeStringRegexp from 'escape-string-regexp'; + +export const escapeKey = (key: string, separator: string): string => { + const stringsToEscape = ['\\', separator]; + const pattern = stringsToEscape + .map((string) => escapeStringRegexp(string)) + .join('|'); + + return key.replace(new RegExp(`(${pattern})`, 'g'), '\\$1'); +}; + +export const splitKey = (key: string, separator: string): Array => { + // if key doesn't have any escape sequence avoid iterating through the characters. + if (key.indexOf('\\') < 0) { + return key.split(separator); + } + + const parts: Array = []; + let currentPart = ''; + let isLiteral = false; + + for (let index = 0; index < key.length; index++) { + const character = key[index]; + + if (isLiteral) { + currentPart += character; + isLiteral = false; + } else if (character === '\\') { + isLiteral = true; + } else if ( + character === separator[0] && + key.substr(index, separator.length) === separator + ) { + parts.push(currentPart); + currentPart = ''; + index += separator.length - 1; + } else { + currentPart += character; + } + } + + parts.push(currentPart); + + return parts; +}; diff --git a/src/order.ts b/src/order.ts index 05b629c..937e610 100644 --- a/src/order.ts +++ b/src/order.ts @@ -1,6 +1,7 @@ /* eslint-disable @typescript-eslint/ban-types */ import clonedeep from 'lodash.clonedeep'; +import {escapeKey, splitKey} from './key'; import {PropertyMap} from './models'; interface GetResult { @@ -15,9 +16,8 @@ const getProperty = ( ): GetResult => { let exists = true; - const value = key - .split(separator) - .filter((s) => s.length > 0) + const value = splitKey(key, separator) + .slice(1) .reduce((o: object, x: string) => { exists = o && o.hasOwnProperty(x); @@ -37,9 +37,8 @@ const setProperty = ( value: object, separator: string ) => { - key - .split(separator) - .filter((s) => s.length > 0) + splitKey(key, separator) + .slice(1) .reduce((o: object, x: string, idx: number, src: Array): object => { if (idx === src.length - 1) { const valueToSet = Array.isArray(value) @@ -79,6 +78,8 @@ const order = ( ): T => { if (separator.length < 1) { throw new Error('Separator should not be an empty string.'); + } else if (separator === '\\') { + throw new Error('Separator cannot be "\\".'); } if (!map) { @@ -108,7 +109,7 @@ const order = ( copyProperty( sourceObject, resultObject, - `${parentKey}${separator}${key}`, + `${parentKey}${separator}${escapeKey(key, separator)}`, separator ) ); diff --git a/src/parse.ts b/src/parse.ts index 544a7f8..1d12699 100644 --- a/src/parse.ts +++ b/src/parse.ts @@ -1,5 +1,6 @@ /* eslint-disable @typescript-eslint/ban-types */ +import {escapeKey} from './key'; import {OrderedParseResult, PropertyMap} from './models'; const traverseObject = ( @@ -22,11 +23,11 @@ const traverseObject = ( childKeys.forEach((childKey) => { const value = obj[childKey]; - if (typeof value === 'object') { + if (value !== null && typeof value === 'object') { traverseObject( value, map, - `${parentKey}${separator}${childKey}`, + `${parentKey}${separator}${escapeKey(childKey, separator)}`, separator ); } @@ -52,12 +53,14 @@ const parse = ( if (separator.length < 1) { throw new Error('Separator should not be an empty string.'); + } else if (separator === '\\') { + throw new Error('Separator cannot be "\\".'); } const obj: T = JSON.parse(jsonString); const map = {}; - traverseObject(obj, map, prefix, separator); + traverseObject(obj, map, escapeKey(prefix, separator), separator); return { object: obj, map, diff --git a/yarn.lock b/yarn.lock index 16652c8..75beecd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2550,6 +2550,11 @@ escape-string-regexp@^2.0.0: resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz#a30304e99daa32e23b2fd20f51babd07cffca344" integrity sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w== +escape-string-regexp@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz#14ba83a5d373e3d311e5afca29cf5bfad965bf34" + integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA== + escodegen@^1.14.1: version "1.14.3" resolved "https://registry.yarnpkg.com/escodegen/-/escodegen-1.14.3.tgz#4e7b81fba61581dc97582ed78cab7f0e8d63f503"