Skip to content

Commit

Permalink
Merge pull request #14 from Nikaple/feat/circular-deps
Browse files Browse the repository at this point in the history
  • Loading branch information
Nikaple authored Aug 12, 2023
2 parents a812570 + 8275767 commit c25e86b
Show file tree
Hide file tree
Showing 11 changed files with 4,710 additions and 3,032 deletions.
1 change: 1 addition & 0 deletions .npmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
strict-peer-dependencies=false
7,540 changes: 4,542 additions & 2,998 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

6 changes: 5 additions & 1 deletion src/deep-get.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ describe('deepGet', () => {
};
});

it('should return root for empty string', () => {
expect(deepGet(obj, '')).toBe(obj);
});

it('should return value for single-level keys', () => {
expect(deepGet({ a: 1 }, 'a')).toBe(1);
});
Expand All @@ -41,6 +45,6 @@ describe('deepGet', () => {
});

it('should return undefined if obj is not an object', () => {
expect(deepGet('test', 'key')).toBe(undefined);
expect(deepGet('test' as any, 'key')).toBe(undefined);
});
});
14 changes: 10 additions & 4 deletions src/deep-get.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,20 @@
import { parsePath } from './internal';

/**
* deeply get value by key
* Deeply get value by key.
*
* @example
*
* deepGet({ a: { b: 1 } }, "a.b") // 1
* deepGet({ a: { b: [1] } }, "a.b.0") // 1
* deepGet({ a: { '?': [1] } }, 'a["?"][0]') // 1
*/
export const deepGet = (obj: any, path: string) => {
export const deepGet = (obj: Record<string, unknown>, path: string) => {
const keys = parsePath(path);

let result: unknown = undefined;
let result: any = obj;
keys.forEach(part => {
result = (result || obj)[part];
result = result[part];
});
return result;
};
2 changes: 1 addition & 1 deletion src/deep-set.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,6 @@ describe('deepSet', () => {
});

it('should not set value if obj is not an object', () => {
expect(deepSet('test', 'key', 'value')).toBe('test');
expect(deepSet('test' as any, 'key', 'value')).toBe('test');
});
});
30 changes: 25 additions & 5 deletions src/deep-set.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,43 @@
import { parsePath } from './internal';
import {
extractCircularKey,
extractCircularValue,
parsePath,
} from './internal';
import { UniFlattenOptions } from './type';

/**
* deeply set value by key
* Deeply set value by key. This method mutates the original object.
*
* @example
*
* deepSet({}, "a.b", 1) // { a: { b: 1 } }
* deepGet({}, "a.b.0", 1) // { a: { b: [1] } }
* deepGet({}, 'a["?"][0]', 1) // { a: { '?': [1] } }
*/
export const deepSet = (obj: any, path: string, value: any) => {
export const deepSet = <T extends Record<string, unknown>>(
obj: T,
path: string,
value: unknown,
options?: UniFlattenOptions,
) => {
if (typeof obj !== 'object' && obj) return obj;

const keys = parsePath(path);
const lastIndex = keys.length - 1;

let current = obj;
let current: any = obj;

keys.forEach((key, i, arr) => {
const isNextArray = typeof arr[i + 1] === 'number';
if (typeof current[key] !== 'object') {
current[key] = isNextArray ? [] : {};
}
if (i === lastIndex) {
current[key] = value;
const circularKey = extractCircularKey(value, options?.circularReference);
current[key] =
circularKey === undefined
? value
: extractCircularValue(obj, circularKey);
}

current = current[key];
Expand Down
33 changes: 32 additions & 1 deletion src/flatten.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,38 @@ describe('flattenObject', () => {
);
});

it('should handle circular dependency, circularReference = string', () => {
const obj1 = {} as Record<string, any>;
obj1.a = obj1;
const target1 = { a: '[Circular->""]' };
expect(flatten(obj1)).toEqual(target1);
expect(unflatten(target1)).toEqual(obj1);
});
it('should handle circular dependency, circularReference = symbol', () => {
const obj2 = { a: { b: { e: '1' } }, c: {} } as Record<string, any>;
obj2.c.d = obj2.a.b;
const flattened2 = flatten(obj2, { circularReference: 'symbol' });
const target2 = {
'a.b.e': '1',
'c.d': `Symbol([Circular->"a.b"])`,
};
expect({
...flattened2,
'c.d': String(flattened2['c.d']),
}).toEqual(target2);
expect(unflatten(target2, { circularReference: 'symbol' })).toEqual(obj2);
});

it('should handle circular dependency, circularReference = null', () => {
const obj3 = {} as Record<string, any>;
obj3.a = obj3;
expect(flatten(obj3, { circularReference: 'null' })).toEqual({ a: null });
expect(
unflatten({ a: null, b: '1' }, { circularReference: 'null' }),
).toEqual({ a: null, b: '1' });
});

it('should not throw on illegal input', () => {
expect(flatten('')).toEqual({});
expect(flatten('' as any)).toEqual({});
});
});
69 changes: 55 additions & 14 deletions src/flatten.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,21 @@
import { deepSet } from './deep-set';
import { extractCircularKey, formatCircularKey } from './internal';
import { UniFlattenOptions } from './type';

/**
* flatten an object to single depth
* Flatten an object to single depth.
*
* @example
*
* flatten({ a: { b: 1 } }) // { "a.b": 1 }
* flatten({ a: { b: [1] } }) // { "a.b[0]": 1 }
* flatten({ a: { '?': [1] } }) // { 'a["?"][0]': 1 }
*/
export const flatten = (obj: any) => {
const result: any = {};
export const flatten = <T>(
obj: Record<string, unknown>,
options?: UniFlattenOptions,
): Record<string, T> => {
const seen = new Map();
const getKey = (key: string, prefix: string, isNumber: boolean) => {
let k;
if (
Expand All @@ -21,37 +32,67 @@ export const flatten = (obj: any) => {
}
return prefix ? `${prefix}${/^\[/.test(k) ? '' : '.'}${k}` : k;
};
const helper = (obj: any, prefix: string) => {
const helper = (obj: any, prefix: string, result: any = {}) => {
if (typeof obj !== 'object') {
return;
return result;
}
const previous = seen.get(obj);
if (previous !== undefined) {
result[prefix] = formatCircularKey(previous, options?.circularReference);
return result;
}
seen.set(obj, prefix);
if (Array.isArray(obj)) {
obj.forEach((item, i) => {
const key = getKey(String(i), prefix, true);
if (typeof item === 'object') {
return helper(item, key);
const res = helper(item, key, result);
return res;
}
result[key] = item;
});
return;
return result;
}

Object.entries(obj).forEach(([k, v]) => {
Object.entries(obj).forEach(([k, item]) => {
const key = getKey(k, prefix, false);
if (typeof v === 'object') {
return helper(v, key);
if (typeof item === 'object') {
const res = helper(item, key, result);
return res;
}
result[key] = v;
result[key] = item;
});
return result;
};
helper(obj, '');

const result: any = {};
helper(obj, '', result);
return result;
};

export const unflatten = (obj: any) => {
/**
* The reverse action of flatten. Transform a flattened object to original un-flattened object.
*
* @example
*
* flatten({ "a.b": 1 }) // { a: { b: 1 } }
* flatten({ "a.b[0]": 1 }) // { a: { b: [1] } }
* flatten({ 'a["?"][0]': 1 }) // { a: { '?': [1] } }
*/
export const unflatten = (obj: any, options?: UniFlattenOptions) => {
const result = {};
const circularEntries: [string, any][] = [];
const normalEntries: [string, any][] = [];
Object.entries(obj).forEach(([key, value]) => {
deepSet(result, key, value);
const circularKey = extractCircularKey(value, options?.circularReference);
if (circularKey !== undefined) {
circularEntries.push([key, value]);
} else {
normalEntries.push([key, value]);
}
});
normalEntries.concat(circularEntries).forEach(([key, value]) => {
deepSet(result, key, value, options);
});
return result;
};
8 changes: 0 additions & 8 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,3 @@
/**
* 对象的深层 get / set 方法,规则如下
*
* 1. 表达普通对象 "a.b.z"
* 2. 表达数组 "b[0].z" 或 "c.0.z"
* 3. 表达数字 key "c["0"].z"
* 4. 表达含 "." 的 key "d["b.c"].z"
*/
export * from './flatten';
export * from './deep-get';
export * from './deep-set';
36 changes: 36 additions & 0 deletions src/internal.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import { deepGet } from './deep-get';
import { UniFlattenOptions } from './type';

export const parsePath = (str: string): (string | number)[] => {
const tokens = [];
let i = 0;
Expand Down Expand Up @@ -35,3 +38,36 @@ export const parsePath = (str: string): (string | number)[] => {
}
return tokens;
};

export function formatCircularKey(
id: string,
option: UniFlattenOptions['circularReference'] = 'string',
) {
if (option === 'string') {
return `[Circular->${JSON.stringify(id)}]`;
}
if (option === 'symbol') {
return Symbol(`[Circular->${JSON.stringify(id)}]`);
}
return null;
}

export function extractCircularKey(
value: unknown,
option: UniFlattenOptions['circularReference'] = 'string',
): string | undefined {
if (typeof value !== 'string' && typeof value !== 'symbol') return undefined;
if (option === 'string') {
const match = /^\[Circular->(.+)\]$/.exec(String(value)) || [];
return match[1] ? JSON.parse(match[1]) : undefined;
}
if (option === 'symbol') {
const match = /^Symbol\(\[Circular->(.+)\]\)$/.exec(String(value)) || [];
return match[1] ? JSON.parse(match[1]) : undefined;
}
return undefined;
}

export function extractCircularValue(value: unknown, circularKey: string) {
return deepGet(value as any, circularKey);
}
3 changes: 3 additions & 0 deletions src/type.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export interface UniFlattenOptions {
circularReference: 'string' | 'symbol' | 'null';
}

0 comments on commit c25e86b

Please sign in to comment.