From 9207675b9b9e66e9149d1c77cc370e9d9d0ddc98 Mon Sep 17 00:00:00 2001 From: Cahil Foley Date: Sun, 26 May 2019 18:13:42 +0800 Subject: [PATCH] feat!(accessors): update get/set methods to allow for arrays to be traversed (#45) BREAKING CHANGE: The get and set methods will new process numeric path accessors as array indicies. This is probably the expected behaviour but wasn't how it previously worked and is therefore a breaking change. --- src/accessors/get.test.ts | 57 +++++++++++++++---- src/accessors/get.ts | 10 +++- src/accessors/set.test.ts | 113 +++++++++++++++++++++++++++++++------- src/accessors/set.ts | 23 ++++++-- 4 files changed, 165 insertions(+), 38 deletions(-) diff --git a/src/accessors/get.test.ts b/src/accessors/get.test.ts index 23aeafb..d2a8333 100644 --- a/src/accessors/get.test.ts +++ b/src/accessors/get.test.ts @@ -1,21 +1,56 @@ import get from './get' describe('Recursive Set (set)', () => { - const testObject = { foo: { bar: 'hello' } } + const testObject = { + value: 'layer 1', + child: { + value: 'layer 2', + child: { + value: 'layer 3', + child: { + value: 'layer 4', + }, + children: [{ value: 'layer 3 child 1' }, { value: 'layer 3 child 2' }], + }, + children: [{ value: 'layer 2 child 1' }, { value: 'layer 2 child 2' }], + }, + } - test('Fetches nested keys', () => { - expect(get(testObject, ['foo', 'bar'])).toBe('hello') - }) + describe('Nested accessor', () => { + it('should retrieve nested values with a path array of any depth', () => { + expect(get(testObject, ['value'])).toBe('layer 1') + expect(get(testObject, ['child', 'value'])).toBe('layer 2') + expect(get(testObject, ['child', 'child', 'value'])).toBe('layer 3') + expect(get(testObject, ['child', 'child', 'child', 'value'])).toBe('layer 4') + }) - test('Accepts period delimited string for path', () => { - expect(get(testObject, 'foo.bar')).toBe('hello') - }) + it('should retrieve nested values with a period delimited string of any depth', () => { + expect(get(testObject, 'value')).toBe('layer 1') + expect(get(testObject, 'child.value')).toBe('layer 2') + expect(get(testObject, 'child.child.value')).toBe('layer 3') + expect(get(testObject, 'child.child.child.value')).toBe('layer 4') + }) - test(`Returns undefined if the property doesn't exist`, () => { - expect(get(testObject, ['bar', 'baz'])).toBeUndefined() + it('should traverse both objects and arrays when using a string path', () => { + expect(get(testObject, 'child.children[0].value')).toBe('layer 2 child 1') + expect(get(testObject, 'child.child.children[1].value')).toBe('layer 3 child 2') + }) }) - test(`Returns a default value if one is provided and they key doesn't exist`, () => { - expect(get(testObject, ['bar', 'baz'], 'world')).toBe('world') + describe('Default value', () => { + it(`should return undefined if the property doesn't exist and a default value is not provided`, () => { + expect(get(testObject, 'child.baz')).toBeUndefined() + expect(get(testObject, ['child', 'baz'])).toBeUndefined() + }) + + it(`should return a default value if one is provided and a value isn't found`, () => { + expect(get(testObject, 'child.baz', 'world')).toBe('world') + expect(get(testObject, ['child', 'baz'], 'world')).toBe('world') + }) + + it(`should ignore the default value if one is provided but a value is found`, () => { + expect(get(testObject, 'child.value', 'world')).toBe('layer 2') + expect(get(testObject, ['child', 'value'], 'world')).toBe('layer 2') + }) }) }) diff --git a/src/accessors/get.ts b/src/accessors/get.ts index 3abd58c..dd39570 100644 --- a/src/accessors/get.ts +++ b/src/accessors/get.ts @@ -1,3 +1,5 @@ +import arrayAccessor from '../internal/patterns/arrayAccessor' + /** * * Gets the value at path of object. If the resolved value is undefined, the defaultValue is returned in its place. @@ -12,7 +14,9 @@ */ export default function get(object: object, path: string[] | string, defaultValue?: any): any { // If the path was a string, split it by periods - path = typeof path === 'string' ? path.split('.') : path + if (typeof path === 'string') { + path = path.replace(arrayAccessor, '.$1').split('.') + } // Next key to access const next = path.shift() @@ -24,10 +28,10 @@ export default function get(object: object, path: string[] | string, defaultValu return defaultValue } - // Call set recursively with the next section of the path + // Call get recursively with the next section of the path return get(object[next], path, defaultValue) } else { // Up to the last section of the path, get the value now - return object[next] + return object[next] || defaultValue } } diff --git a/src/accessors/set.test.ts b/src/accessors/set.test.ts index 6c44656..615877a 100644 --- a/src/accessors/set.test.ts +++ b/src/accessors/set.test.ts @@ -1,31 +1,106 @@ import set from './set' describe('Recursive Set (set)', () => { - test('Updates nested keys', () => { - const testObject = { foo: { bar: 'hello' } } - set(testObject, ['foo', 'bar'], 'world') + describe('Nested accessor', () => { + it('should update nested values with a path array of any depth', () => { + const testObject = { + value: 'layer 1', + child: { + value: 'layer 2', + child: { + value: 'layer 3', + child: { + value: 'layer 4', + }, + }, + }, + } - expect(testObject).toEqual({ foo: { bar: 'world' } }) - }) + set(testObject, ['value'], 'updated layer 1') + expect(testObject.value).toBe('updated layer 1') - test('Accepts period delimited string for path', () => { - const testObject = { foo: { bar: { baz: 'hello' } } } - set(testObject, 'foo.bar.baz', 'world') + set(testObject, ['child', 'value'], 'updated layer 2') + expect(testObject.child.value).toBe('updated layer 2') - expect(testObject).toEqual({ foo: { bar: { baz: 'world' } } }) - }) + set(testObject, ['child', 'child', 'value'], 'updated layer 3') + expect(testObject.child.child.value).toBe('updated layer 3') - test('Creates nested objects if they do not exist', () => { - const testObject = { foo: { bar: 'hello' } } - set(testObject, ['bar', 'baz'], 'world') + set(testObject, ['child', 'child', 'child', 'value'], 'updated layer 4') + expect(testObject.child.child.child.value).toBe('updated layer 4') - expect(testObject).toEqual({ bar: { baz: 'world' }, foo: { bar: 'hello' } }) - }) + expect(testObject).toEqual({ + value: 'updated layer 1', + child: { + value: 'updated layer 2', + child: { + value: 'updated layer 3', + child: { + value: 'updated layer 4', + }, + }, + }, + }) + }) + + it('should update nested values with a period delimited string of any depth', () => { + const testObject = { + value: 'layer 1', + child: { + value: 'layer 2', + child: { + value: 'layer 3', + child: { + value: 'layer 4', + }, + }, + }, + } + + set(testObject, 'value', 'updated layer 1') + expect(testObject.value).toBe('updated layer 1') + + set(testObject, 'child.value', 'updated layer 2') + expect(testObject.child.value).toBe('updated layer 2') + + set(testObject, 'child.child.value', 'updated layer 3') + expect(testObject.child.child.value).toBe('updated layer 3') + + set(testObject, 'child.child.child.value', 'updated layer 4') + expect(testObject.child.child.child.value).toBe('updated layer 4') + + expect(testObject).toEqual({ + value: 'updated layer 1', + child: { + value: 'updated layer 2', + child: { + value: 'updated layer 3', + child: { + value: 'updated layer 4', + }, + }, + }, + }) + }) + + it('should create empty objects or arrays when descending the object if they do not exist', () => { + const testObject = { foo: { bar: 'hello' } } + set(testObject, 'bar.baz', 'world') + expect(testObject).toEqual({ bar: { baz: 'world' }, foo: { bar: 'hello' } }) + + const testObjectEmpty = {} + set(testObjectEmpty, 'foo[0].bar[0].baz', 'world') + expect(testObjectEmpty).toEqual({ foo: [{ bar: [{ baz: 'world' }] }] }) + }) - test('Overrides keys in the path if they are not objects', () => { - const testObject = { foo: { bar: 'hello' } } - set(testObject, ['foo', 'bar', 'baz'], 'world') + it('should convert values in the path to objects if they are not indexable', () => { + const testObject = { foo: { bar: 'hello' } } + set(testObject, 'foo.bar.baz', 'world') + expect(testObject).toEqual({ foo: { bar: { baz: 'world' } } }) - expect(testObject).toEqual({ foo: { bar: { baz: 'world' } } }) + const testObjectArray = { foo: 'hello world' } + set(testObjectArray, 'foo[0]', 'hola') + set(testObjectArray, 'foo[1]', 'world') + expect(testObjectArray).toEqual({ foo: ['hola', 'world'] }) + }) }) }) diff --git a/src/accessors/set.ts b/src/accessors/set.ts index fe383bc..8a63ef0 100644 --- a/src/accessors/set.ts +++ b/src/accessors/set.ts @@ -1,3 +1,5 @@ +import arrayAccessor from '../internal/patterns/arrayAccessor' + /** * * Sets the value at path of object. If a portion of path doesn't exist, it's created. Arrays are created for missing @@ -10,17 +12,28 @@ * @category accessors * */ -export default function set(object: object, path: string[] | string, value: any): void { - // If the path was a string, split it by periods - path = typeof path === 'string' ? path.split('.') : path +export default function set( + object: Record | any[], + path: string[] | string, + value: any, +): void { + // If the path was a string, split it by periods and array accessors + if (typeof path === 'string') { + path = path.replace(arrayAccessor, '.$1').split('.') + } // Next key to access - const next = path.shift() + let next: string | number = path.shift() // Still got more steps to go if (path.length) { + // If the next path item is a number then the item we are about to enter is an array + if (!Number.isNaN(+path[0])) { + // If the next item isn't already an array then create it + if (!Array.isArray(object[next])) object[next] = [] + } // If the next key isn't an object - make it one - if (!object[next] || typeof object[next] !== 'object') { + else if (!object[next] || typeof object[next] !== 'object') { object[next] = {} }