From 7637350f7f165d1137ccf338812fbe6e4aaacb4e Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Fri, 31 Jul 2020 11:20:22 +0200 Subject: [PATCH 01/21] Add ClassRegistry --- src/class-registry.test.ts | 35 ++++++++++++++++++++ src/class-registry.ts | 67 ++++++++++++++++++++++++++++++++++++++ src/index.test.ts | 2 +- src/index.ts | 19 ++++++++--- 4 files changed, 117 insertions(+), 6 deletions(-) create mode 100644 src/class-registry.test.ts create mode 100644 src/class-registry.ts diff --git a/src/class-registry.test.ts b/src/class-registry.test.ts new file mode 100644 index 0000000..4e6e410 --- /dev/null +++ b/src/class-registry.test.ts @@ -0,0 +1,35 @@ +import * as ClassRegistry from './class-registry'; + +test('class registry', () => { + class Car { + honk() { + console.log('honk'); + } + } + ClassRegistry.registerClass(Car); + + expect(ClassRegistry.getClass('Car')).toBe(Car); + expect(ClassRegistry.getIdentifier(Car)).toBe('Car'); + + expect(() => ClassRegistry.registerClass(Car)).not.toThrow(); + + expect(() => ClassRegistry.registerClass(class Car {})).toThrow( + 'Ambiguous class, provide a unique identifier.' + ); + + ClassRegistry.unregisterClass(Car); + + expect(ClassRegistry.getClass('Car')).toBeUndefined(); + + ClassRegistry.registerClass(Car, 'car1'); + + ClassRegistry.registerClass(class Car {}, 'car2'); + + expect(ClassRegistry.getClass('car1')).toBe(Car); + + expect(ClassRegistry.getClass('car2')).not.toBeUndefined(); + + ClassRegistry.clear(); + + expect(ClassRegistry.getClass('car1')).toBeUndefined(); +}); diff --git a/src/class-registry.ts b/src/class-registry.ts new file mode 100644 index 0000000..7af8487 --- /dev/null +++ b/src/class-registry.ts @@ -0,0 +1,67 @@ +class DoubleIndexedKV { + keyToValue = new Map(); + valueToKey = new Map(); + + set(key: K, value: V) { + this.keyToValue.set(key, value); + this.valueToKey.set(value, key); + } + + deleteByValue(value: V) { + this.valueToKey.delete(value); + this.keyToValue.forEach((otherValue, otherKey) => { + if (value === otherValue) { + this.keyToValue.delete(otherKey); + } + }); + } + + getByKey(key: K): V | undefined { + return this.keyToValue.get(key); + } + + getByValue(value: V): K | undefined { + return this.valueToKey.get(value); + } + + clear() { + this.keyToValue.clear(); + this.valueToKey.clear(); + } +} + +type Class = { new (): any }; + +const classRegistry = new DoubleIndexedKV(); + +export function registerClass(clazz: Class, identifier?: string): void { + if (classRegistry.getByValue(clazz)) { + return; + } + + if (!identifier) { + identifier = clazz.name; + } + + if (classRegistry.getByKey(identifier)) { + throw new Error('Ambiguous class, provide a unique identifier.'); + } + + classRegistry.set(identifier, clazz); +} + +export function unregisterClass(clazz: Class): void { + classRegistry.deleteByValue(clazz); +} + +export function clear(): void { + classRegistry.clear(); +} + +export function getIdentifier(clazz: Class) { + return classRegistry.getByValue(clazz); +} + +export function getClass(identifier: string) { + return classRegistry.getByKey(identifier); +} diff --git a/src/index.test.ts b/src/index.test.ts index eb4e0e4..c0570b2 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -1,4 +1,4 @@ -import * as SuperJSON from './'; +import SuperJSON from './'; import { Annotations } from './annotator'; import { isArray, isMap, isPlainObject, isPrimitive, isSet } from './is'; import { JSONValue, SuperJSONValue } from './types'; diff --git a/src/index.ts b/src/index.ts index ed19ca5..27f2377 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,8 +2,9 @@ import { applyAnnotations, makeAnnotator } from './annotator'; import { isEmptyObject } from './is'; import { plainer } from './plainer'; import { SuperJSONResult, SuperJSONValue, isSuperJSONResult } from './types'; +import { clear, registerClass, unregisterClass } from './class-registry'; -export const serialize = (object: SuperJSONValue): SuperJSONResult => { +const serialize = (object: SuperJSONValue): SuperJSONResult => { const { getAnnotations, annotator } = makeAnnotator(); const output = plainer(object, annotator); @@ -15,7 +16,7 @@ export const serialize = (object: SuperJSONValue): SuperJSONResult => { }; }; -export const deserialize = (payload: SuperJSONResult): SuperJSONValue => { +const deserialize = (payload: SuperJSONResult): SuperJSONValue => { if (!isSuperJSONResult(payload)) { throw new Error('Not a valid SuperJSON payload.'); } @@ -29,10 +30,18 @@ export const deserialize = (payload: SuperJSONResult): SuperJSONValue => { return json; }; -export const stringify = (object: SuperJSONValue): string => +const stringify = (object: SuperJSONValue): string => JSON.stringify(serialize(object)); -export const parse = (string: string): SuperJSONValue => +const parse = (string: string): SuperJSONValue => deserialize(JSON.parse(string)); -export default { stringify, parse, serialize, deserialize }; +export default { + stringify, + parse, + serialize, + deserialize, + clear, + registerClass, + unregisterClass, +}; From acad2534501ec796babc76e45d3abcc3a9f0d648 Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Fri, 31 Jul 2020 11:44:50 +0200 Subject: [PATCH 02/21] Add test describing what shall be achieved --- src/class-registry.ts | 4 ++-- src/index.test.ts | 38 ++++++++++++++++++++++++++++++++++++++ src/types.ts | 2 ++ 3 files changed, 42 insertions(+), 2 deletions(-) diff --git a/src/class-registry.ts b/src/class-registry.ts index 7af8487..0ed301c 100644 --- a/src/class-registry.ts +++ b/src/class-registry.ts @@ -1,3 +1,5 @@ +import { Class } from './types'; + class DoubleIndexedKV { keyToValue = new Map(); valueToKey = new Map(); @@ -30,8 +32,6 @@ class DoubleIndexedKV { } } -type Class = { new (): any }; - const classRegistry = new DoubleIndexedKV(); export function registerClass(clazz: Class, identifier?: string): void { diff --git a/src/index.test.ts b/src/index.test.ts index c0570b2..8d710bc 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -334,6 +334,44 @@ describe('stringify & parse', () => { }); } + describe('when serializing custom class instances', () => { + it('revives them to their original class', () => { + class Train { + constructor( + private topSpeed: number, + private color: 'red' | 'blue' | 'yellow', + private brand: string + ) {} + + public brag() { + return `I'm a ${this.brand} in freakin' ${this.color} and I go ${this.topSpeed} km/h, isn't that bonkers?`; + } + } + + SuperJSON.registerClass(Train); + + const { json, meta } = SuperJSON.serialize({ + s7: new Train(100, 'yellow', 'Bombardier') as any, // typing is to be solved + }); + + expect(json).toEqual({ + s7: { + topSpeed: 100, + color: 'yellow', + brand: 'Bombardier', + }, + }); + + expect(meta).toEqual({}); + + const deserialized: any = SuperJSON.deserialize( + JSON.parse(JSON.stringify({ json, meta })) + ); + expect(deserialized.s7).toBeInstanceOf(Train); + expect(typeof deserialized.s7.brag()).toBe('string'); + }); + }); + describe('when given a non-SuperJSON object', () => { it('throws', () => { expect(() => { diff --git a/src/types.ts b/src/types.ts index 0fb61b0..482cdf0 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,6 +1,8 @@ import { Annotations, isAnnotations } from './annotator'; import { isUndefined } from './is'; +export type Class = { new (...args: any[]): any }; + export type PrimitveJSONValue = string | number | boolean | undefined | null; export type JSONValue = PrimitveJSONValue | JSONArray | JSONObject; From a017f2e2dd48b0b82221e0e230e5a6acfe2445f1 Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Fri, 31 Jul 2020 21:54:38 +0200 Subject: [PATCH 03/21] Implement class revival --- src/index.test.ts | 12 ++++--- src/transformer.ts | 89 ++++++++++++++++++++++++++++++++-------------- 2 files changed, 70 insertions(+), 31 deletions(-) diff --git a/src/index.test.ts b/src/index.test.ts index 8d710bc..78fa7a5 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -100,9 +100,9 @@ describe('stringify & parse', () => { outputAnnotations: { values: { - a: 'map:number', - b: 'map:string', - d: 'map:boolean', + a: ['map', 'number'], + b: ['map', 'string'], + d: ['map', 'boolean'], }, }, }, @@ -362,7 +362,11 @@ describe('stringify & parse', () => { }, }); - expect(meta).toEqual({}); + expect(meta).toEqual({ + values: { + s7: ['class', 'Train'], + }, + }); const deserialized: any = SuperJSON.deserialize( JSON.parse(JSON.stringify({ json, meta })) diff --git a/src/transformer.ts b/src/transformer.ts index f8acd53..d278020 100644 --- a/src/transformer.ts +++ b/src/transformer.ts @@ -11,6 +11,7 @@ import { isString, isUndefined, } from './is'; +import * as ClassRegistry from './class-registry'; export type PrimitiveTypeAnnotation = | 'NaN' @@ -21,13 +22,11 @@ export type PrimitiveTypeAnnotation = type LeafTypeAnnotation = PrimitiveTypeAnnotation | 'regexp' | 'Date'; -type MapTypeAnnotation = - | 'map:number' - | 'map:string' - | 'map:bigint' - | 'map:boolean'; +type MapTypeAnnotation = ['map', 'number' | 'string' | 'bigint' | 'boolean']; -type ContainerTypeAnnotation = MapTypeAnnotation | 'set'; +type ClassTypeAnnotation = ['class', string]; + +type ContainerTypeAnnotation = MapTypeAnnotation | ClassTypeAnnotation | 'set'; export type TypeAnnotation = LeafTypeAnnotation | ContainerTypeAnnotation; @@ -46,18 +45,19 @@ export const isPrimitiveTypeAnnotation = ( }; const ALL_TYPE_ANNOTATIONS: TypeAnnotation[] = ALL_PRIMITIVE_TYPE_ANNOTATIONS.concat( - [ - 'map:number', - 'map:string', - 'map:bigint', - 'map:boolean', - 'regexp', - 'set', - 'Date', - ] + ['regexp', 'set', 'Date'] ); export const isTypeAnnotation = (value: any): value is TypeAnnotation => { + if (Array.isArray(value)) { + switch (value[0]) { + case 'map': + return ['number', 'string', 'bigint', 'boolean'].includes(value[1]); + case 'class': + return typeof value[1] === 'string'; + } + } + return ALL_TYPE_ANNOTATIONS.includes(value); }; @@ -103,37 +103,80 @@ export const transformValue = ( const { done: valueIsEmpty, value: firstKey } = value.keys().next(); const returnValueDoesntMatter = valueIsEmpty; if (returnValueDoesntMatter || isString(firstKey)) { - return { value, type: 'map:string' }; + return { value, type: ['map', 'string'] }; } if (isNumber(firstKey)) { return { value: value, - type: 'map:number', + type: ['map', 'number'], }; } if (isBigint(firstKey)) { return { value: value, - type: 'map:bigint', + type: ['map', 'bigint'], }; } if (isBoolean(firstKey)) { return { value: value, - type: 'map:boolean', + type: ['map', 'boolean'], }; } throw new Error('Key type not supported.'); } + if (value?.constructor) { + const identifier = ClassRegistry.getIdentifier(value.constructor); + if (identifier) { + return { + value: value, + type: ['class', identifier], + }; + } + } + return undefined; }; export const untransformValue = (json: any, type: TypeAnnotation) => { + if (Array.isArray(type)) { + switch (type[0]) { + case 'map': { + switch (type[1]) { + case 'number': + return new Map( + Object.entries(json).map(([k, v]) => [Number(k), v]) + ); + case 'string': + return new Map(Object.entries(json)); + case 'boolean': + return new Map( + Object.entries(json).map(([k, v]) => [Boolean(k), v]) + ); + case 'bigint': + return new Map( + Object.entries(json).map(([k, v]) => [BigInt(k), v]) + ); + } + } + + case 'class': { + const clazz = ClassRegistry.getClass(type[1]); + + if (!clazz) { + throw new Error('Trying to deserialize unknown class'); + } + + return Object.assign(Object.create(clazz.prototype), json); + } + } + } + switch (type) { case 'bigint': return BigInt(json); @@ -147,14 +190,6 @@ export const untransformValue = (json: any, type: TypeAnnotation) => { 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 'set': return new Set(json as unknown[]); case 'regexp': { From 09c7a16f8de8a5ede202741dd1fe14ee2687771c Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Fri, 31 Jul 2020 22:00:47 +0200 Subject: [PATCH 04/21] add test for accessors --- src/index.test.ts | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/src/index.test.ts b/src/index.test.ts index 78fa7a5..a3068dd 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -374,6 +374,36 @@ describe('stringify & parse', () => { expect(deserialized.s7).toBeInstanceOf(Train); expect(typeof deserialized.s7.brag()).toBe('string'); }); + + describe('with accessor attributes', () => { + it('works', () => { + class Currency { + constructor(private valueInUsd: number) {} + + get inUSD() { + return this.valueInUsd; + } + } + + SuperJSON.registerClass(Currency); + + const { json, meta } = SuperJSON.serialize({ + price: new Currency(100) as any, // typing is to be solved + }); + + expect(json).toEqual({ + price: { + valueInUsd: 100, + }, + }); + + const result: any = SuperJSON.parse(JSON.stringify({ json, meta })); + + const price: Currency = result.price; + + expect(price.inUSD).toBe(100); + }); + }); }); describe('when given a non-SuperJSON object', () => { From 70f2912aa725033025810295b2f5ed1a694449bd Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Fri, 31 Jul 2020 22:08:12 +0200 Subject: [PATCH 05/21] Remove type overrides --- src/index.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/index.test.ts b/src/index.test.ts index a3068dd..6c8feb3 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -351,7 +351,7 @@ describe('stringify & parse', () => { SuperJSON.registerClass(Train); const { json, meta } = SuperJSON.serialize({ - s7: new Train(100, 'yellow', 'Bombardier') as any, // typing is to be solved + s7: new Train(100, 'yellow', 'Bombardier'), }); expect(json).toEqual({ @@ -388,7 +388,7 @@ describe('stringify & parse', () => { SuperJSON.registerClass(Currency); const { json, meta } = SuperJSON.serialize({ - price: new Currency(100) as any, // typing is to be solved + price: new Currency(100), }); expect(json).toEqual({ From 3d336a71cc9dd9656e72f066bc2a5b285c745a38 Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Sat, 1 Aug 2020 13:48:59 +0200 Subject: [PATCH 06/21] 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 From 263361333bc393ba6003f530c91ccc6fb5df50f4 Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Sun, 2 Aug 2020 11:04:08 +0200 Subject: [PATCH 07/21] Add first go on compressing paths into tree --- src/treeifier.test.ts | 55 +++++++++++++++++++++++++++++ src/treeifier.ts | 82 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 137 insertions(+) create mode 100644 src/treeifier.test.ts create mode 100644 src/treeifier.ts diff --git a/src/treeifier.test.ts b/src/treeifier.test.ts new file mode 100644 index 0000000..2f40c9c --- /dev/null +++ b/src/treeifier.test.ts @@ -0,0 +1,55 @@ +import { TreeEntry, treeify, detreeify } from './treeifier'; + +describe('treeify & detreeify', () => { + interface TestCase { + input: TreeEntry[]; + expectedOutput: object; + } + + const cases: Record = { + simple: { + input: [ + { + path: ['hello', 'world'], + value: 'lol', + }, + { + path: ['hello', 'reader'], + value: 'lel', + }, + ], + expectedOutput: { + hello: { + world: 'lol', + reader: 'lel', + }, + }, + }, + rootHasValue: { + input: [ + { + path: [], + value: 'lol', + }, + { + path: ['hello'], + value: 'world', + }, + ], + expectedOutput: [ + 'lol', + { + hello: 'world', + }, + ], + }, + }; + + Object.entries(cases).forEach(([name, testcase]) => { + test(name, () => { + const output = treeify(testcase.input); + expect(output).toEqual(testcase.expectedOutput); + expect(detreeify(output)).toEqual(testcase.input); + }); + }); +}); diff --git a/src/treeifier.ts b/src/treeifier.ts new file mode 100644 index 0000000..0b84905 --- /dev/null +++ b/src/treeifier.ts @@ -0,0 +1,82 @@ +import { isArray, isString } from './is'; + +export type Path = (string | number)[]; +export interface TreeEntry { + path: Path; + value: string; +} + +type TreeValueNode = [string, TreeInnerNode]; + +interface TreeInnerNode extends Record {} + +type Tree = TreeValueNode | TreeInnerNode; + +export const treeify = (entries: TreeEntry[]): Tree => { + let result: Tree = {}; + + entries.forEach(entry => { + const { path } = entry; + if (path.length === 0) { + result = [entry.value, result as TreeInnerNode]; + return; + } + + const front = path.slice(0, path.length - 1); + const end = path[path.length - 1]; + + let parent: Tree = result; + for (const segment of front) { + if (isArray(parent)) { + const [, realParent] = parent; + parent = realParent; + } + + if (!parent.hasOwnProperty(segment)) { + parent[segment] = {}; + } + + if (isString(parent[segment])) { + parent[segment] = [parent[segment] as string, {}]; + } else { + parent = parent[segment] as TreeInnerNode; + } + } + + if (isArray(parent)) { + const [, realParent] = parent; + parent = realParent; + } + + (parent as TreeInnerNode)[end] = entry.value; + }); + + return result; +}; + +export const detreeify = (tree: Tree): TreeEntry[] => { + if (isArray(tree)) { + const [value, children] = tree; + + const rootEntry: TreeEntry = { + path: [], + value, + }; + const childrenEntries = detreeify(children); + return [rootEntry, ...childrenEntries]; + } + + return Object.entries(tree).flatMap(([segment, child]) => { + if (isString(child)) { + return { + path: [segment], + value: child, + }; + } + + return detreeify(child).map(entry => ({ + path: [segment, ...entry.path], + value: entry.value, + })); + }); +}; From 6182b20dd843aa360f7ebac279504e73341c6968 Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Sun, 2 Aug 2020 11:46:54 +0200 Subject: [PATCH 08/21] Add treecompressor --- src/pathstringifier.test.ts | 10 ++++++ src/pathstringifier.ts | 8 ++--- src/treecompressor.test.ts | 36 ++++++++++++++++++++ src/treecompressor.ts | 66 +++++++++++++++++++++++++++++++++++++ src/treeifier.ts | 6 ++-- 5 files changed, 119 insertions(+), 7 deletions(-) create mode 100644 src/pathstringifier.test.ts create mode 100644 src/treecompressor.test.ts create mode 100644 src/treecompressor.ts diff --git a/src/pathstringifier.test.ts b/src/pathstringifier.test.ts new file mode 100644 index 0000000..9d5fa52 --- /dev/null +++ b/src/pathstringifier.test.ts @@ -0,0 +1,10 @@ +import { escapeKey } from './pathstringifier'; + +describe('escapeKey', () => { + test.each([ + ['dontescape', 'dontescape'], + ['escape.me', 'escape\\.me'], + ])('escapeKey(%s) === %s', (input, expectedOutput) => { + expect(escapeKey(input)).toEqual(expectedOutput); + }); +}); diff --git a/src/pathstringifier.ts b/src/pathstringifier.ts index 7b5dfe5..35cc426 100644 --- a/src/pathstringifier.ts +++ b/src/pathstringifier.ts @@ -1,17 +1,17 @@ export type StringifiedPath = string; type Path = (number | string)[]; -const escape = (key: string) => key.replace(/\./g, '\\.'); -const unescape = (k: string) => k.replace(/\\\./g, '.'); +export const escapeKey = (key: string) => key.replace(/\./g, '\\.'); +export const unescapeKey = (k: string) => k.replace(/\\\./g, '.'); export const stringifyPath = (path: Path): StringifiedPath => path .map(String) - .map(escape) + .map(escapeKey) .join('.'); export const parsePath = (string: StringifiedPath): Path => - string.split(/(? { + interface TestCase { + input: Tree; + expectedOutput: Tree; + } + + const cases: Record = { + simple: { + input: { + this: { is: { a: { nested: 'tree' } } }, + }, + expectedOutput: { + 'this.is.a.nested': 'tree', + }, + }, + 'with keys that need escaping': { + input: { + 'this.needs': { 'to.be': 'escaped' }, + }, + expectedOutput: { + 'this\\.needs.to\\.be': 'escaped', + }, + }, + }; + + Object.entries(cases).forEach(([name, testcase]) => { + test(name, () => { + const output = compress(testcase.input); + expect(output).toEqual(testcase.expectedOutput); + expect(uncompress(output)).toEqual(testcase.input); + }); + }); +}); diff --git a/src/treecompressor.ts b/src/treecompressor.ts new file mode 100644 index 0000000..da01b44 --- /dev/null +++ b/src/treecompressor.ts @@ -0,0 +1,66 @@ +import { Tree, TreeInnerNode } from './treeifier'; +import { isArray, isString, isPlainObject } from './is'; +import { escapeKey, parsePath } from './pathstringifier'; + +export const compress = (tree: Tree): Tree => { + if (isArray(tree)) { + return tree; + } + + return Object.fromEntries( + Object.entries(tree).map(([segment, child]) => { + if (isString(child) || isArray(child)) { + return [segment, child]; + } + + if (isPlainObject(child)) { + child = Object.fromEntries( + Object.entries(child).map(([key, value]) => [escapeKey(key), value]) + ); + child = compress(child) as TreeInnerNode; + } + + const keysOfChild = Object.keys(child); + const hasMultipleKeys = keysOfChild.length > 1; + if (hasMultipleKeys) { + return [segment, child]; + } + + const [singleChildKey] = keysOfChild; + let singleChildValue = child[singleChildKey]; + + return [`${escapeKey(segment)}.${singleChildKey}`, singleChildValue]; + }) + ); +}; + +export const uncompress = (tree: Tree): Tree => { + if (isArray(tree)) { + return tree; + } + + return Object.fromEntries( + Object.entries(tree).map(([segment, child]) => { + if (!isString(child)) { + return [segment, child]; + } + + const path = parsePath(segment); + const firstKey = path[0]; + const innerKeys = path.slice(1, path.length - 1); + const lastKey = path[path.length - 1]; + + const newChild: TreeInnerNode = {}; + let lastNode = newChild; + for (const key of innerKeys) { + const newNode = {}; + lastNode[key] = newNode; + lastNode = newNode; + } + + lastNode[lastKey] = child; + + return [firstKey, newChild]; + }) + ); +}; diff --git a/src/treeifier.ts b/src/treeifier.ts index 0b84905..92b1680 100644 --- a/src/treeifier.ts +++ b/src/treeifier.ts @@ -6,11 +6,11 @@ export interface TreeEntry { value: string; } -type TreeValueNode = [string, TreeInnerNode]; +export type TreeValueNode = [string, TreeInnerNode]; -interface TreeInnerNode extends Record {} +export interface TreeInnerNode extends Record {} -type Tree = TreeValueNode | TreeInnerNode; +export type Tree = TreeValueNode | TreeInnerNode; export const treeify = (entries: TreeEntry[]): Tree => { let result: Tree = {}; From 6730f1b089dc5d1f5ef07a839922a2709a2301ba Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Sun, 2 Aug 2020 12:36:31 +0200 Subject: [PATCH 09/21] Use treeifier for `value` annotations --- src/annotator.ts | 47 ++++++++++++++++---------------------- src/index.test.ts | 8 ++----- src/treecompressor.test.ts | 8 +++++++ src/treecompressor.ts | 12 +++++++--- src/treeifier.test.ts | 20 ++++++++++++++++ src/treeifier.ts | 39 +++++++++++++++++++------------ 6 files changed, 83 insertions(+), 51 deletions(-) diff --git a/src/annotator.ts b/src/annotator.ts index 3ae71ad..f868e95 100644 --- a/src/annotator.ts +++ b/src/annotator.ts @@ -10,20 +10,22 @@ import { import { Walker } from './plainer'; import { TypeAnnotation, - isTypeAnnotation, + // isTypeAnnotation, transformValue, untransformValue, } from './transformer'; +import { TreeEntry, treeify, detreeify, Tree } from './treeifier'; +import * as TreeCompressor from './treecompressor'; export interface Annotations { - root?: TypeAnnotation; - values?: Record; + values?: Tree; referentialEqualities?: Record; referentialEqualitiesRoot?: StringifiedPath[]; } export function isAnnotations(object: any): object is Annotations { try { + /* if (!!object.root && !isTypeAnnotation(object.root)) { return false; } @@ -33,6 +35,7 @@ export function isAnnotations(object: any): object is Annotations { ([key, value]) => isStringifiedPath(key) && isTypeAnnotation(value) ); } + */ if (!!object.referentialEqualities) { return Object.entries(object.referentialEqualities).every( @@ -48,6 +51,7 @@ export function isAnnotations(object: any): object is Annotations { } export const makeAnnotator = () => { + const valueAnnotations: TreeEntry[] = []; const annotations: Annotations = {}; const objectIdentities = new Map(); @@ -65,15 +69,10 @@ export const makeAnnotator = () => { const transformed = transformValue(node); if (transformed) { - if (path.length === 0) { - annotations.root = transformed.type; - } else { - if (!annotations.values) { - annotations.values = {}; - } - - annotations.values[stringifyPath(path)] = transformed.type; - } + valueAnnotations.push({ + path: path.map(String), + value: transformed.type, + }); return transformed.value; } else { @@ -101,6 +100,10 @@ export const makeAnnotator = () => { } }); + if (valueAnnotations.length > 0) { + annotations.values = TreeCompressor.compress(treeify(valueAnnotations)); + } + return annotations; } @@ -109,23 +112,13 @@ export const makeAnnotator = () => { export const applyAnnotations = (plain: any, annotations: Annotations): any => { if (annotations.values) { - const annotationsWithPaths = Object.entries(annotations.values).map( - ([key, type]) => [parsePath(key), type] as [string[], TypeAnnotation] - ); - - const annotationsWithPathsLeavesToRoot = annotationsWithPaths.sort( - ([pathA], [pathB]) => pathB.length - pathA.length + const valueAnnotations = detreeify( + TreeCompressor.uncompress(annotations.values) ); - for (const [path, type] of annotationsWithPathsLeavesToRoot) { - plain = setDeep(plain, path, v => - untransformValue(v, type as TypeAnnotation) - ); - } - } - - if (annotations.root) { - plain = untransformValue(plain, annotations.root); + valueAnnotations.forEach(({ path, value: type }) => { + plain = setDeep(plain, path, v => untransformValue(v, type)); + }); } if (annotations.referentialEqualities) { diff --git a/src/index.test.ts b/src/index.test.ts index eb4e0e4..cabf4a7 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -58,8 +58,7 @@ describe('stringify & parse', () => { }, outputAnnotations: { values: { - a: 'set', - 'a.1': 'undefined', + a: ['set', { 1: 'undefined' }], }, }, }, @@ -68,10 +67,7 @@ describe('stringify & parse', () => { input: new Set([1, undefined, 2]), output: [1, undefined, 2], outputAnnotations: { - root: 'set', - values: { - '1': 'undefined', - }, + values: ['set', { 1: 'undefined' }], }, }, diff --git a/src/treecompressor.test.ts b/src/treecompressor.test.ts index f8b9c9d..983cc92 100644 --- a/src/treecompressor.test.ts +++ b/src/treecompressor.test.ts @@ -24,6 +24,14 @@ describe('compress & uncompress', () => { 'this\\.needs.to\\.be': 'escaped', }, }, + 'a: map:number': { + input: { + a: 'map:number', + }, + expectedOutput: { + a: 'map:number', + }, + }, }; Object.entries(cases).forEach(([name, testcase]) => { diff --git a/src/treecompressor.ts b/src/treecompressor.ts index da01b44..9850494 100644 --- a/src/treecompressor.ts +++ b/src/treecompressor.ts @@ -2,7 +2,7 @@ import { Tree, TreeInnerNode } from './treeifier'; import { isArray, isString, isPlainObject } from './is'; import { escapeKey, parsePath } from './pathstringifier'; -export const compress = (tree: Tree): Tree => { +export const compress = (tree: Tree): Tree => { if (isArray(tree)) { return tree; } @@ -17,7 +17,7 @@ export const compress = (tree: Tree): Tree => { child = Object.fromEntries( Object.entries(child).map(([key, value]) => [escapeKey(key), value]) ); - child = compress(child) as TreeInnerNode; + child = compress(child) as TreeInnerNode; } const keysOfChild = Object.keys(child); @@ -34,7 +34,9 @@ export const compress = (tree: Tree): Tree => { ); }; -export const uncompress = (tree: Tree): Tree => { +export const uncompress = ( + tree: Tree +): Tree => { if (isArray(tree)) { return tree; } @@ -46,6 +48,10 @@ export const uncompress = (tree: Tree): Tree => { } const path = parsePath(segment); + if (path.length === 1) { + return [segment, child]; + } + const firstKey = path[0]; const innerKeys = path.slice(1, path.length - 1); const lastKey = path[path.length - 1]; diff --git a/src/treeifier.test.ts b/src/treeifier.test.ts index 2f40c9c..387682b 100644 --- a/src/treeifier.test.ts +++ b/src/treeifier.test.ts @@ -43,6 +43,26 @@ describe('treeify & detreeify', () => { }, ], }, + anotherTestCase: { + input: [ + { path: ['a'], value: 'set' }, + { path: ['a', '1'], value: 'undefined' }, + ], + expectedOutput: { + a: [ + 'set', + { + 1: 'undefined', + }, + ], + }, + }, + aa: { + input: [{ path: ['a'], value: 'map:number' }], + expectedOutput: { + a: 'map:number', + }, + }, }; Object.entries(cases).forEach(([name, testcase]) => { diff --git a/src/treeifier.ts b/src/treeifier.ts index 92b1680..b95e5ee 100644 --- a/src/treeifier.ts +++ b/src/treeifier.ts @@ -1,24 +1,29 @@ import { isArray, isString } from './is'; -export type Path = (string | number)[]; -export interface TreeEntry { +export type Path = string[]; +export interface TreeEntry { path: Path; - value: string; + value: T; } -export type TreeValueNode = [string, TreeInnerNode]; +export type TreeValueNode = [T, TreeInnerNode]; -export interface TreeInnerNode extends Record {} +export interface TreeInnerNode + extends Record> {} -export type Tree = TreeValueNode | TreeInnerNode; +export type Tree = + | TreeValueNode + | TreeInnerNode; -export const treeify = (entries: TreeEntry[]): Tree => { - let result: Tree = {}; +export const treeify = ( + entries: TreeEntry[] +): Tree => { + let result: Tree = {}; entries.forEach(entry => { const { path } = entry; if (path.length === 0) { - result = [entry.value, result as TreeInnerNode]; + result = [entry.value, result as TreeInnerNode]; return; } @@ -37,9 +42,11 @@ export const treeify = (entries: TreeEntry[]): Tree => { } if (isString(parent[segment])) { - parent[segment] = [parent[segment] as string, {}]; + const newNode: TreeValueNode = [parent[segment] as T, {}]; + parent[segment] = newNode; + parent = newNode; } else { - parent = parent[segment] as TreeInnerNode; + parent = parent[segment] as TreeInnerNode; } } @@ -48,17 +55,19 @@ export const treeify = (entries: TreeEntry[]): Tree => { parent = realParent; } - (parent as TreeInnerNode)[end] = entry.value; + (parent as TreeInnerNode)[end] = entry.value; }); return result; }; -export const detreeify = (tree: Tree): TreeEntry[] => { +export const detreeify = ( + tree: Tree +): TreeEntry[] => { if (isArray(tree)) { const [value, children] = tree; - const rootEntry: TreeEntry = { + const rootEntry = { path: [], value, }; @@ -71,7 +80,7 @@ export const detreeify = (tree: Tree): TreeEntry[] => { return { path: [segment], value: child, - }; + } as TreeEntry; } return detreeify(child).map(entry => ({ From 639e025957b1652f648abcbcff0d2f3fe895e2c6 Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Sun, 2 Aug 2020 15:13:58 +0200 Subject: [PATCH 10/21] Also model `referentialIdentites` using the tree --- src/annotator.test.ts | 2 +- src/annotator.ts | 118 +++++++++++++++--------------- src/index.test.ts | 32 ++++----- src/iteratorutils.ts | 13 +++- src/treecompressor.test.ts | 16 ++--- src/treecompressor.ts | 71 ++++++++++-------- src/treeifier.test.ts | 15 ++-- src/treeifier.ts | 144 ++++++++++++++++++++++++++----------- 8 files changed, 247 insertions(+), 164 deletions(-) diff --git a/src/annotator.test.ts b/src/annotator.test.ts index c229dcc..5b35371 100644 --- a/src/annotator.test.ts +++ b/src/annotator.test.ts @@ -14,7 +14,7 @@ describe('annotator', () => { expect(getAnnotations()).toEqual({ values: { - 'a.1.b': 'undefined', + 'a.1.b': ['undefined'], }, }); }); diff --git a/src/annotator.ts b/src/annotator.ts index f868e95..0919857 100644 --- a/src/annotator.ts +++ b/src/annotator.ts @@ -1,47 +1,44 @@ import { getDeep, setDeep } from './accessDeep'; -import { isPrimitive } from './is'; +import { isPrimitive, isString } from './is'; import * as IteratorUtils from './iteratorutils'; -import { - StringifiedPath, - isStringifiedPath, - parsePath, - stringifyPath, -} from './pathstringifier'; import { Walker } from './plainer'; import { TypeAnnotation, // isTypeAnnotation, transformValue, untransformValue, + isTypeAnnotation, } from './transformer'; -import { TreeEntry, treeify, detreeify, Tree } from './treeifier'; +import { + TreeEntry, + treeify, + detreeify, + Tree, + treeifyPaths, + detreeifyPaths, + isTree, +} from './treeifier'; import * as TreeCompressor from './treecompressor'; export interface Annotations { values?: Tree; - referentialEqualities?: Record; - referentialEqualitiesRoot?: StringifiedPath[]; + referentialEqualities?: Tree>; } export function isAnnotations(object: any): object is Annotations { try { - /* - if (!!object.root && !isTypeAnnotation(object.root)) { - return false; - } - - if (!!object.values) { - return Object.entries(object.values).every( - ([key, value]) => isStringifiedPath(key) && isTypeAnnotation(value) - ); + if (object.values) { + if (!isTree(object.values, isTypeAnnotation)) { + return false; + } } - */ - if (!!object.referentialEqualities) { - return Object.entries(object.referentialEqualities).every( - ([key, value]) => - isStringifiedPath(key) && (value as string[]).every(isStringifiedPath) - ); + if (object.referentialEqualities) { + if ( + !isTree(object.referentialEqualities, tree => isTree(tree, isString)) + ) { + return false; + } } return true; @@ -52,7 +49,6 @@ export function isAnnotations(object: any): object is Annotations { export const makeAnnotator = () => { const valueAnnotations: TreeEntry[] = []; - const annotations: Annotations = {}; const objectIdentities = new Map(); function registerObjectPath(object: any, path: any[]) { @@ -81,27 +77,39 @@ export const makeAnnotator = () => { }; function getAnnotations(): Annotations { - IteratorUtils.forEach(objectIdentities.values(), paths => { - if (paths.length > 1) { - const [shortestPath, ...identityPaths] = paths - .sort((a, b) => a.length - b.length) - .map(stringifyPath); - - const isRoot = shortestPath.length === 0; - if (isRoot) { - annotations.referentialEqualitiesRoot = identityPaths; - } else { - if (!annotations.referentialEqualities) { - annotations.referentialEqualities = {}; - } - - annotations.referentialEqualities[shortestPath] = identityPaths; - } + const annotations: Annotations = {}; + + const annotationsValuesTree = TreeCompressor.compress( + treeify(valueAnnotations) + ); + if (Object.keys(annotationsValuesTree).length > 0) { + annotations.values = annotationsValuesTree; + } + + const referentialEqualitiesTreeEntries = IteratorUtils.flatMap< + any[][], + TreeEntry> + >(objectIdentities.values(), paths => { + if (paths.length <= 1) { + return []; } + + const [shortestPath, ...identityPaths] = paths.sort( + (a, b) => a.length - b.length + ); + return [ + { + path: shortestPath, + value: TreeCompressor.compress(treeifyPaths(identityPaths)!), + }, + ]; }); - if (valueAnnotations.length > 0) { - annotations.values = TreeCompressor.compress(treeify(valueAnnotations)); + const annotationsReferentialEqualitiesTree = TreeCompressor.compress( + treeify(referentialEqualitiesTreeEntries) + ); + if (Object.keys(annotationsReferentialEqualitiesTree).length > 0) { + annotations.referentialEqualities = annotationsReferentialEqualitiesTree; } return annotations; @@ -122,24 +130,20 @@ export const applyAnnotations = (plain: any, annotations: Annotations): any => { } if (annotations.referentialEqualities) { - for (const [objectPath, identicalObjectsPaths] of Object.entries( - annotations.referentialEqualities - )) { - const object = getDeep(plain, parsePath(objectPath)); + const referentialEqualitiesAnnotations = detreeify( + TreeCompressor.uncompress(annotations.referentialEqualities) + ); + for (const { path, value } of referentialEqualitiesAnnotations) { + const object = getDeep(plain, path); - for (const identicalObjectPath of identicalObjectsPaths.map(parsePath)) { + const identicalObjectPaths = detreeifyPaths( + TreeCompressor.uncompress(value) + ); + for (const identicalObjectPath of identicalObjectPaths) { setDeep(plain, identicalObjectPath, () => object); } } } - if (annotations.referentialEqualitiesRoot) { - for (const identicalObjectPath of annotations.referentialEqualitiesRoot.map( - parsePath - )) { - setDeep(plain, identicalObjectPath, () => plain); - } - } - return plain; }; diff --git a/src/index.test.ts b/src/index.test.ts index cabf4a7..88ca41a 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -44,7 +44,7 @@ describe('stringify & parse', () => { }, outputAnnotations: { values: { - 'a.1': 'undefined', + 'a.1': ['undefined'], }, }, }, @@ -58,7 +58,7 @@ describe('stringify & parse', () => { }, outputAnnotations: { values: { - a: ['set', { 1: 'undefined' }], + a: ['set', { 1: ['undefined'] }], }, }, }, @@ -67,7 +67,7 @@ describe('stringify & parse', () => { input: new Set([1, undefined, 2]), output: [1, undefined, 2], outputAnnotations: { - values: ['set', { 1: 'undefined' }], + values: ['set', { 1: ['undefined'] }], }, }, @@ -96,9 +96,9 @@ describe('stringify & parse', () => { outputAnnotations: { values: { - a: 'map:number', - b: 'map:string', - d: 'map:boolean', + a: ['map:number'], + b: ['map:string'], + d: ['map:boolean'], }, }, }, @@ -118,7 +118,7 @@ describe('stringify & parse', () => { }, outputAnnotations: { referentialEqualities: { - selected: ['options.0'], + selected: [{ options: ['0'] }], }, }, customExpectations: output => { @@ -139,7 +139,7 @@ describe('stringify & parse', () => { }, outputAnnotations: { values: { - 'a\\.1.b': 'set', + 'a\\.1.b': ['set'], }, }, }, @@ -157,7 +157,7 @@ describe('stringify & parse', () => { }, outputAnnotations: { values: { - 'a\\\\.1.b': 'set', + 'a\\\\.1.b': ['set'], }, }, }, @@ -175,7 +175,7 @@ describe('stringify & parse', () => { }, outputAnnotations: { values: { - 'meeting.date': 'Date', + 'meeting.date': ['Date'], }, }, }, @@ -189,7 +189,7 @@ describe('stringify & parse', () => { }, outputAnnotations: { values: { - a: 'regexp', + a: ['regexp'], }, }, }, @@ -203,7 +203,7 @@ describe('stringify & parse', () => { }, outputAnnotations: { values: { - a: 'Infinity', + a: ['Infinity'], }, }, }, @@ -217,7 +217,7 @@ describe('stringify & parse', () => { }, outputAnnotations: { values: { - a: '-Infinity', + a: ['-Infinity'], }, }, }, @@ -231,7 +231,7 @@ describe('stringify & parse', () => { }, outputAnnotations: { values: { - a: 'NaN', + a: ['NaN'], }, }, }, @@ -245,7 +245,7 @@ describe('stringify & parse', () => { }, outputAnnotations: { values: { - a: 'bigint', + a: ['bigint'], }, }, }, @@ -267,7 +267,7 @@ describe('stringify & parse', () => { ], }, outputAnnotations: { - referentialEqualitiesRoot: ['children.0.parents.0'], + referentialEqualities: [{ 'children.0.parents': ['0'] }], }, }, }; diff --git a/src/iteratorutils.ts b/src/iteratorutils.ts index b2846f2..877b33e 100644 --- a/src/iteratorutils.ts +++ b/src/iteratorutils.ts @@ -9,15 +9,22 @@ export const forEach = (iterator: Iterator, func: (v: T) => void) => { } }; -export const map = ( +export const flatMap = ( iterator: Iterator, - map: (v: A, index: number) => B + map: (v: A, index: number) => B[] ): B[] => { const result: B[] = []; forEach(iterator, value => { - result.push(map(value, result.length)); + result.push(...map(value, result.length)); }); return result; }; + +export const map = ( + iterator: Iterator, + map: (v: A, index: number) => B +): B[] => { + return flatMap(iterator, (v, i) => [map(v, i)]); +}; diff --git a/src/treecompressor.test.ts b/src/treecompressor.test.ts index 983cc92..16a2869 100644 --- a/src/treecompressor.test.ts +++ b/src/treecompressor.test.ts @@ -3,33 +3,33 @@ import { compress, uncompress } from './treecompressor'; describe('compress & uncompress', () => { interface TestCase { - input: Tree; - expectedOutput: Tree; + input: Tree; + expectedOutput: Tree; } const cases: Record = { simple: { input: { - this: { is: { a: { nested: 'tree' } } }, + this: { is: { a: { nested: ['tree'] } } }, }, expectedOutput: { - 'this.is.a.nested': 'tree', + 'this.is.a.nested': ['tree'], }, }, 'with keys that need escaping': { input: { - 'this.needs': { 'to.be': 'escaped' }, + 'this.needs': { 'to.be': ['escaped'] }, }, expectedOutput: { - 'this\\.needs.to\\.be': 'escaped', + 'this\\.needs.to\\.be': ['escaped'], }, }, 'a: map:number': { input: { - a: 'map:number', + a: ['map:number'], }, expectedOutput: { - a: 'map:number', + a: ['map:number'], }, }, }; diff --git a/src/treecompressor.ts b/src/treecompressor.ts index 9850494..a7f2d60 100644 --- a/src/treecompressor.ts +++ b/src/treecompressor.ts @@ -1,52 +1,65 @@ -import { Tree, TreeInnerNode } from './treeifier'; -import { isArray, isString, isPlainObject } from './is'; +import { + Tree, + TreeInnerNodeWithoutValue, + TreeInnerNodeWithValue, +} from './treeifier'; +import { isArray } from './is'; import { escapeKey, parsePath } from './pathstringifier'; -export const compress = (tree: Tree): Tree => { - if (isArray(tree)) { +export const compress = (tree: Tree): Tree => { + const isLeaf = isArray(tree) && tree.length === 1; + if (isLeaf) { return tree; } + const isInnerNodeWithValue = isArray(tree) && tree.length === 2; + if (isInnerNodeWithValue) { + const [value, children] = tree as TreeInnerNodeWithValue; + return [value, compress(children) as TreeInnerNodeWithoutValue]; + } + return Object.fromEntries( - Object.entries(tree).map(([segment, child]) => { - if (isString(child) || isArray(child)) { - return [segment, child]; - } + Object.entries(tree as TreeInnerNodeWithoutValue).map( + ([edge, child]) => { + const childHasValue = isArray(child); + if (childHasValue) { + return [edge, child]; + } - if (isPlainObject(child)) { child = Object.fromEntries( Object.entries(child).map(([key, value]) => [escapeKey(key), value]) ); - child = compress(child) as TreeInnerNode; - } + child = compress(child) as TreeInnerNodeWithoutValue; - const keysOfChild = Object.keys(child); - const hasMultipleKeys = keysOfChild.length > 1; - if (hasMultipleKeys) { - return [segment, child]; - } + const keysOfChild = Object.keys(child); + const hasMultipleKeys = keysOfChild.length > 1; + if (hasMultipleKeys) { + return [edge, child]; + } - const [singleChildKey] = keysOfChild; - let singleChildValue = child[singleChildKey]; + const [singleChildKey] = keysOfChild; + let singleChildValue = child[singleChildKey]; - return [`${escapeKey(segment)}.${singleChildKey}`, singleChildValue]; - }) + return [`${escapeKey(edge)}.${singleChildKey}`, singleChildValue]; + } + ) ); }; -export const uncompress = ( - tree: Tree -): Tree => { - if (isArray(tree)) { +export const uncompress = (tree: Tree): Tree => { + const isLeaf = isArray(tree) && tree.length === 1; + if (isLeaf) { return tree; } + const isInnerNodeWithValue = isArray(tree) && tree.length === 2; + if (isInnerNodeWithValue) { + const [value, children] = tree as TreeInnerNodeWithValue; + return [value, uncompress(children) as TreeInnerNodeWithoutValue]; + } + return Object.fromEntries( Object.entries(tree).map(([segment, child]) => { - if (!isString(child)) { - return [segment, child]; - } - const path = parsePath(segment); if (path.length === 1) { return [segment, child]; @@ -56,7 +69,7 @@ export const uncompress = ( const innerKeys = path.slice(1, path.length - 1); const lastKey = path[path.length - 1]; - const newChild: TreeInnerNode = {}; + const newChild: TreeInnerNodeWithoutValue = {}; let lastNode = newChild; for (const key of innerKeys) { const newNode = {}; diff --git a/src/treeifier.test.ts b/src/treeifier.test.ts index 387682b..84f676d 100644 --- a/src/treeifier.test.ts +++ b/src/treeifier.test.ts @@ -2,7 +2,7 @@ import { TreeEntry, treeify, detreeify } from './treeifier'; describe('treeify & detreeify', () => { interface TestCase { - input: TreeEntry[]; + input: TreeEntry[]; expectedOutput: object; } @@ -20,8 +20,8 @@ describe('treeify & detreeify', () => { ], expectedOutput: { hello: { - world: 'lol', - reader: 'lel', + world: ['lol'], + reader: ['lel'], }, }, }, @@ -39,7 +39,7 @@ describe('treeify & detreeify', () => { expectedOutput: [ 'lol', { - hello: 'world', + hello: ['world'], }, ], }, @@ -52,7 +52,7 @@ describe('treeify & detreeify', () => { a: [ 'set', { - 1: 'undefined', + 1: ['undefined'], }, ], }, @@ -60,7 +60,7 @@ describe('treeify & detreeify', () => { aa: { input: [{ path: ['a'], value: 'map:number' }], expectedOutput: { - a: 'map:number', + a: ['map:number'], }, }, }; @@ -69,7 +69,8 @@ describe('treeify & detreeify', () => { test(name, () => { const output = treeify(testcase.input); expect(output).toEqual(testcase.expectedOutput); - expect(detreeify(output)).toEqual(testcase.input); + expect(output).toBeDefined(); + expect(detreeify(output!)).toEqual(testcase.input); }); }); }); diff --git a/src/treeifier.ts b/src/treeifier.ts index b95e5ee..6baeda4 100644 --- a/src/treeifier.ts +++ b/src/treeifier.ts @@ -1,91 +1,149 @@ -import { isArray, isString } from './is'; +import { isArray, isPlainObject } from './is'; export type Path = string[]; -export interface TreeEntry { +export interface TreeEntry { path: Path; value: T; } -export type TreeValueNode = [T, TreeInnerNode]; +export type TreeLeaf = [T]; -export interface TreeInnerNode - extends Record> {} +export type TreeInnerNodeWithValue = [T, TreeInnerNodeWithoutValue]; -export type Tree = - | TreeValueNode - | TreeInnerNode; +export interface TreeInnerNodeWithoutValue extends Record> {} -export const treeify = ( - entries: TreeEntry[] -): Tree => { +export type Tree = + | TreeLeaf + | TreeInnerNodeWithoutValue + | TreeInnerNodeWithValue; + +export const isTree = ( + v: any, + valueChecker: (v: T) => boolean = () => true +): v is Tree => { + try { + if (isArray(v)) { + if (v.length === 1) { + return valueChecker(v[0]); + } else if (v.length === 2) { + return valueChecker(v[0]) && isTree(v[1], valueChecker); + } else { + return false; + } + } + + if (isPlainObject(v)) { + return Object.values(v).every(v => isTree(v, valueChecker)); + } + + return true; + } catch (error) { + return false; + } +}; + +export const treeify = (entries: TreeEntry[]): Tree => { let result: Tree = {}; - entries.forEach(entry => { + for (const entry of entries) { const { path } = entry; if (path.length === 0) { - result = [entry.value, result as TreeInnerNode]; - return; + const resultIsEmpty = Object.keys(result).length === 0; + if (resultIsEmpty) { + result = [entry.value]; + } else { + result = [entry.value, result as TreeInnerNodeWithoutValue]; + } + + continue; } const front = path.slice(0, path.length - 1); const end = path[path.length - 1]; - let parent: Tree = result; + let parent: Tree = result; for (const segment of front) { - if (isArray(parent)) { - const [, realParent] = parent; + const isValueNode = isArray(parent); + const hasChild = isValueNode && parent.length > 1; + if (hasChild) { + const [, realParent] = parent as TreeInnerNodeWithValue; parent = realParent; } + parent = parent as TreeInnerNodeWithoutValue; + if (!parent.hasOwnProperty(segment)) { parent[segment] = {}; } - if (isString(parent[segment])) { - const newNode: TreeValueNode = [parent[segment] as T, {}]; + const childIsValueNode = isArray(parent[segment]); + + if (childIsValueNode) { + const leaf = parent[segment] as TreeLeaf; + const [leafValue] = leaf; + const newNode: TreeInnerNodeWithValue = [leafValue, {}]; parent[segment] = newNode; parent = newNode; } else { - parent = parent[segment] as TreeInnerNode; + parent = parent[segment]; } } if (isArray(parent)) { - const [, realParent] = parent; - parent = realParent; + const type = parent.length === 1 ? 'leaf' : 'inner_node'; + if (type === 'leaf') { + parent[1] = { + [end]: [entry.value], + }; + } + if (type === 'inner_node') { + parent[1]![end] = [entry.value]; + } + } else { + parent[end] = [entry.value]; } - - (parent as TreeInnerNode)[end] = entry.value; - }); + } return result; }; -export const detreeify = ( - tree: Tree -): TreeEntry[] => { +export const detreeify = (tree: Tree): TreeEntry[] => { if (isArray(tree)) { - const [value, children] = tree; - - const rootEntry = { - path: [], - value, - }; - const childrenEntries = detreeify(children); - return [rootEntry, ...childrenEntries]; + const [value, children = {}] = tree; + + return [ + { + path: [], + value, + }, + ...detreeify(children), + ]; } return Object.entries(tree).flatMap(([segment, child]) => { - if (isString(child)) { - return { - path: [segment], - value: child, - } as TreeEntry; - } - return detreeify(child).map(entry => ({ path: [segment, ...entry.path], value: entry.value, })); }); }; + +export const treeifyPaths = (paths: Path[]): Tree => { + return treeify( + paths.map(path => { + const front = path.slice(0, path.length - 1); + const lastSegment = path[path.length - 1]; + return { + path: front, + value: '' + lastSegment, + }; + }) + ); +}; + +export const detreeifyPaths = (tree: Tree): Path[] => { + return detreeify(tree).map(treeEntry => { + const { path: front, value: lastSegment } = treeEntry; + return [...front, '' + lastSegment]; + }); +}; From fc665bd70e9badaeb79de341da1a30fe17416283 Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Sun, 2 Aug 2020 18:56:55 +0200 Subject: [PATCH 11/21] Create highly-specialised pathtree --- src/pathstringifier.ts | 2 +- src/pathtree.test.ts | 34 +++++++++++++ src/pathtree.ts | 105 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 140 insertions(+), 1 deletion(-) create mode 100644 src/pathtree.test.ts create mode 100644 src/pathtree.ts diff --git a/src/pathstringifier.ts b/src/pathstringifier.ts index 35cc426..1f78d30 100644 --- a/src/pathstringifier.ts +++ b/src/pathstringifier.ts @@ -1,5 +1,5 @@ export type StringifiedPath = string; -type Path = (number | string)[]; +type Path = string[]; export const escapeKey = (key: string) => key.replace(/\./g, '\\.'); export const unescapeKey = (k: string) => k.replace(/\\\./g, '.'); diff --git a/src/pathtree.test.ts b/src/pathtree.test.ts new file mode 100644 index 0000000..395133b --- /dev/null +++ b/src/pathtree.test.ts @@ -0,0 +1,34 @@ +import { PathTree } from './pathtree'; + +test('pathtree', () => { + let tree = PathTree.create(null); + + tree = PathTree.append(tree, ['abc'], '1'); + + expect(tree).toEqual([null, { abc: ['1'] }]); + + tree = PathTree.append(tree, ['abc', 'd'], '2'); + + expect(tree).toEqual([null, { abc: ['1', { d: ['2'] }] }]); + + tree = PathTree.append(tree, ['foo', 'bar'], '3'); + + expect(tree).toEqual([null, { abc: ['1', { d: ['2'] }], 'foo.bar': ['3'] }]); + + tree = PathTree.append(tree, ['foo', 'bar', 'lel', 'lol'], '4'); + + expect(tree).toEqual([ + null, + { abc: ['1', { d: ['2'] }], 'foo.bar': ['3', { 'lel.lol': ['4'] }] }, + ]); + + const traversalResults: [string[], string | null][] = []; + PathTree.traverse(tree, (v, path) => traversalResults.push([path, v])); + expect(traversalResults).toEqual([ + [[], null], + [['abc'], '1'], + [['abc', 'd'], '2'], + [['foo', 'bar'], '3'], + [['foo', 'bar', 'lel', 'lol'], '4'], + ]); +}); diff --git a/src/pathtree.ts b/src/pathtree.ts new file mode 100644 index 0000000..665f681 --- /dev/null +++ b/src/pathtree.ts @@ -0,0 +1,105 @@ +import { stringifyPath, parsePath } from './pathstringifier'; +import { isUndefined } from './is'; + +function isPrefix(to: T[], prefixCandidate: T[]) { + return prefixCandidate.every((value, index) => { + const isPresentInArr = to[index] === value; + return isPresentInArr; + }); +} + +export type Tree = InnerNode | Leaf; +type Leaf = [T]; +type InnerNode = [T, Record>]; + +export module PathTree { + export function create(value: T): Tree { + return [value, {}]; + } + + export function get( + tree: Tree, + path: (string | number)[] + ): [T, true] | [null, false] { + if (path.length === 0) { + return [tree[0] as T, true]; + } + + if (tree.length === 1) { + return [null, false]; + } else { + const [head, ...tail] = path; + const [, children] = tree; + return get(children[head], tail); + } + } + + /** + * @description Optimised for adding new leaves. Does not support adding inner nodes. + */ + export function append(tree: Tree, path: string[], value: T): Tree { + if (path.length === 0) { + if (tree.length === 1) { + return [value]; + } else { + const [, children] = tree; + return [value, children]; + } + } + + if (tree.length === 1) { + const [nodeValue] = tree; + return [nodeValue, { [stringifyPath(path)]: [value] }]; + } else { + const [nodeValue, children] = tree; + const availablePaths = Object.keys(children); + + // due to the constraints mentioned in the functions description, + // there may be prefixes of `path` already set, but no extensions of it. + // If there's such a prefix, we'll find it. + const prefix = availablePaths + .map(parsePath) + .find(candidate => isPrefix(path, candidate)); + + if (isUndefined(prefix)) { + return [nodeValue, { ...children, [stringifyPath(path)]: [value] }]; + } else { + const pathWithoutPrefix = path.slice(prefix.length); + const stringPrefix = stringifyPath(prefix); + return [ + nodeValue, + { + ...children, + [stringPrefix]: append( + children[stringPrefix], + pathWithoutPrefix, + value + ), + }, + ]; + } + } + } + + /** + * Depth-first traversal, + * root is traversed before its children. + */ + export function traverse( + tree: Tree, + walker: (v: T, path: string[]) => void, + origin: string[] = [] + ): void { + if (tree.length === 1) { + const [nodeValue] = tree; + walker(nodeValue, origin); + } else { + const [nodeValue, children] = tree; + walker(nodeValue, origin); + + Object.entries(children).forEach(([key, children]) => { + traverse(children, walker, [...origin, ...parsePath(key)]); + }); + } + } +} From f74393a3ab6b1a626a3af3f580d2e4a953937379 Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Sun, 2 Aug 2020 19:06:13 +0200 Subject: [PATCH 12/21] add minimizer --- src/pathtree.ts | 33 ++++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/src/pathtree.ts b/src/pathtree.ts index 665f681..8ef45a5 100644 --- a/src/pathtree.ts +++ b/src/pathtree.ts @@ -1,5 +1,5 @@ import { stringifyPath, parsePath } from './pathstringifier'; -import { isUndefined } from './is'; +import { isUndefined, isNull, isArray } from './is'; function isPrefix(to: T[], prefixCandidate: T[]) { return prefixCandidate.every((value, index) => { @@ -102,4 +102,35 @@ export module PathTree { }); } } + + /** + * @description Minimizes trees that start with a `null`-root + */ + export function minimize( + tree: Tree + ): Tree | Record> | undefined { + if (isNull(tree[0])) { + if (tree.length === 1) { + return undefined; + } else { + return tree[1] as Record>; + } + } + + return tree as Tree; + } + + export function unminimize( + tree: Tree | Record> | undefined + ): Tree { + if (isArray(tree)) { + return tree; + } + + if (isUndefined(tree)) { + return [null]; + } + + return [null, tree]; + } } From 3f0337be638b080878b605cfc0372b5d08a50971 Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Sun, 2 Aug 2020 19:36:37 +0200 Subject: [PATCH 13/21] Use specialized pathtree instead --- src/annotator.ts | 135 ++++++++++++++++++--------------- src/pathtree.ts | 67 ++++++++++++++--- src/treecompressor.test.ts | 44 ----------- src/treecompressor.ts | 85 --------------------- src/treeifier.test.ts | 76 ------------------- src/treeifier.ts | 149 ------------------------------------- 6 files changed, 133 insertions(+), 423 deletions(-) delete mode 100644 src/treecompressor.test.ts delete mode 100644 src/treecompressor.ts delete mode 100644 src/treeifier.test.ts delete mode 100644 src/treeifier.ts diff --git a/src/annotator.ts b/src/annotator.ts index 0919857..839aebd 100644 --- a/src/annotator.ts +++ b/src/annotator.ts @@ -1,41 +1,35 @@ import { getDeep, setDeep } from './accessDeep'; -import { isPrimitive, isString } from './is'; +import { isPrimitive, isNull, isString } from './is'; import * as IteratorUtils from './iteratorutils'; import { Walker } from './plainer'; import { TypeAnnotation, - // isTypeAnnotation, + isTypeAnnotation, transformValue, untransformValue, - isTypeAnnotation, } from './transformer'; -import { - TreeEntry, - treeify, - detreeify, - Tree, - treeifyPaths, - detreeifyPaths, - isTree, -} from './treeifier'; -import * as TreeCompressor from './treecompressor'; +import { PathTree } from './pathtree'; export interface Annotations { - values?: Tree; - referentialEqualities?: Tree>; + values?: PathTree.MinimizedTree; + referentialEqualities?: PathTree.MinimizedTree< + PathTree.MinimizedTree + >; } export function isAnnotations(object: any): object is Annotations { try { if (object.values) { - if (!isTree(object.values, isTypeAnnotation)) { + if (!PathTree.isMinimizedTree(object.values, isTypeAnnotation)) { return false; } } if (object.referentialEqualities) { if ( - !isTree(object.referentialEqualities, tree => isTree(tree, isString)) + !PathTree.isMinimizedTree(object.referentialEqualities, tree => + PathTree.isMinimizedTree(tree, isString) + ) ) { return false; } @@ -48,7 +42,7 @@ export function isAnnotations(object: any): object is Annotations { } export const makeAnnotator = () => { - const valueAnnotations: TreeEntry[] = []; + let valueAnnotations = PathTree.create(null); const objectIdentities = new Map(); function registerObjectPath(object: any, path: any[]) { @@ -65,10 +59,11 @@ export const makeAnnotator = () => { const transformed = transformValue(node); if (transformed) { - valueAnnotations.push({ - path: path.map(String), - value: transformed.type, - }); + valueAnnotations = PathTree.append( + valueAnnotations, + path.map(String), + transformed.type + ); return transformed.value; } else { @@ -79,37 +74,45 @@ export const makeAnnotator = () => { function getAnnotations(): Annotations { const annotations: Annotations = {}; - const annotationsValuesTree = TreeCompressor.compress( - treeify(valueAnnotations) - ); - if (Object.keys(annotationsValuesTree).length > 0) { - annotations.values = annotationsValuesTree; + const valueAnnotationsMinimized = PathTree.minimize(valueAnnotations); + if (valueAnnotationsMinimized) { + annotations.values = valueAnnotationsMinimized; } - const referentialEqualitiesTreeEntries = IteratorUtils.flatMap< - any[][], - TreeEntry> - >(objectIdentities.values(), paths => { + let referentialEqualitiesAnnotations = PathTree.create | null>(null); + + IteratorUtils.forEach(objectIdentities.values(), paths => { if (paths.length <= 1) { - return []; + return; + } + + const [shortestPath, ...identityPaths] = paths + .map(path => path.map(String)) + .sort((a, b) => a.length - b.length); + let identities = PathTree.create(null); + for (const identityPath of identityPaths) { + identities = PathTree.appendPath(identities, identityPath); + } + + const minimizedIdentities = PathTree.minimize(identities); + if (!minimizedIdentities) { + throw new Error('Illegal State'); } - const [shortestPath, ...identityPaths] = paths.sort( - (a, b) => a.length - b.length + referentialEqualitiesAnnotations = PathTree.append( + referentialEqualitiesAnnotations, + shortestPath, + minimizedIdentities ); - return [ - { - path: shortestPath, - value: TreeCompressor.compress(treeifyPaths(identityPaths)!), - }, - ]; }); - const annotationsReferentialEqualitiesTree = TreeCompressor.compress( - treeify(referentialEqualitiesTreeEntries) + const referentialEqualitiesAnnotationsMinimized = PathTree.minimize( + referentialEqualitiesAnnotations ); - if (Object.keys(annotationsReferentialEqualitiesTree).length > 0) { - annotations.referentialEqualities = annotationsReferentialEqualitiesTree; + if (referentialEqualitiesAnnotationsMinimized) { + annotations.referentialEqualities = referentialEqualitiesAnnotationsMinimized; } return annotations; @@ -120,29 +123,41 @@ export const makeAnnotator = () => { export const applyAnnotations = (plain: any, annotations: Annotations): any => { if (annotations.values) { - const valueAnnotations = detreeify( - TreeCompressor.uncompress(annotations.values) - ); + PathTree.traverse(PathTree.unminimize(annotations.values), (type, path) => { + if (isNull(type)) { + if (path.length === 0) { + return; + } + + throw new Error('Illegal State'); + } - valueAnnotations.forEach(({ path, value: type }) => { plain = setDeep(plain, path, v => untransformValue(v, type)); }); } if (annotations.referentialEqualities) { - const referentialEqualitiesAnnotations = detreeify( - TreeCompressor.uncompress(annotations.referentialEqualities) - ); - for (const { path, value } of referentialEqualitiesAnnotations) { - const object = getDeep(plain, path); - - const identicalObjectPaths = detreeifyPaths( - TreeCompressor.uncompress(value) - ); - for (const identicalObjectPath of identicalObjectPaths) { - setDeep(plain, identicalObjectPath, () => object); + PathTree.traverse( + PathTree.unminimize(annotations.referentialEqualities), + (identicalObjects, path) => { + if (isNull(identicalObjects)) { + if (path.length === 0) { + return; + } + + throw new Error('Illegal State'); + } + + const object = getDeep(plain, path); + + PathTree.traversePaths( + PathTree.unminimize(identicalObjects), + identicalObjectPath => { + plain = setDeep(plain, identicalObjectPath, () => object); + } + ); } - } + ); } return plain; diff --git a/src/pathtree.ts b/src/pathtree.ts index 8ef45a5..ee09df6 100644 --- a/src/pathtree.ts +++ b/src/pathtree.ts @@ -1,5 +1,5 @@ import { stringifyPath, parsePath } from './pathstringifier'; -import { isUndefined, isNull, isArray } from './is'; +import { isUndefined, isNull, isArray, isPlainObject } from './is'; function isPrefix(to: T[], prefixCandidate: T[]) { return prefixCandidate.every((value, index) => { @@ -12,14 +12,34 @@ export type Tree = InnerNode | Leaf; type Leaf = [T]; type InnerNode = [T, Record>]; +export function isTree( + v: any, + valueChecker: (nodeValue: T) => boolean +): v is Tree { + if (!isArray(v)) { + return false; + } + + if (v.length === 1) { + return valueChecker(v[0]); + } else if (v.length === 2) { + return ( + valueChecker(v[0]) && + Object.values(v[1]).every(v => isTree(v, valueChecker)) + ); + } + + return false; +} + export module PathTree { export function create(value: T): Tree { - return [value, {}]; + return [value]; } export function get( tree: Tree, - path: (string | number)[] + path: string[] ): [T, true] | [null, false] { if (path.length === 0) { return [tree[0] as T, true]; @@ -81,6 +101,15 @@ export module PathTree { } } + export function appendPath( + tree: Tree, + path: string[] + ): Tree { + const front = path.slice(0, path.length - 1); + const last = path[path.length - 1]; + return append(tree, front, last); + } + /** * Depth-first traversal, * root is traversed before its children. @@ -103,12 +132,34 @@ export module PathTree { } } + export function traversePaths( + tree: Tree, + walker: (path: string[]) => void + ) { + traverse(tree, (last, front) => !isNull(last) && walker([...front, last])); + } + + export type MinimizedTree = Tree | Record> | undefined; + + export function isMinimizedTree( + v: any, + valueChecker: (v: T) => boolean + ): v is MinimizedTree { + if (isUndefined(v)) { + return true; + } + + if (isPlainObject(v)) { + return Object.values(v).every(v => isTree(v, valueChecker)); + } + + return isTree(v, valueChecker); + } + /** * @description Minimizes trees that start with a `null`-root */ - export function minimize( - tree: Tree - ): Tree | Record> | undefined { + export function minimize(tree: Tree): MinimizedTree { if (isNull(tree[0])) { if (tree.length === 1) { return undefined; @@ -120,9 +171,7 @@ export module PathTree { return tree as Tree; } - export function unminimize( - tree: Tree | Record> | undefined - ): Tree { + export function unminimize(tree: MinimizedTree): Tree { if (isArray(tree)) { return tree; } diff --git a/src/treecompressor.test.ts b/src/treecompressor.test.ts deleted file mode 100644 index 16a2869..0000000 --- a/src/treecompressor.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { Tree } from './treeifier'; -import { compress, uncompress } from './treecompressor'; - -describe('compress & uncompress', () => { - interface TestCase { - input: Tree; - expectedOutput: Tree; - } - - const cases: Record = { - simple: { - input: { - this: { is: { a: { nested: ['tree'] } } }, - }, - expectedOutput: { - 'this.is.a.nested': ['tree'], - }, - }, - 'with keys that need escaping': { - input: { - 'this.needs': { 'to.be': ['escaped'] }, - }, - expectedOutput: { - 'this\\.needs.to\\.be': ['escaped'], - }, - }, - 'a: map:number': { - input: { - a: ['map:number'], - }, - expectedOutput: { - a: ['map:number'], - }, - }, - }; - - Object.entries(cases).forEach(([name, testcase]) => { - test(name, () => { - const output = compress(testcase.input); - expect(output).toEqual(testcase.expectedOutput); - expect(uncompress(output)).toEqual(testcase.input); - }); - }); -}); diff --git a/src/treecompressor.ts b/src/treecompressor.ts deleted file mode 100644 index a7f2d60..0000000 --- a/src/treecompressor.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { - Tree, - TreeInnerNodeWithoutValue, - TreeInnerNodeWithValue, -} from './treeifier'; -import { isArray } from './is'; -import { escapeKey, parsePath } from './pathstringifier'; - -export const compress = (tree: Tree): Tree => { - const isLeaf = isArray(tree) && tree.length === 1; - if (isLeaf) { - return tree; - } - - const isInnerNodeWithValue = isArray(tree) && tree.length === 2; - if (isInnerNodeWithValue) { - const [value, children] = tree as TreeInnerNodeWithValue; - return [value, compress(children) as TreeInnerNodeWithoutValue]; - } - - return Object.fromEntries( - Object.entries(tree as TreeInnerNodeWithoutValue).map( - ([edge, child]) => { - const childHasValue = isArray(child); - if (childHasValue) { - return [edge, child]; - } - - child = Object.fromEntries( - Object.entries(child).map(([key, value]) => [escapeKey(key), value]) - ); - child = compress(child) as TreeInnerNodeWithoutValue; - - const keysOfChild = Object.keys(child); - const hasMultipleKeys = keysOfChild.length > 1; - if (hasMultipleKeys) { - return [edge, child]; - } - - const [singleChildKey] = keysOfChild; - let singleChildValue = child[singleChildKey]; - - return [`${escapeKey(edge)}.${singleChildKey}`, singleChildValue]; - } - ) - ); -}; - -export const uncompress = (tree: Tree): Tree => { - const isLeaf = isArray(tree) && tree.length === 1; - if (isLeaf) { - return tree; - } - - const isInnerNodeWithValue = isArray(tree) && tree.length === 2; - if (isInnerNodeWithValue) { - const [value, children] = tree as TreeInnerNodeWithValue; - return [value, uncompress(children) as TreeInnerNodeWithoutValue]; - } - - return Object.fromEntries( - Object.entries(tree).map(([segment, child]) => { - const path = parsePath(segment); - if (path.length === 1) { - return [segment, child]; - } - - const firstKey = path[0]; - const innerKeys = path.slice(1, path.length - 1); - const lastKey = path[path.length - 1]; - - const newChild: TreeInnerNodeWithoutValue = {}; - let lastNode = newChild; - for (const key of innerKeys) { - const newNode = {}; - lastNode[key] = newNode; - lastNode = newNode; - } - - lastNode[lastKey] = child; - - return [firstKey, newChild]; - }) - ); -}; diff --git a/src/treeifier.test.ts b/src/treeifier.test.ts deleted file mode 100644 index 84f676d..0000000 --- a/src/treeifier.test.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { TreeEntry, treeify, detreeify } from './treeifier'; - -describe('treeify & detreeify', () => { - interface TestCase { - input: TreeEntry[]; - expectedOutput: object; - } - - const cases: Record = { - simple: { - input: [ - { - path: ['hello', 'world'], - value: 'lol', - }, - { - path: ['hello', 'reader'], - value: 'lel', - }, - ], - expectedOutput: { - hello: { - world: ['lol'], - reader: ['lel'], - }, - }, - }, - rootHasValue: { - input: [ - { - path: [], - value: 'lol', - }, - { - path: ['hello'], - value: 'world', - }, - ], - expectedOutput: [ - 'lol', - { - hello: ['world'], - }, - ], - }, - anotherTestCase: { - input: [ - { path: ['a'], value: 'set' }, - { path: ['a', '1'], value: 'undefined' }, - ], - expectedOutput: { - a: [ - 'set', - { - 1: ['undefined'], - }, - ], - }, - }, - aa: { - input: [{ path: ['a'], value: 'map:number' }], - expectedOutput: { - a: ['map:number'], - }, - }, - }; - - Object.entries(cases).forEach(([name, testcase]) => { - test(name, () => { - const output = treeify(testcase.input); - expect(output).toEqual(testcase.expectedOutput); - expect(output).toBeDefined(); - expect(detreeify(output!)).toEqual(testcase.input); - }); - }); -}); diff --git a/src/treeifier.ts b/src/treeifier.ts deleted file mode 100644 index 6baeda4..0000000 --- a/src/treeifier.ts +++ /dev/null @@ -1,149 +0,0 @@ -import { isArray, isPlainObject } from './is'; - -export type Path = string[]; -export interface TreeEntry { - path: Path; - value: T; -} - -export type TreeLeaf = [T]; - -export type TreeInnerNodeWithValue = [T, TreeInnerNodeWithoutValue]; - -export interface TreeInnerNodeWithoutValue extends Record> {} - -export type Tree = - | TreeLeaf - | TreeInnerNodeWithoutValue - | TreeInnerNodeWithValue; - -export const isTree = ( - v: any, - valueChecker: (v: T) => boolean = () => true -): v is Tree => { - try { - if (isArray(v)) { - if (v.length === 1) { - return valueChecker(v[0]); - } else if (v.length === 2) { - return valueChecker(v[0]) && isTree(v[1], valueChecker); - } else { - return false; - } - } - - if (isPlainObject(v)) { - return Object.values(v).every(v => isTree(v, valueChecker)); - } - - return true; - } catch (error) { - return false; - } -}; - -export const treeify = (entries: TreeEntry[]): Tree => { - let result: Tree = {}; - - for (const entry of entries) { - const { path } = entry; - if (path.length === 0) { - const resultIsEmpty = Object.keys(result).length === 0; - if (resultIsEmpty) { - result = [entry.value]; - } else { - result = [entry.value, result as TreeInnerNodeWithoutValue]; - } - - continue; - } - - const front = path.slice(0, path.length - 1); - const end = path[path.length - 1]; - - let parent: Tree = result; - for (const segment of front) { - const isValueNode = isArray(parent); - const hasChild = isValueNode && parent.length > 1; - if (hasChild) { - const [, realParent] = parent as TreeInnerNodeWithValue; - parent = realParent; - } - - parent = parent as TreeInnerNodeWithoutValue; - - if (!parent.hasOwnProperty(segment)) { - parent[segment] = {}; - } - - const childIsValueNode = isArray(parent[segment]); - - if (childIsValueNode) { - const leaf = parent[segment] as TreeLeaf; - const [leafValue] = leaf; - const newNode: TreeInnerNodeWithValue = [leafValue, {}]; - parent[segment] = newNode; - parent = newNode; - } else { - parent = parent[segment]; - } - } - - if (isArray(parent)) { - const type = parent.length === 1 ? 'leaf' : 'inner_node'; - if (type === 'leaf') { - parent[1] = { - [end]: [entry.value], - }; - } - if (type === 'inner_node') { - parent[1]![end] = [entry.value]; - } - } else { - parent[end] = [entry.value]; - } - } - - return result; -}; - -export const detreeify = (tree: Tree): TreeEntry[] => { - if (isArray(tree)) { - const [value, children = {}] = tree; - - return [ - { - path: [], - value, - }, - ...detreeify(children), - ]; - } - - return Object.entries(tree).flatMap(([segment, child]) => { - return detreeify(child).map(entry => ({ - path: [segment, ...entry.path], - value: entry.value, - })); - }); -}; - -export const treeifyPaths = (paths: Path[]): Tree => { - return treeify( - paths.map(path => { - const front = path.slice(0, path.length - 1); - const lastSegment = path[path.length - 1]; - return { - path: front, - value: '' + lastSegment, - }; - }) - ); -}; - -export const detreeifyPaths = (tree: Tree): Path[] => { - return detreeify(tree).map(treeEntry => { - const { path: front, value: lastSegment } = treeEntry; - return [...front, '' + lastSegment]; - }); -}; From 7e4bd33afef08f71330feea1c5ad4db16c0d7394 Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Sun, 2 Aug 2020 19:38:13 +0200 Subject: [PATCH 14/21] Remove unnused function `isStringifiedPath` --- src/pathstringifier.ts | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/src/pathstringifier.ts b/src/pathstringifier.ts index 1f78d30..63c04c6 100644 --- a/src/pathstringifier.ts +++ b/src/pathstringifier.ts @@ -12,14 +12,3 @@ export const stringifyPath = (path: Path): StringifiedPath => export const parsePath = (string: StringifiedPath): Path => string.split(/(? { - try { - parsePath(string); - return true; - } catch (anyError) { - return false; - } -}; From 2138b864839d2e62b23949ff90f212d9adaf8d33 Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Sun, 2 Aug 2020 20:10:25 +0200 Subject: [PATCH 15/21] Clean up the codebase --- src/annotator.ts | 152 +++++++++++++++++++++---------------------- src/iteratorutils.ts | 13 +--- src/pathtree.ts | 32 +++++++-- 3 files changed, 106 insertions(+), 91 deletions(-) diff --git a/src/annotator.ts b/src/annotator.ts index 839aebd..3e4f461 100644 --- a/src/annotator.ts +++ b/src/annotator.ts @@ -1,5 +1,5 @@ import { getDeep, setDeep } from './accessDeep'; -import { isPrimitive, isNull, isString } from './is'; +import { isPrimitive, isString } from './is'; import * as IteratorUtils from './iteratorutils'; import { Walker } from './plainer'; import { @@ -11,9 +11,9 @@ import { import { PathTree } from './pathtree'; export interface Annotations { - values?: PathTree.MinimizedTree; - referentialEqualities?: PathTree.MinimizedTree< - PathTree.MinimizedTree + values?: PathTree.CollapsedRootTree; + referentialEqualities?: PathTree.CollapsedRootTree< + PathTree.CollapsedRootTree >; } @@ -41,117 +41,117 @@ export function isAnnotations(object: any): object is Annotations { } } -export const makeAnnotator = () => { - let valueAnnotations = PathTree.create(null); +class ValueAnnotationFactory { + private tree = PathTree.create(null); - const objectIdentities = new Map(); - function registerObjectPath(object: any, path: any[]) { - const paths = objectIdentities.get(object) ?? []; - paths.push(path); - objectIdentities.set(object, paths); + add(path: any[], annotation: TypeAnnotation) { + this.tree = PathTree.append(this.tree, path.map(String), annotation); } - const annotator: Walker = ({ path, node }) => { - if (!isPrimitive(node)) { - registerObjectPath(node, path); - } - - const transformed = transformValue(node); - - if (transformed) { - valueAnnotations = PathTree.append( - valueAnnotations, - path.map(String), - transformed.type - ); - - return transformed.value; - } else { - return node; - } - }; + create() { + return PathTree.collapseRoot(this.tree); + } +} - function getAnnotations(): Annotations { - const annotations: Annotations = {}; +class ReferentialEqualityAnnotationFactory { + private readonly objectIdentities = new Map(); - const valueAnnotationsMinimized = PathTree.minimize(valueAnnotations); - if (valueAnnotationsMinimized) { - annotations.values = valueAnnotationsMinimized; - } + register(object: any, path: any[]) { + const paths = this.objectIdentities.get(object) ?? []; + paths.push(path); + this.objectIdentities.set(object, paths); + } - let referentialEqualitiesAnnotations = PathTree.create | null>(null); + create() { + let tree = PathTree.create | null>(null); - IteratorUtils.forEach(objectIdentities.values(), paths => { + IteratorUtils.forEach(this.objectIdentities.values(), paths => { if (paths.length <= 1) { return; } - const [shortestPath, ...identityPaths] = paths + const [shortestPath, ...identicalPaths] = paths .map(path => path.map(String)) .sort((a, b) => a.length - b.length); + let identities = PathTree.create(null); - for (const identityPath of identityPaths) { - identities = PathTree.appendPath(identities, identityPath); + for (const identicalPath of identicalPaths) { + identities = PathTree.appendPath(identities, identicalPath); } - const minimizedIdentities = PathTree.minimize(identities); + const minimizedIdentities = PathTree.collapseRoot(identities); if (!minimizedIdentities) { throw new Error('Illegal State'); } - referentialEqualitiesAnnotations = PathTree.append( - referentialEqualitiesAnnotations, - shortestPath, - minimizedIdentities - ); + tree = PathTree.append(tree, shortestPath, minimizedIdentities); }); - const referentialEqualitiesAnnotationsMinimized = PathTree.minimize( - referentialEqualitiesAnnotations - ); - if (referentialEqualitiesAnnotationsMinimized) { - annotations.referentialEqualities = referentialEqualitiesAnnotationsMinimized; + return PathTree.collapseRoot(tree); + } +} + +class AnnotationFactory { + public readonly valueAnnotations = new ValueAnnotationFactory(); + public readonly objectIdentities = new ReferentialEqualityAnnotationFactory(); + + create(): Annotations { + const annotations: Annotations = {}; + + const values = this.valueAnnotations.create(); + if (values) { + annotations.values = values; + } + + const referentialEqualities = this.objectIdentities.create(); + if (referentialEqualities) { + annotations.referentialEqualities = referentialEqualities; } return annotations; } +} + +export const makeAnnotator = () => { + const annotationFactory = new AnnotationFactory(); + const { valueAnnotations, objectIdentities } = annotationFactory; + + const annotator: Walker = ({ path, node }) => { + if (!isPrimitive(node)) { + objectIdentities.register(node, path); + } - return { getAnnotations, annotator }; + const transformed = transformValue(node); + + if (transformed) { + valueAnnotations.add(path, transformed.type); + return transformed.value; + } else { + return node; + } + }; + + return { getAnnotations: () => annotationFactory.create(), annotator }; }; export const applyAnnotations = (plain: any, annotations: Annotations): any => { if (annotations.values) { - PathTree.traverse(PathTree.unminimize(annotations.values), (type, path) => { - if (isNull(type)) { - if (path.length === 0) { - return; - } - - throw new Error('Illegal State'); + PathTree.traverseWhileIgnoringNullRoot( + PathTree.expandRoot(annotations.values), + (type, path) => { + plain = setDeep(plain, path, v => untransformValue(v, type)); } - - plain = setDeep(plain, path, v => untransformValue(v, type)); - }); + ); } if (annotations.referentialEqualities) { - PathTree.traverse( - PathTree.unminimize(annotations.referentialEqualities), + PathTree.traverseWhileIgnoringNullRoot( + PathTree.expandRoot(annotations.referentialEqualities), (identicalObjects, path) => { - if (isNull(identicalObjects)) { - if (path.length === 0) { - return; - } - - throw new Error('Illegal State'); - } - const object = getDeep(plain, path); PathTree.traversePaths( - PathTree.unminimize(identicalObjects), + PathTree.expandRoot(identicalObjects), identicalObjectPath => { plain = setDeep(plain, identicalObjectPath, () => object); } diff --git a/src/iteratorutils.ts b/src/iteratorutils.ts index 877b33e..b2846f2 100644 --- a/src/iteratorutils.ts +++ b/src/iteratorutils.ts @@ -9,22 +9,15 @@ export const forEach = (iterator: Iterator, func: (v: T) => void) => { } }; -export const flatMap = ( +export const map = ( iterator: Iterator, - map: (v: A, index: number) => B[] + map: (v: A, index: number) => B ): B[] => { const result: B[] = []; forEach(iterator, value => { - result.push(...map(value, result.length)); + result.push(map(value, result.length)); }); return result; }; - -export const map = ( - iterator: Iterator, - map: (v: A, index: number) => B -): B[] => { - return flatMap(iterator, (v, i) => [map(v, i)]); -}; diff --git a/src/pathtree.ts b/src/pathtree.ts index ee09df6..d783c57 100644 --- a/src/pathtree.ts +++ b/src/pathtree.ts @@ -132,19 +132,41 @@ export module PathTree { } } + export function traverseWhileIgnoringNullRoot( + tree: Tree, + walker: (v: T, path: string[]) => void + ): void { + traverse(tree, (v, path) => { + if (isNull(v)) { + if (path.length === 0) { + return; + } + + throw new Error('Illegal State'); + } + + walker(v, path); + }); + } + export function traversePaths( tree: Tree, walker: (path: string[]) => void ) { - traverse(tree, (last, front) => !isNull(last) && walker([...front, last])); + traverseWhileIgnoringNullRoot(tree, (last, front) => + walker([...front, last]) + ); } - export type MinimizedTree = Tree | Record> | undefined; + export type CollapsedRootTree = + | Tree + | Record> + | undefined; export function isMinimizedTree( v: any, valueChecker: (v: T) => boolean - ): v is MinimizedTree { + ): v is CollapsedRootTree { if (isUndefined(v)) { return true; } @@ -159,7 +181,7 @@ export module PathTree { /** * @description Minimizes trees that start with a `null`-root */ - export function minimize(tree: Tree): MinimizedTree { + export function collapseRoot(tree: Tree): CollapsedRootTree { if (isNull(tree[0])) { if (tree.length === 1) { return undefined; @@ -171,7 +193,7 @@ export module PathTree { return tree as Tree; } - export function unminimize(tree: MinimizedTree): Tree { + export function expandRoot(tree: CollapsedRootTree): Tree { if (isArray(tree)) { return tree; } From 8a02b90e9833edf6c2ed220c4293eed165d83601 Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Mon, 3 Aug 2020 10:36:12 +0200 Subject: [PATCH 16/21] Update README to reflect pathtree structure --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index ad64007..911c605 100644 --- a/README.md +++ b/README.md @@ -90,8 +90,8 @@ json = { // note that `normal` is not included here; `meta` only has special cases meta = { - timestamp: 'date', - test: 'regexp', + timestamp: ['date'], + test: ['regexp'], }; */ ``` From 291a5f2dccfc6d8b56de393b8337ddb75c8ca472 Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Mon, 3 Aug 2020 17:31:14 +0200 Subject: [PATCH 17/21] Improve performance by not parsing paths too often --- src/pathtree.ts | 23 ++++++----------------- 1 file changed, 6 insertions(+), 17 deletions(-) diff --git a/src/pathtree.ts b/src/pathtree.ts index 17c847f..12c7757 100644 --- a/src/pathtree.ts +++ b/src/pathtree.ts @@ -1,13 +1,6 @@ import { stringifyPath, parsePath } from './pathstringifier'; import { isUndefined, isNull, isArray, isPlainObject } from './is'; -function isPrefix(to: T[], prefixCandidate: T[]) { - return prefixCandidate.every((value, index) => { - const isPresentInArr = to[index] === value; - return isPresentInArr; - }); -} - export type Tree = InnerNode | Leaf; type Leaf = [T]; type InnerNode = [T, Record>]; @@ -74,27 +67,23 @@ export module PathTree { const [nodeValue, children] = tree; const availablePaths = Object.keys(children); + const stringifiedPath = stringifyPath(path); // due to the constraints mentioned in the functions description, // there may be prefixes of `path` already set, but no extensions of it. // If there's such a prefix, we'll find it. - const prefix = availablePaths - .map(parsePath) - .find(candidate => isPrefix(path, candidate)); + const prefix = availablePaths.find(candidate => + stringifiedPath.startsWith(candidate) + ); if (isUndefined(prefix)) { return [nodeValue, { ...children, [stringifyPath(path)]: [value] }]; } else { - const pathWithoutPrefix = path.slice(prefix.length); - const stringPrefix = stringifyPath(prefix); + const pathWithoutPrefix = path.slice(parsePath(prefix).length); return [ nodeValue, { ...children, - [stringPrefix]: append( - children[stringPrefix], - pathWithoutPrefix, - value - ), + [prefix]: append(children[prefix], pathWithoutPrefix, value), }, ]; } From 5e15bc56acda6efdd8c985662abd8f2297ebf33e Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Mon, 3 Aug 2020 18:00:31 +0200 Subject: [PATCH 18/21] fix benchmark --- benchmark.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/benchmark.js b/benchmark.js index 46535fa..0f9f397 100644 --- a/benchmark.js +++ b/benchmark.js @@ -1,5 +1,5 @@ const Benchmark = require("benchmark") -const SuperJSON = require("./dist/") +const SuperJSON = require("./dist/").default const instances = { "toy example": { From 42e1b9feb731583e7ce4928ed2e1eab8a668ee4a Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Mon, 3 Aug 2020 21:56:07 +0200 Subject: [PATCH 19/21] Extract registry into its own file --- src/class-registry.test.ts | 35 -------------------- src/class-registry.ts | 67 ++------------------------------------ src/double-indexed-kv.ts | 31 ++++++++++++++++++ src/index.test.ts | 4 +-- src/index.ts | 14 ++++++-- src/registry.test.ts | 38 +++++++++++++++++++++ src/registry.ts | 39 ++++++++++++++++++++++ src/transformer.ts | 4 +-- 8 files changed, 125 insertions(+), 107 deletions(-) delete mode 100644 src/class-registry.test.ts create mode 100644 src/double-indexed-kv.ts create mode 100644 src/registry.test.ts create mode 100644 src/registry.ts diff --git a/src/class-registry.test.ts b/src/class-registry.test.ts deleted file mode 100644 index 4e6e410..0000000 --- a/src/class-registry.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -import * as ClassRegistry from './class-registry'; - -test('class registry', () => { - class Car { - honk() { - console.log('honk'); - } - } - ClassRegistry.registerClass(Car); - - expect(ClassRegistry.getClass('Car')).toBe(Car); - expect(ClassRegistry.getIdentifier(Car)).toBe('Car'); - - expect(() => ClassRegistry.registerClass(Car)).not.toThrow(); - - expect(() => ClassRegistry.registerClass(class Car {})).toThrow( - 'Ambiguous class, provide a unique identifier.' - ); - - ClassRegistry.unregisterClass(Car); - - expect(ClassRegistry.getClass('Car')).toBeUndefined(); - - ClassRegistry.registerClass(Car, 'car1'); - - ClassRegistry.registerClass(class Car {}, 'car2'); - - expect(ClassRegistry.getClass('car1')).toBe(Car); - - expect(ClassRegistry.getClass('car2')).not.toBeUndefined(); - - ClassRegistry.clear(); - - expect(ClassRegistry.getClass('car1')).toBeUndefined(); -}); diff --git a/src/class-registry.ts b/src/class-registry.ts index 0ed301c..1f12fd0 100644 --- a/src/class-registry.ts +++ b/src/class-registry.ts @@ -1,67 +1,4 @@ +import { Registry } from './registry'; import { Class } from './types'; -class DoubleIndexedKV { - keyToValue = new Map(); - valueToKey = new Map(); - - set(key: K, value: V) { - this.keyToValue.set(key, value); - this.valueToKey.set(value, key); - } - - deleteByValue(value: V) { - this.valueToKey.delete(value); - this.keyToValue.forEach((otherValue, otherKey) => { - if (value === otherValue) { - this.keyToValue.delete(otherKey); - } - }); - } - - getByKey(key: K): V | undefined { - return this.keyToValue.get(key); - } - - getByValue(value: V): K | undefined { - return this.valueToKey.get(value); - } - - clear() { - this.keyToValue.clear(); - this.valueToKey.clear(); - } -} - -const classRegistry = new DoubleIndexedKV(); - -export function registerClass(clazz: Class, identifier?: string): void { - if (classRegistry.getByValue(clazz)) { - return; - } - - if (!identifier) { - identifier = clazz.name; - } - - if (classRegistry.getByKey(identifier)) { - throw new Error('Ambiguous class, provide a unique identifier.'); - } - - classRegistry.set(identifier, clazz); -} - -export function unregisterClass(clazz: Class): void { - classRegistry.deleteByValue(clazz); -} - -export function clear(): void { - classRegistry.clear(); -} - -export function getIdentifier(clazz: Class) { - return classRegistry.getByValue(clazz); -} - -export function getClass(identifier: string) { - return classRegistry.getByKey(identifier); -} +export const ClassRegistry = new Registry(c => c.name); diff --git a/src/double-indexed-kv.ts b/src/double-indexed-kv.ts new file mode 100644 index 0000000..9e348f0 --- /dev/null +++ b/src/double-indexed-kv.ts @@ -0,0 +1,31 @@ +export class DoubleIndexedKV { + keyToValue = new Map(); + valueToKey = new Map(); + + set(key: K, value: V) { + this.keyToValue.set(key, value); + this.valueToKey.set(value, key); + } + + deleteByValue(value: V) { + this.valueToKey.delete(value); + this.keyToValue.forEach((otherValue, otherKey) => { + if (value === otherValue) { + this.keyToValue.delete(otherKey); + } + }); + } + + getByKey(key: K): V | undefined { + return this.keyToValue.get(key); + } + + getByValue(value: V): K | undefined { + return this.valueToKey.get(value); + } + + clear() { + this.keyToValue.clear(); + this.valueToKey.clear(); + } +} diff --git a/src/index.test.ts b/src/index.test.ts index 6c8feb3..1c86e6d 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -351,7 +351,7 @@ describe('stringify & parse', () => { SuperJSON.registerClass(Train); const { json, meta } = SuperJSON.serialize({ - s7: new Train(100, 'yellow', 'Bombardier'), + s7: new Train(100, 'yellow', 'Bombardier') as any, }); expect(json).toEqual({ @@ -388,7 +388,7 @@ describe('stringify & parse', () => { SuperJSON.registerClass(Currency); const { json, meta } = SuperJSON.serialize({ - price: new Currency(100), + price: new Currency(100) as any, }); expect(json).toEqual({ diff --git a/src/index.ts b/src/index.ts index 27f2377..5300ccd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,8 +1,13 @@ import { applyAnnotations, makeAnnotator } from './annotator'; import { isEmptyObject } from './is'; import { plainer } from './plainer'; -import { SuperJSONResult, SuperJSONValue, isSuperJSONResult } from './types'; -import { clear, registerClass, unregisterClass } from './class-registry'; +import { + SuperJSONResult, + SuperJSONValue, + isSuperJSONResult, + Class, +} from './types'; +import { ClassRegistry } from './class-registry'; const serialize = (object: SuperJSONValue): SuperJSONResult => { const { getAnnotations, annotator } = makeAnnotator(); @@ -36,12 +41,15 @@ const stringify = (object: SuperJSONValue): string => const parse = (string: string): SuperJSONValue => deserialize(JSON.parse(string)); +const registerClass = (v: Class, identifier?: string) => + ClassRegistry.register(v, identifier); +const unregisterClass = (v: Class) => ClassRegistry.unregister(v); + export default { stringify, parse, serialize, deserialize, - clear, registerClass, unregisterClass, }; diff --git a/src/registry.test.ts b/src/registry.test.ts new file mode 100644 index 0000000..3c8de46 --- /dev/null +++ b/src/registry.test.ts @@ -0,0 +1,38 @@ +import { Registry } from './registry'; +import { Class } from './types'; + +test('class registry', () => { + const registry = new Registry(c => c.name); + + class Car { + honk() { + console.log('honk'); + } + } + registry.register(Car); + + expect(registry.getValue('Car')).toBe(Car); + expect(registry.getIdentifier(Car)).toBe('Car'); + + expect(() => registry.register(Car)).not.toThrow(); + + expect(() => registry.register(class Car {})).toThrow( + 'Ambiguous class, provide a unique identifier.' + ); + + registry.unregister(Car); + + expect(registry.getValue('Car')).toBeUndefined(); + + registry.register(Car, 'car1'); + + registry.register(class Car {}, 'car2'); + + expect(registry.getValue('car1')).toBe(Car); + + expect(registry.getValue('car2')).not.toBeUndefined(); + + registry.clear(); + + expect(registry.getValue('car1')).toBeUndefined(); +}); diff --git a/src/registry.ts b/src/registry.ts new file mode 100644 index 0000000..99e476f --- /dev/null +++ b/src/registry.ts @@ -0,0 +1,39 @@ +import { DoubleIndexedKV } from './double-indexed-kv'; + +export class Registry { + private kv = new DoubleIndexedKV(); + + constructor(private readonly generateIdentifier: (v: T) => string) {} + + register(value: T, identifier?: string): void { + if (this.kv.getByValue(value)) { + return; + } + + if (!identifier) { + identifier = this.generateIdentifier(value); + } + + if (this.kv.getByKey(identifier)) { + throw new Error('Ambiguous class, provide a unique identifier.'); + } + + this.kv.set(identifier, value); + } + + unregister(v: T): void { + this.kv.deleteByValue(v); + } + + clear(): void { + this.kv.clear(); + } + + getIdentifier(value: T) { + return this.kv.getByValue(value); + } + + getValue(identifier: string) { + return this.kv.getByKey(identifier); + } +} diff --git a/src/transformer.ts b/src/transformer.ts index d278020..9f676db 100644 --- a/src/transformer.ts +++ b/src/transformer.ts @@ -11,7 +11,7 @@ import { isString, isUndefined, } from './is'; -import * as ClassRegistry from './class-registry'; +import { ClassRegistry } from './class-registry'; export type PrimitiveTypeAnnotation = | 'NaN' @@ -166,7 +166,7 @@ export const untransformValue = (json: any, type: TypeAnnotation) => { } case 'class': { - const clazz = ClassRegistry.getClass(type[1]); + const clazz = ClassRegistry.getValue(type[1]); if (!clazz) { throw new Error('Trying to deserialize unknown class'); From c18089bb54e817c22ec9e09bb57583beefb0d3d8 Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Mon, 3 Aug 2020 21:58:29 +0200 Subject: [PATCH 20/21] Create SymbolRegistry --- src/index.ts | 7 +++++++ src/symbol-registry.ts | 3 +++ 2 files changed, 10 insertions(+) create mode 100644 src/symbol-registry.ts diff --git a/src/index.ts b/src/index.ts index 5300ccd..35c1cc9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,6 +8,7 @@ import { Class, } from './types'; import { ClassRegistry } from './class-registry'; +import { SymbolRegistry } from './symbol-registry'; const serialize = (object: SuperJSONValue): SuperJSONResult => { const { getAnnotations, annotator } = makeAnnotator(); @@ -45,6 +46,10 @@ const registerClass = (v: Class, identifier?: string) => ClassRegistry.register(v, identifier); const unregisterClass = (v: Class) => ClassRegistry.unregister(v); +const registerSymbol = (v: Symbol, identifier?: string) => + SymbolRegistry.register(v, identifier); +const unregisterSymbol = (v: Symbol) => SymbolRegistry.unregister(v); + export default { stringify, parse, @@ -52,4 +57,6 @@ export default { deserialize, registerClass, unregisterClass, + registerSymbol, + unregisterSymbol, }; diff --git a/src/symbol-registry.ts b/src/symbol-registry.ts new file mode 100644 index 0000000..058683d --- /dev/null +++ b/src/symbol-registry.ts @@ -0,0 +1,3 @@ +import { Registry } from './registry'; + +export const SymbolRegistry = new Registry(s => s.description ?? ''); From 167666dd1f4de1c7e23c6b1cedff111bf3db0119 Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Mon, 3 Aug 2020 22:06:52 +0200 Subject: [PATCH 21/21] Add support for symbols --- src/index.test.ts | 24 ++++++++++++++++++++++++ src/transformer.ts | 28 +++++++++++++++++++++++++++- src/types.ts | 1 + 3 files changed, 52 insertions(+), 1 deletion(-) diff --git a/src/index.test.ts b/src/index.test.ts index 1c86e6d..039d9d6 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -274,6 +274,30 @@ describe('stringify & parse', () => { referentialEqualitiesRoot: ['children.0.parents.0'], }, }, + + 'works for symbols': { + input: () => { + const parent = Symbol('Parent'); + const child = Symbol('Child'); + SuperJSON.registerSymbol(parent, '1'); + SuperJSON.registerSymbol(child, '2'); + + const a = { role: parent }; + const b = { role: child }; + + return { a, b }; + }, + output: { + a: { role: 'Parent' }, + b: { role: 'Child' }, + }, + outputAnnotations: { + values: { + 'a.role': ['symbol', '1'], + 'b.role': ['symbol', '2'], + }, + }, + }, }; function deepFreeze(object: any, alreadySeenObjects = new Set()) { diff --git a/src/transformer.ts b/src/transformer.ts index 9f676db..1ef512a 100644 --- a/src/transformer.ts +++ b/src/transformer.ts @@ -10,8 +10,10 @@ import { isSet, isString, isUndefined, + isSymbol, } from './is'; import { ClassRegistry } from './class-registry'; +import { SymbolRegistry } from './symbol-registry'; export type PrimitiveTypeAnnotation = | 'NaN' @@ -25,8 +27,13 @@ type LeafTypeAnnotation = PrimitiveTypeAnnotation | 'regexp' | 'Date'; type MapTypeAnnotation = ['map', 'number' | 'string' | 'bigint' | 'boolean']; type ClassTypeAnnotation = ['class', string]; +type SymbolTypeAnnotation = ['symbol', string]; -type ContainerTypeAnnotation = MapTypeAnnotation | ClassTypeAnnotation | 'set'; +type ContainerTypeAnnotation = + | MapTypeAnnotation + | ClassTypeAnnotation + | SymbolTypeAnnotation + | 'set'; export type TypeAnnotation = LeafTypeAnnotation | ContainerTypeAnnotation; @@ -53,6 +60,7 @@ export const isTypeAnnotation = (value: any): value is TypeAnnotation => { switch (value[0]) { case 'map': return ['number', 'string', 'bigint', 'boolean'].includes(value[1]); + case 'symbol': case 'class': return typeof value[1] === 'string'; } @@ -69,6 +77,14 @@ export const transformValue = ( value: undefined, type: 'undefined', }; + } else if (isSymbol(value)) { + const identifier = SymbolRegistry.getIdentifier(value); + if (identifier) { + return { + value: value.description, + type: ['symbol', identifier], + }; + } } else if (isBigint(value)) { return { value: value.toString(), @@ -174,6 +190,16 @@ export const untransformValue = (json: any, type: TypeAnnotation) => { return Object.assign(Object.create(clazz.prototype), json); } + + case 'symbol': { + const symbol = SymbolRegistry.getValue(type[1]); + + if (!symbol) { + throw new Error('Trying to deserialize unknown symbol'); + } + + return symbol; + } } } diff --git a/src/types.ts b/src/types.ts index 482cdf0..c60b707 100644 --- a/src/types.ts +++ b/src/types.ts @@ -23,6 +23,7 @@ type MapWithUniformKeys = export type SerializableJSONValue = | Set | MapWithUniformKeys + | Symbol | undefined | bigint | Date