From 3d336a71cc9dd9656e72f066bc2a5b285c745a38 Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Sat, 1 Aug 2020 13:48:59 +0200 Subject: [PATCH] Serialize maps using arrays of pairs While we had been trying to fit Map's semantics into JSON's object, the solution had been in front of us all along: It's impossible! `new Map()` takes a list of pairs and not an object, because objects are inherently less powerful than Maps. By also using list of pairs to describe `Map`, we finally can serialize `Map` or `Map<{}, V>`. See introduced tests in `index.test.ts` for reference! :D --- src/accessDeep.test.ts | 27 ++++++++ src/accessDeep.ts | 91 +++++++++++++++++++++++-- src/index.test.ts | 147 +++++++++++++++++++++++++++++++++++------ src/plainer.test.ts | 14 ++-- src/plainer.ts | 11 ++- src/transformer.ts | 92 +++++--------------------- src/types.ts | 11 +-- 7 files changed, 277 insertions(+), 116 deletions(-) create mode 100644 src/accessDeep.test.ts diff --git a/src/accessDeep.test.ts b/src/accessDeep.test.ts new file mode 100644 index 0000000..9f5ba5c --- /dev/null +++ b/src/accessDeep.test.ts @@ -0,0 +1,27 @@ +import { setDeep } from './accessDeep'; + +describe('setDeep', () => { + it('correctly sets values in maps', () => { + const obj = { + a: new Map([['NaN', 10]]), + }; + + setDeep(obj, ['a', 0, 0], Number); + + expect(obj).toEqual({ + a: new Map([[NaN, 10]]), + }); + }); + + it('correctly sets values in sets', () => { + const obj = { + a: new Set(['NaN']), + }; + + setDeep(obj, ['a', 0], Number); + + expect(obj).toEqual({ + a: new Set([NaN]), + }); + }); +}); diff --git a/src/accessDeep.ts b/src/accessDeep.ts index 68bb94c..195d860 100644 --- a/src/accessDeep.ts +++ b/src/accessDeep.ts @@ -1,3 +1,15 @@ +import { isMap, isArray, isPlainObject, isSet } from './is'; + +export const getNthKey = (value: Map | Set, n: number): any => { + const keys = value.keys(); + while (n > 0) { + keys.next(); + n--; + } + + return keys.next().value; +}; + export const getDeep = (object: object, path: (string | number)[]): object => { for (const key of path) { object = (object as any)[key]; @@ -7,20 +19,87 @@ export const getDeep = (object: object, path: (string | number)[]): object => { }; export const setDeep = ( - object: object, + object: any, path: (string | number)[], mapper: (v: any) => any -): object => { +): any => { if (path.length === 0) { return mapper(object); } - const front = path.slice(0, path.length - 1); - const last = path[path.length - 1]; + let parent = object; - const parent: any = getDeep(object, front); + for (let i = 0; i < path.length - 1; i++) { + const key = path[i]; + + if (isArray(parent)) { + const index = +key; + parent = parent[index]; + } else if (isPlainObject(parent)) { + parent = parent[key]; + } else if (isSet(parent)) { + const row = +key; + parent = getNthKey(parent, row); + } else if (isMap(parent)) { + const isEnd = i === path.length - 2; + if (isEnd) { + break; + } + + const row = +key; + const type = +path[i + 1] === 0 ? 'key' : 'value'; + + const keyOfRow = getNthKey(parent, row); + switch (type) { + case 'key': + parent = keyOfRow; + break; + case 'value': + parent = parent.get(keyOfRow); + break; + } + + i++; + } + } - parent[last] = mapper(parent[last]); + const lastKey = path[path.length - 1]; + + if (isArray(parent) || isPlainObject(parent)) { + parent[lastKey] = mapper(parent[lastKey]); + } + + if (isSet(parent)) { + const oldValue = getNthKey(parent, +lastKey); + const newValue = mapper(oldValue); + if (oldValue !== newValue) { + parent.delete(oldValue); + parent.add(newValue); + } + } + + if (isMap(parent)) { + const row = +path[path.length - 2]; + const keyToRow = getNthKey(parent, row); + + const type = +lastKey === 0 ? 'key' : 'value'; + switch (type) { + case 'key': { + const newKey = mapper(keyToRow); + parent.set(newKey, parent.get(keyToRow)); + + if (newKey !== keyToRow) { + parent.delete(keyToRow); + } + break; + } + + case 'value': { + parent.set(keyToRow, mapper(parent.get(keyToRow))); + break; + } + } + } return object; }; diff --git a/src/index.test.ts b/src/index.test.ts index eb4e0e4..6a173c8 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -86,23 +86,20 @@ describe('stringify & parse', () => { }, output: { - a: { - 1: 'a', - NaN: 'b', - }, - b: { - 2: 'b', - }, - d: { - true: 'true key', - }, + a: [ + [1, 'a'], + ['NaN', 'b'], + ], + b: [['2', 'b']], + d: [[true, 'true key']], }, outputAnnotations: { values: { - a: 'map:number', - b: 'map:string', - d: 'map:boolean', + a: 'map', + 'a.1.0': 'number', + b: 'map', + d: 'map', }, }, }, @@ -203,11 +200,11 @@ describe('stringify & parse', () => { a: Number.POSITIVE_INFINITY, }, output: { - a: undefined, + a: 'Infinity', }, outputAnnotations: { values: { - a: 'Infinity', + a: 'number', }, }, }, @@ -217,11 +214,11 @@ describe('stringify & parse', () => { a: Number.NEGATIVE_INFINITY, }, output: { - a: undefined, + a: '-Infinity', }, outputAnnotations: { values: { - a: '-Infinity', + a: 'number', }, }, }, @@ -231,11 +228,11 @@ describe('stringify & parse', () => { a: NaN, }, output: { - a: undefined, + a: 'NaN', }, outputAnnotations: { values: { - a: 'NaN', + a: 'number', }, }, }, @@ -274,6 +271,118 @@ describe('stringify & parse', () => { referentialEqualitiesRoot: ['children.0.parents.0'], }, }, + + 'works for Maps with two keys that serialize to the same string but have a different reference': { + input: new Map([ + [/a/g, 'foo'], + [/a/g, 'bar'], + ]), + output: [ + ['/a/g', 'foo'], + ['/a/g', 'bar'], + ], + outputAnnotations: { + root: 'map', + values: { + '0.0': 'regexp', + '1.0': 'regexp', + }, + }, + }, + + "works for Maps with a key that's referentially equal to another field": { + input: () => { + const robbyBubble = { id: 5 }; + const highscores = new Map([[robbyBubble, 5000]]); + return { + highscores, + topScorer: robbyBubble, + } as any; + }, + output: { + highscores: [[{ id: 5 }, 5000]], + topScorer: { id: 5 }, + }, + outputAnnotations: { + values: { + highscores: 'map', + }, + referentialEqualities: { + topScorer: ['highscores.0.0'], + }, + }, + }, + + 'works for referentially equal maps': { + input: () => { + const map = new Map([[1, 1]]); + return { + a: map, + b: map, + }; + }, + output: { + a: [[1, 1]], + b: [[1, 1]], + }, + outputAnnotations: { + values: { + a: 'map', + b: 'map', + }, + referentialEqualities: { + a: ['b'], + }, + }, + customExpectations: value => { + expect(value.a).toBe(value.b); + }, + }, + + 'works for maps with non-uniform keys': { + input: { + map: new Map([ + [1, 1], + ['1', 1], + ]), + }, + output: { + map: [ + [1, 1], + ['1', 1], + ], + }, + outputAnnotations: { + values: { + map: 'map', + }, + }, + }, + + 'works for referentially equal values inside a set': { + input: () => { + const user = { id: 2 }; + return { + users: new Set([user]), + userOfTheMonth: user, + }; + }, + output: { + users: [{ id: 2 }], + userOfTheMonth: { id: 2 }, + }, + outputAnnotations: { + values: { + users: 'set', + }, + referentialEqualities: { + userOfTheMonth: ['users.0'], + }, + }, + customExpectations: value => { + expect(value.users.values().next().value).toBe(value.userOfTheMonth); + }, + }, }; function deepFreeze(object: any, alreadySeenObjects = new Set()) { diff --git a/src/plainer.test.ts b/src/plainer.test.ts index 871fcb6..3014f42 100644 --- a/src/plainer.test.ts +++ b/src/plainer.test.ts @@ -24,10 +24,10 @@ describe('plainer', () => { }); expect(output).toEqual({ - a: { - 2: 'hallo', - undefined: null, - }, + a: [ + [2, 'hallo'], + [undefined, null], + ], b: { c: [1, 2, /hallo/g], }, @@ -60,8 +60,10 @@ describe('plainer', () => { 2 => "hallo", undefined => null, }, - "a.": null, - "a.2": "hallo", + "a.0.0": 2, + "a.0.1": "hallo", + "a.1.0": undefined, + "a.1.1": null, "b": Object { "c": Set { 1, diff --git a/src/plainer.ts b/src/plainer.ts index 78b263f..8246021 100644 --- a/src/plainer.ts +++ b/src/plainer.ts @@ -50,9 +50,16 @@ export const plainer = ( ); } - if (isPlainObject(object) || isMap(object)) { + if (isMap(object)) { + return IteratorUtils.map(entries(object), ([key, value], index) => [ + plainer(key, walker, [...path, index, 0], alreadySeenObjects), + plainer(value, walker, [...path, index, 1], alreadySeenObjects), + ]); + } + + if (isPlainObject(object)) { return Object.fromEntries( - IteratorUtils.map(entries(object), ([key, value]) => [ + Object.entries(object).map(([key, value]) => [ key, plainer(value, walker, [...path, key], alreadySeenObjects), ]) diff --git a/src/transformer.ts b/src/transformer.ts index f8acd53..bdf0628 100644 --- a/src/transformer.ts +++ b/src/transformer.ts @@ -1,41 +1,26 @@ import { isBigint, - isBoolean, isDate, isInfinite, isMap, isNaNValue, - isNumber, isRegExp, isSet, - isString, isUndefined, } from './is'; +import * as IteratorUtils from './iteratorutils'; -export type PrimitiveTypeAnnotation = - | 'NaN' - | '-Infinity' - | 'Infinity' - | 'undefined' - | 'bigint'; +export type PrimitiveTypeAnnotation = 'number' | 'undefined' | 'bigint'; type LeafTypeAnnotation = PrimitiveTypeAnnotation | 'regexp' | 'Date'; -type MapTypeAnnotation = - | 'map:number' - | 'map:string' - | 'map:bigint' - | 'map:boolean'; - -type ContainerTypeAnnotation = MapTypeAnnotation | 'set'; +type ContainerTypeAnnotation = 'map' | 'set'; export type TypeAnnotation = LeafTypeAnnotation | ContainerTypeAnnotation; const ALL_PRIMITIVE_TYPE_ANNOTATIONS: TypeAnnotation[] = [ - '-Infinity', - 'Infinity', 'undefined', - 'NaN', + 'number', 'bigint', ]; @@ -46,15 +31,7 @@ export const isPrimitiveTypeAnnotation = ( }; const ALL_TYPE_ANNOTATIONS: TypeAnnotation[] = ALL_PRIMITIVE_TYPE_ANNOTATIONS.concat( - [ - 'map:number', - 'map:string', - 'map:bigint', - 'map:boolean', - 'regexp', - 'set', - 'Date', - ] + ['map', 'regexp', 'set', 'Date'] ); export const isTypeAnnotation = (value: any): value is TypeAnnotation => { @@ -81,13 +58,13 @@ export const transformValue = ( }; } else if (isNaNValue(value)) { return { - value: undefined, - type: 'NaN', + value: 'NaN', + type: 'number', }; } else if (isInfinite(value)) { return { - value: undefined, - type: value > 0 ? 'Infinity' : '-Infinity', + value: value > 0 ? 'Infinity' : '-Infinity', + type: 'number', }; } else if (isSet(value)) { return { @@ -100,34 +77,11 @@ export const transformValue = ( type: 'regexp', }; } else if (isMap(value)) { - const { done: valueIsEmpty, value: firstKey } = value.keys().next(); - const returnValueDoesntMatter = valueIsEmpty; - if (returnValueDoesntMatter || isString(firstKey)) { - return { value, type: 'map:string' }; - } - - if (isNumber(firstKey)) { - return { - value: value, - type: 'map:number', - }; - } - - if (isBigint(firstKey)) { - return { - value: value, - type: 'map:bigint', - }; - } - - if (isBoolean(firstKey)) { - return { - value: value, - type: 'map:boolean', - }; - } - - throw new Error('Key type not supported.'); + const entries = IteratorUtils.map(value.entries(), pair => pair); + return { + value: entries, + type: 'map', + }; } return undefined; @@ -141,20 +95,10 @@ export const untransformValue = (json: any, type: TypeAnnotation) => { return undefined; case 'Date': return new Date(json as string); - case 'NaN': - return Number.NaN; - case 'Infinity': - return Number.POSITIVE_INFINITY; - case '-Infinity': - return Number.NEGATIVE_INFINITY; - case 'map:number': - return new Map(Object.entries(json).map(([k, v]) => [Number(k), v])); - case 'map:string': - return new Map(Object.entries(json)); - case 'map:boolean': - return new Map(Object.entries(json).map(([k, v]) => [Boolean(k), v])); - case 'map:bigint': - return new Map(Object.entries(json).map(([k, v]) => [BigInt(k), v])); + case 'number': + return Number(json); + case 'map': + return new Map(json); case 'set': return new Set(json as unknown[]); case 'regexp': { diff --git a/src/types.ts b/src/types.ts index 0fb61b0..d8f1da8 100644 --- a/src/types.ts +++ b/src/types.ts @@ -11,16 +11,9 @@ export interface JSONObject { [key: string]: JSONValue; } -type MapWithUniformKeys = - | Map - | Map - | Map - | Map - | Map; - export type SerializableJSONValue = - | Set - | MapWithUniformKeys + | Set + | Map | undefined | bigint | Date