Skip to content

Commit

Permalink
feat!(accessors): update get/set methods to allow for arrays to be tr…
Browse files Browse the repository at this point in the history
…aversed (#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.
  • Loading branch information
cahilfoley authored May 26, 2019
1 parent 5b305c7 commit 9207675
Show file tree
Hide file tree
Showing 4 changed files with 165 additions and 38 deletions.
57 changes: 46 additions & 11 deletions src/accessors/get.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
})
10 changes: 7 additions & 3 deletions src/accessors/get.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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()
Expand All @@ -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
}
}
113 changes: 94 additions & 19 deletions src/accessors/set.test.ts
Original file line number Diff line number Diff line change
@@ -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'] })
})
})
})
23 changes: 18 additions & 5 deletions src/accessors/set.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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<string, any> | 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] = {}
}

Expand Down

0 comments on commit 9207675

Please sign in to comment.