From c1e28c1f5da196ad163c83de741d781ef28ea21b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcos=20Gon=C3=A7alves?= Date: Fri, 12 Feb 2021 16:15:52 +0100 Subject: [PATCH] feat(typescript): improve types and methods definitions --- README.md | 10 ++--- src/parse-key.ts | 21 ++++++----- src/parse.ts | 26 ++++++------- src/pick.test.ts | 17 ++++++--- src/pick.ts | 39 +++++++++++--------- src/utils/create-path-breadcrumb.ts | 2 +- src/utils/get-array-index.ts | 10 ++--- src/utils/get-key.test.ts | 1 + src/utils/get-key.ts | 4 +- src/utils/merge.test.ts | 57 ++++++++++++++++------------- src/utils/merge.ts | 30 +++++++++------ src/utils/replace.test.ts | 4 +- src/utils/replace.ts | 11 +++--- src/utils/shallow-copy.test.ts | 45 +++++++++++++++++++++++ src/utils/shallow-copy.ts | 25 +++++++++++++ src/utils/to-array.ts | 6 ++- src/utils/type-of.test.ts | 20 ++++++++++ src/utils/type-of.ts | 16 ++++++++ 18 files changed, 240 insertions(+), 104 deletions(-) create mode 100644 src/utils/shallow-copy.test.ts create mode 100644 src/utils/shallow-copy.ts create mode 100644 src/utils/type-of.test.ts create mode 100644 src/utils/type-of.ts diff --git a/README.md b/README.md index 01b5b7f..9bed12e 100644 --- a/README.md +++ b/README.md @@ -53,10 +53,10 @@ const source = { }; dot.pick(source, 'person.name'); -//outputs { firstName: 'John', lastName: 'Doe' } +//output { firstName: 'John', lastName: 'Doe' } dot.pick(source, 'person.address[0].street'); -//outputs "Infinite Loop" +//output "Infinite Loop" ``` ### Parsing an object @@ -76,7 +76,7 @@ const source = { dot.parse(source); -/* outputs +/* output { person: { name: { @@ -116,7 +116,7 @@ const source = { dot.parse(source); -/* outputs +/* output [ { "postalCode": 95014, @@ -147,7 +147,7 @@ const value = 'John Doe'; dot.parseKey(path, value); -/* outputs +/* output { person: { name: 'John Doe', diff --git a/src/parse-key.ts b/src/parse-key.ts index 97df0b9..97e603c 100644 --- a/src/parse-key.ts +++ b/src/parse-key.ts @@ -1,24 +1,25 @@ import getArrayIndex from './utils/get-array-index'; import getKey from './utils/get-key'; +import shallowCopy from './utils/shallow-copy'; /** - * Parse object key from dot notation + * Parse an object key from dot notation * @example * parseKey('person.name', 'John Doe'); - * // outputs { person: { name: 'John Doe' } } + * // output { person: { name: 'John Doe' } } * parseKey('person.alias[]', 'John Doe'); - * // outputs { person: { alias: ['John Doe] } } - * @param {string} path - Dot notation object path - * @param {any} value - Dot notation path value + * // output { person: { alias: ['John Doe'] } } + * @param {string} path - Dot notation path + * @param {unknown} value * @returns {object} */ -const parseKey = (path: string, value: unknown): T extends [] ? T[] : T => { - const [current, remaining] = getKey(path); - const match = getArrayIndex(current); +const parseKey = (path: string, value: unknown): T extends [] ? Array : T => { + const [key, remainingPath] = getKey(path); + const hasArrayNotation = getArrayIndex(key); - const mount = (): T => (remaining ? parseKey(remaining, value) : value) as T; + const compiledValue = (remainingPath ? parseKey(remainingPath, value) : shallowCopy(value)) as T; - return (match ? [mount()] : { [current]: mount() }) as T extends [] ? T[] : T; + return (hasArrayNotation ? [compiledValue] : { [key]: compiledValue }) as T extends [] ? Array : T; }; export default parseKey; diff --git a/src/parse.ts b/src/parse.ts index 745a8b2..24e1ca5 100644 --- a/src/parse.ts +++ b/src/parse.ts @@ -4,8 +4,9 @@ import getArrayIndex from './utils/get-array-index'; import is from './utils/is'; import merge from './utils/merge'; import createPathBreadcrumb from './utils/create-path-breadcrumb'; +import shallowCopy from './utils/shallow-copy'; -const compile = ( +const compileEntry = ( source: Record | unknown[], instructions: string[], value: unknown, @@ -29,7 +30,7 @@ const compile = ( } else { const hasChild = instructions.length > 1; - const result = hasChild ? compile((data as unknown[])[+idx] as unknown[], instructions.slice(1), value) : value; + const result = hasChild ? compileEntry(data[+idx] as unknown[], instructions.slice(1), value) : value; if (is.object(result)) { data[+idx] = { ...result }; @@ -46,29 +47,29 @@ const compile = ( /** * Parse object from dot notation * @template T - * @param {object} source - Dot notation object - * @returns {object} - * @param source + * @param {Object.} source * @return {T|T[]} */ const parse = (source: Record): T extends [] ? T[] : T => { - const paths = Object.keys(source); + const content = shallowCopy(source); - let result: unknown = getArrayIndex(createPathBreadcrumb(paths[0])[0]) ? [] : {}; + const paths = Object.keys(content); + + let result = getArrayIndex(createPathBreadcrumb(paths[0])[0]) ? [] : {}; for (let i = 0; i < paths.length; i += 1) { const path = paths[i]; const hasArrayNotation = getArrayIndex(path); - const value = source[path]; + const value = shallowCopy(content[path]); let parsedValue = parseKey(path, value); if (hasArrayNotation) { const commonPath = path.substr(0, hasArrayNotation.index); const workingPath = createPathBreadcrumb(path.replace(commonPath, '')); - const workingNode: unknown[] = commonPath ? pick(result, commonPath) || [] : (result as unknown[]); + const workingNode = commonPath ? pick(result, commonPath) || [] : result; - parsedValue = compile(workingNode, workingPath, value); + parsedValue = compileEntry(workingNode, workingPath, value); if (commonPath) { parsedValue = parseKey(commonPath, parsedValue); @@ -79,10 +80,7 @@ const parse = (source: Record): T extends [] ? T[] : T => { result = parsedValue; } - result = merge>( - result as Partial>, - parsedValue as Partial>, - ); + result = merge(result, parsedValue); } return result as T extends [] ? T[] : T; diff --git a/src/pick.test.ts b/src/pick.test.ts index 55a1f2d..1d09170 100644 --- a/src/pick.test.ts +++ b/src/pick.test.ts @@ -87,7 +87,14 @@ describe('pick()', () => { expect(pick(source, pathHasATypo)).toBe(undefined); }); - const expects: Record = { + it('should return undefined when source is not an object nor array', () => { + const content = 'hello world'; + const path = 'greeting'; + + expect(pick(content, path)).toBeUndefined(); + }); + + const expects = { array: source.array, 'array.[0]': source.array[0], 'array.[0].[0]': source.array[0][0], @@ -107,7 +114,7 @@ describe('pick()', () => { }); describe('object path', () => { - const expects: Record = { + const expects = { person: source.person, 'person.name': source.person.name, 'person.name.firstName': source.person.name.firstName, @@ -126,7 +133,7 @@ describe('pick()', () => { }); describe('array path', () => { - const expects: Record = { + const expects = { array: source.array, 'array[0]': source.array[0], 'array[0][0]': source.array[0][0], @@ -144,7 +151,7 @@ describe('pick()', () => { }); }); - const expectsArray: Record = { + const expectsArray = { '[0]': array[0], '[0][0]': array[0][0], '[0][0][0]': array[0][0][0], @@ -163,7 +170,7 @@ describe('pick()', () => { }); describe('nested path', () => { - const paths: Record = { + const paths = { 'person.address[0]': source.person.address[0], 'person.address[0].postalCode': source.person.address[0].postalCode, 'person.address[1]': source.person.address[1], diff --git a/src/pick.ts b/src/pick.ts index ae792eb..121de21 100644 --- a/src/pick.ts +++ b/src/pick.ts @@ -1,48 +1,51 @@ import getArrayIndex from './utils/get-array-index'; import getKey from './utils/get-key'; import is from './utils/is'; +import shallowCopy from './utils/shallow-copy'; /** - * Pick - * @template T, S - * @description Reads value from object using dot notation path as key + * Pick value at a given dot notation path + * @template T + * @param {Object. | Object. | unknown[]>(source: S, path: string): T => { +const pick = (source: Record | Array>, path: string): T => { if (is.nullOrUndefined(path) || !path.trim()) { throw new SyntaxError(`A dot notation path was expected, but instead got "${path}"`); } + const content = shallowCopy(source) as Record; + // eslint-disable-next-line prefer-const - let [current, remaining] = getKey(path) as [string | number, string | undefined]; + let [key, remainingPath]: [string | number, string | undefined] = getKey(path); - const match = getArrayIndex(current.toString()); + const hasArrayNotation = getArrayIndex(key.toString()); - if (match) { - const { 1: index } = match; + if (hasArrayNotation) { + const { 1: idx } = hasArrayNotation; - if (!index) { + if (!idx) { throw new SyntaxError(`An array index was expected but nothing was found at "${path}"`); } - if (Number.isNaN(+index)) { - throw new TypeError(`Array index must a positive integer "${index}"`); + if (Number.isNaN(+idx)) { + throw new TypeError(`Array index must a positive integer "${idx}"`); } - if (+index < 0) { - throw new RangeError(`Array index must be equal or greater than 0, but instead got "${index}"`); + if (+idx < 0) { + throw new RangeError(`Array index must be equal or greater than 0, but instead got "${idx}"`); } - current = +index; + // replace key with array index value + key = +idx; } - if (!remaining || !(source as Record)[current]) { - return (source as Record)[current] as T; + if (!remainingPath || is.nullOrUndefined(content[key])) { + return content[key] as T; } - return pick((source as Record)[current] as S, remaining); + return pick(content[key] as Record, remainingPath); }; export default pick; diff --git a/src/utils/create-path-breadcrumb.ts b/src/utils/create-path-breadcrumb.ts index 4fcd9ae..89f8544 100644 --- a/src/utils/create-path-breadcrumb.ts +++ b/src/utils/create-path-breadcrumb.ts @@ -9,7 +9,7 @@ import getKey from './get-key'; const createPathBreadcrumb = (path: string): string[] => { return ( path - .split(getKey.regex) + .split(getKey.regexp) .filter(Boolean) // add default index for empty array notation .map((p) => (p === '[]' ? '[0]' : p)) diff --git a/src/utils/get-array-index.ts b/src/utils/get-array-index.ts index 3852199..7cfe6d5 100644 --- a/src/utils/get-array-index.ts +++ b/src/utils/get-array-index.ts @@ -1,15 +1,15 @@ /** * Ger Array Index - * @description get array index from given string + * @description get array index from dot notation path string * @param {string} str - * @return {string[]|null} + * @return {RegExpExecArray|null} */ const getArrayIndex = (str: string): RegExpExecArray | null => { - return getArrayIndex.regexNaNIndex.exec(str) || getArrayIndex.regexIntegerIndex.exec(str); + return getArrayIndex.regexpNaNIndex.exec(str) || getArrayIndex.regexpIntIndex.exec(str); }; -getArrayIndex.regexIntegerIndex = /\[([-]*\d*)\]/g; +getArrayIndex.regexpIntIndex = /\[(-*\d*)]/g; -getArrayIndex.regexNaNIndex = /\[([^\]]*)\]/; +getArrayIndex.regexpNaNIndex = /\[([^\]]*)]/; export default getArrayIndex; diff --git a/src/utils/get-key.test.ts b/src/utils/get-key.test.ts index 69f9b89..5c805d0 100644 --- a/src/utils/get-key.test.ts +++ b/src/utils/get-key.test.ts @@ -55,6 +55,7 @@ describe('utils/getKey()', () => { Object.keys(expectations).forEach((path) => { it(`should parse "${path} accordingly`, () => { + // console.table(dotNotationPath(path)); expect(getKey(path)).toStrictEqual(expectations[path]); }); }); diff --git a/src/utils/get-key.ts b/src/utils/get-key.ts index ce6bffa..63c44c1 100644 --- a/src/utils/get-key.ts +++ b/src/utils/get-key.ts @@ -5,11 +5,11 @@ * @returns {[string, string | undefined]} - returns key, remaining dot notation path and isArray, */ const getKey = (value: string): [string, string | undefined] => { - const [current, ...remaining] = value.split(getKey.regex).filter(Boolean); + const [current, ...remaining] = value.split(getKey.regexp).filter(Boolean); return [current, remaining.length ? remaining.join('.') : undefined]; }; -getKey.regex = /\.|(\[[^\]]*\])|(\[[-]*\d*\])/; +getKey.regexp = /\.|(\[[^\]]*])|(\[-*\d*])/; export default getKey; diff --git a/src/utils/merge.test.ts b/src/utils/merge.test.ts index c458300..a57687e 100644 --- a/src/utils/merge.test.ts +++ b/src/utils/merge.test.ts @@ -7,11 +7,11 @@ describe('utils/merge()', () => { describe('exceptions', () => { it('should merge uneven objects', () => { - const leftSide = { + const lhs = { hobbies: ['barbecue'], }; - const rightSide = { + const rhs = { hobbies: ['movie', 'coding'], }; @@ -19,78 +19,85 @@ describe('utils/merge()', () => { hobbies: ['movie', 'coding'], }; - expect(merge>(leftSide, rightSide)).toStrictEqual(expects); + expect(merge(lhs, rhs)).toStrictEqual(expects); }); }); describe('object', () => { it('should return merge objects accordingly', () => { - const leftSide = { name: 'John' }; - const rightSide = { lastName: 'Doe' }; + const lhs = { name: 'John' }; + const rhs = { lastName: 'Doe' }; - const expects = { ...leftSide, ...rightSide }; + const expects = { ...lhs, ...rhs }; - expect(merge>(leftSide, rightSide)).toStrictEqual(expects); + expect(merge(lhs, rhs)).toStrictEqual(expects); }); it('should merge deep object', () => { - const leftSide = { earth: { human: { person: { name: 'John' } } } }; - const rightSide = { earth: { human: { person: { lastName: 'Doe' } } } }; + const lhs = { earth: { human: { person: { name: 'John' } } } }; + const rhs = { earth: { human: { person: { lastName: 'Doe' } } } }; const expects = { earth: { human: { person: { name: 'John', lastName: 'Doe' } } } }; - expect(merge>(leftSide, rightSide)).toStrictEqual(expects); + expect(merge(lhs, rhs)).toStrictEqual(expects); }); }); describe('array', () => { describe('pure array data type', () => { it('should always replace from the right side value', () => { - const leftSide = [1, 2, 3]; - const rightSide = [4, 5, 6]; + const lhs = [1, 2, 3]; + const rhs = [4, 5, 6]; - expect(merge(leftSide, rightSide)).toStrictEqual(rightSide); + expect(merge(lhs, rhs)).toStrictEqual(rhs); }); }); + describe('nested data type', () => { it('should always replace from the right side value', () => { - const leftSide = { + const lhs = { collection: ['books'], hobbies: ['barbecue'], }; - const rightSide = { + const rhs = { preferences: ['email'], - hobbies: ['movie', 'coding'], + hobbies: { favourite: 'coding' }, }; const expects = { collection: ['books'], preferences: ['email'], - hobbies: ['movie', 'coding'], + hobbies: { favourite: 'coding' }, }; - expect(merge>(leftSide, rightSide)).toStrictEqual(expects); + expect(merge(lhs, rhs)).toStrictEqual(expects); }); }); }); describe('nested', () => { it('should merge simple nested object array', () => { - const leftSide = { name: 'John', hobbies: ['barbecue'] }; - const rightSide = { lastName: 'Doe', hobbies: ['movie'] }; + interface NestedSource { + name: string; + lastName: string; + hobbies: string[]; + } + + const lhs: Partial = { name: 'John', hobbies: ['barbecue'] }; + const rhs: Partial = { lastName: 'Doe', hobbies: ['movie'] }; - const expects = { name: 'John', lastName: 'Doe', hobbies: ['movie'] }; + const expects: NestedSource = { name: 'John', lastName: 'Doe', hobbies: ['movie'] }; - expect(merge(leftSide, rightSide)).toStrictEqual(expects); + expect(merge(lhs, rhs)).toStrictEqual(expects); }); it('should merge complex nested object array', () => { - const leftSide = { + const lhs = { person: { name: 'John', random: ['bacon', 1, { language: 'javascript' }, true] }, }; - const rightSide = { + const rhs = { person: { lastName: 'Doe', random: ['cheeseburger', 2, { ide: 'webstorm' }, false] }, }; @@ -102,7 +109,7 @@ describe('utils/merge()', () => { }, }; - expect(merge>(leftSide, rightSide)).toStrictEqual(expects); + expect(merge(lhs, rhs)).toStrictEqual(expects); }); }); }); diff --git a/src/utils/merge.ts b/src/utils/merge.ts index f242835..1d8a9a8 100644 --- a/src/utils/merge.ts +++ b/src/utils/merge.ts @@ -1,29 +1,37 @@ import is from './is'; +import shallowCopy from './shallow-copy'; -const merge = (x: Partial, y: Partial): X | (X & Y) => { +/** + * Merge property from two objects + * @param x + * @param y + */ +const merge = (x: Partial, y: Partial): X & Y => { if (!(is.object(x) || is.object(y))) { return y; } - const keys = Object.keys(x); + const lhs = shallowCopy>(x); + const rhs = shallowCopy>(y); + const keys = Object.keys(lhs); - const result: Record = { ...y }; + const content = shallowCopy>(y); for (let i = 0; i < keys.length; i += 1) { const key = keys[i]; - const xValue = (x as Record)[key]; - const yValue = (y as Record)[key]; + const lhsValue = shallowCopy(lhs[key]); + const rhsValue = shallowCopy(rhs[key]); - if (Array.isArray(xValue) && Array.isArray(yValue)) { - result[key] = yValue; - } else if (is.object(xValue) && is.object(yValue)) { - result[key] = merge({ ...(xValue as Partial) }, { ...(yValue as Partial) }); + if (is.array(lhsValue) && is.array(rhsValue)) { + content[key] = rhsValue; + } else if (is.object(lhsValue) && is.object(rhsValue)) { + content[key] = merge(lhsValue, rhsValue); } else { - result[key] = xValue as X; + content[key] = rhsValue || lhsValue; } } - return result as X | (X & Y); + return content as X & Y; }; export default merge; diff --git a/src/utils/replace.test.ts b/src/utils/replace.test.ts index 0ac1da9..aad7d3b 100644 --- a/src/utils/replace.test.ts +++ b/src/utils/replace.test.ts @@ -14,13 +14,13 @@ describe('utils/replace()', () => { }); it('should return original source value when there is no match', () => { - const search = ['oh-snap']; + const search = 'oh-snap'; expect(replace(source, search, '')).toBe(source); }); it('should replace search values with replace value', () => { - const search = ['ipsum dolor amet']; + const search = 'ipsum dolor amet'; const expects = 'Spicy jalapeno bacon cheese burger spare ribs ham doner venison ground round strip steak'; expect(replace(source, search, 'cheese burger')).toBe(expects); diff --git a/src/utils/replace.ts b/src/utils/replace.ts index aa04af1..769a0f5 100644 --- a/src/utils/replace.ts +++ b/src/utils/replace.ts @@ -1,12 +1,13 @@ +import toArray from './to-array'; + /** - * Replace - * @description replaces string search values + * Replace search values from string * @param {string} source - * @param {string[]} searchValues + * @param {string | string[]} searchValues * @param {string} replaceWith * @returns {string} */ -const replace = (source: string, searchValues: string[], replaceWith: string): string => - searchValues.reduce((raw, value) => raw.replace(value, replaceWith), source); +const replace = (source: string, searchValues: string | string[], replaceWith: string): string => + toArray(searchValues).reduce((raw, value) => raw.replace(value, replaceWith), source); export default replace; diff --git a/src/utils/shallow-copy.test.ts b/src/utils/shallow-copy.test.ts new file mode 100644 index 0000000..7999ccf --- /dev/null +++ b/src/utils/shallow-copy.test.ts @@ -0,0 +1,45 @@ +import shallowCopy from './shallow-copy'; +import typeOf from './type-of'; + +describe('utils/shallowCopy()', () => { + it('should do shallow copy from an Object', () => { + const source = { name: 'John Doe', age: '30' }; + const copy = shallowCopy(source); + + copy.name = 'John Cena'; + + expect(source.name).not.toEqual(copy.name); + expect(source.age).toEqual(copy.age); + }); + + it('should do shallow copy from an Array', () => { + const source = ['hello', 'world']; + const copy = shallowCopy(source); + + copy[1] = 'you!'; + + expect(source[0]).toEqual(copy[0]); + expect(source[1]).not.toEqual(copy[1]); + }); + + it('should do shallow copy from a Date object', () => { + const source = new Date('1900-12-01 13:00:00'); + const copy = shallowCopy(source); + + copy.setFullYear(2000); + + expect(source.getHours()).toEqual(copy.getHours()); + expect(source.toUTCString()).not.toEqual(copy.toUTCString()); + }); + + const dataTypes = ['string', 1, true, undefined, null]; + + dataTypes.forEach((dataType) => { + it(`should do shallow copy ${typeOf(dataType)}`, () => { + const source = dataType; + const copy = shallowCopy(source); + + expect(source).toEqual(copy); + }); + }); +}); diff --git a/src/utils/shallow-copy.ts b/src/utils/shallow-copy.ts new file mode 100644 index 0000000..754b01e --- /dev/null +++ b/src/utils/shallow-copy.ts @@ -0,0 +1,25 @@ +import is from './is'; +import typeOf from './type-of'; + +/** + * Shallow copy + * @description Create a copy of a original collection with same structure. + * @param value + */ +const shallowCopy = (value: T): T extends [] ? T[] : T => { + let copy: unknown; + + if (is.array(value)) { + copy = [...value]; + } else if (is.object(value)) { + copy = { ...value }; + } else if (typeOf(value) === 'date') { + copy = new Date((value as unknown) as Date); + } else { + copy = value; + } + + return copy as T extends [] ? T[] : T; +}; + +export default shallowCopy; diff --git a/src/utils/to-array.ts b/src/utils/to-array.ts index c014976..9a10904 100644 --- a/src/utils/to-array.ts +++ b/src/utils/to-array.ts @@ -1,3 +1,7 @@ -const toArray = (source: T | T[]): T[] => (Array.isArray(source) ? source : [source]); +/** + * Ensure that given value is array, if not, convert it. + * @param value + */ +const toArray = (value: T | T[]): Array => (Array.isArray(value) ? value : [value]); export default toArray; diff --git a/src/utils/type-of.test.ts b/src/utils/type-of.test.ts new file mode 100644 index 0000000..86c1698 --- /dev/null +++ b/src/utils/type-of.test.ts @@ -0,0 +1,20 @@ +import typeOf from './type-of'; + +describe('utils/typeOf()', () => { + const expectations: Record = { + string: 'string', + number: 1, + boolean: true, + null: null, + object: {}, + array: [], + function: (): void => null, + date: new Date(), + }; + + Object.keys(expectations).forEach((type) => { + it(`should flag "${expectations[type] as string}" as "${type}"`, () => { + expect(typeOf(expectations[type])).toStrictEqual(type); + }); + }); +}); diff --git a/src/utils/type-of.ts b/src/utils/type-of.ts new file mode 100644 index 0000000..6f68491 --- /dev/null +++ b/src/utils/type-of.ts @@ -0,0 +1,16 @@ +/** + * Returns a string with the data type from given value. + * @example + * typeOf('hello'); // output: string + * typeOf(function() {}); // output: function + * typeOf(new Date()); // output: date + * @param value + * @return {string} + */ +const typeOf = (value: unknown | unknown[]): string => + ({}.toString + .call(value) + .match(/\s([A-Za-z]+)/)[1] + .toLowerCase() as string); + +export default typeOf;