Skip to content

Commit

Permalink
✨ Slice walking implementation (#124)
Browse files Browse the repository at this point in the history
* fix #94

* ✨ First naive implementation of slice walking

* ✨ Support negative slice indexes

* ⚡ Avoid multiple array reinstanciations

* 💡 fix operation callback jsdoc

* 🔨 Do not cap slice bounds at length

* 🚚🔥 Move around some utils code, remove unused isArrayProp, inline apply callback for now

* 🚨 fix lint after rebase

* 🚚 Make apply's own test file

* 🔨 Finer mutations detection in tests

* 🚚 Move tests in apply's own test file

* ✅ Test out of bound slice
  • Loading branch information
nlepage committed Nov 13, 2017
1 parent f97fe2c commit 33165c2
Show file tree
Hide file tree
Showing 7 changed files with 289 additions and 52 deletions.
6 changes: 5 additions & 1 deletion src/array/convertArrayMethod.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import { convert } from 'core/convert'

import {
isNil,
} from 'util/lang'

const copyArray = array => {
if (array === undefined || array === null) return []
if (isNil(array)) return []
if (Array.isArray(array)) return [...array]
return [array]
}
Expand Down
40 changes: 27 additions & 13 deletions src/core/apply.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
import {
isArrayProp,
getSliceBounds,
isIndex,
isSlice,
} from './path.utils'

import {
isNil,
length,
} from 'util/lang'

import { unsafeToPath } from './toPath'

/**
Expand All @@ -14,26 +22,20 @@ import { unsafeToPath } from './toPath'
* @since 0.4.0
*/
const copy = (value, asArray) => {
if (value === undefined || value === null) {
if (asArray)
return []
if (isNil(value)) {
if (asArray) return []
return {}
}
if (Array.isArray(value)) return [...value]
return { ...value }
}

const callback = (obj, prop) => {
if (obj === undefined || obj === null) return undefined
return obj[prop]
}

/**
* Operation to apply on a nested property of an object, to be called by {@link core.apply|apply}.
* @memberof core
* @callback operation
* @param {*} obj The last nested object
* @param {string|number|Array<number>} prop The prop of the last nested object
* @param {string|number} prop The prop of the last nested object
* @param {*} value The value of the prop
* @returns {*} Result of the operation
* @private
Expand All @@ -52,12 +54,24 @@ const callback = (obj, prop) => {
* @since 0.4.0
*/
const apply = (obj, path, operation) => {
const walkPath = (curObj, curPath) => {
const walkPath = (curObj, curPath, doCopy = true) => {
const [prop, ...pathRest] = curPath

const value = callback(curObj, prop)
if (isSlice(prop)) {
const [start, end] = getSliceBounds(prop, length(curObj))

const newArr = copy(curObj, true)

for (let i = start; i < end; i++)
walkPath(newArr, [i, ...pathRest], false)

return newArr
}

const value = isNil(curObj) ? undefined : curObj[prop]

const newObj = copy(curObj, isArrayProp(prop))
let newObj = curObj
if (doCopy) newObj = copy(curObj, isIndex(prop))

if (curPath.length === 1) {
operation(newObj, prop, value)
Expand Down
125 changes: 125 additions & 0 deletions src/core/apply.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
/* eslint-env jest */
import { apply } from './apply'
import { immutaTest } from 'test.utils'

describe('Apply', () => {

const _inc = (v, i = 1) => {
let r = Number(v)
if (Number.isNaN(r)) r = 0
return r + i
}

const inc = (obj, path, ...args) => apply(obj, path, (obj, prop) => { obj[prop] = _inc(obj[prop], ...args) })

it('should inc in an array slice', () => {
immutaTest(
input => {
const output = inc(input, 'nested.prop[:].val', 2)
expect(output).toEqual({
nested: {
prop: [
{
val: 6,
other: {},
},
{ val: -6 },
{ val: 2 },
{ val: 2 },
],
},
})
return output
},
{
nested: {
prop: [
{
val: 4,
other: {},
},
{ val: -8 },
{ val: 'a' },
{},
],
},
},
'nested.prop.0.val',
'nested.prop.1.val',
'nested.prop.2.val',
'nested.prop.3.val',
)

immutaTest(
input => {
const output = inc(input, 'nested.prop[1:3].val')
expect(output).toEqual({
nested: {
prop: [{ val: 0 }, {
val: 2,
other: {},
}, { val: 3 }, { val: 3 }],
},
other: {},
})
return output
},
{
nested: {
prop: [{ val: 0 }, {
val: 1,
other: {},
}, { val: 2 }, { val: 3 }],
},
other: {},
},
'nested.prop.1.val',
'nested.prop.2.val',
)

immutaTest(
input => {
const output = inc(input, 'nested.prop[-3:-1].val')
expect(output).toEqual({
nested: {
prop: [{ val: 0 }, {
val: 2,
other: {},
}, { val: 3 }, { val: 3 }],
},
other: {},
})
return output
},
{
nested: {
prop: [{ val: 0 }, {
val: 1,
other: {},
}, { val: 2 }, { val: 3 }],
},
other: {},
},
'nested.prop.1.val',
'nested.prop.2.val',
)
})

immutaTest(
input => {
const output = inc(input, 'nested.prop[3:5].val', 6)
expect(output).toEqual({
nested: { prop: [{ val: 0 }, { val: 1 }, undefined, { val: 6 }, { val: 6 }] },
other: {},
})
return output
},
{
nested: { prop: [{ val: 0 }, { val: 1 }] },
other: {},
},
'nested.prop.2',
'nested.prop.3.val',
'nested.prop.4.val',
)
})
42 changes: 26 additions & 16 deletions src/core/path.utils.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,34 @@
import {
isNaturalInteger,
} from 'util/lang'

const getSliceBound = (value, defaultValue, length) => {
if (value === undefined) return defaultValue
if (value < 0) return Math.max(length + value, 0)
return value
}

/**
* Tests whether <code>arg</code> is a valid index, that is a positive integer.
* Get the actual bounds of a slice.
* @param {Array<number>} bounds The bounds of the slice
* @param {number} length The length of the actual array
* @returns {Array<number>} The actual bounds of the slice
* @private
* @since 0.4.0
*/
const getSliceBounds = ([start, end], length) => ([
getSliceBound(start, 0, length),
getSliceBound(end, length, length),
])

/**
* This is an alias for {@link util/isNaturalInteger}.
* @function
* @param {*} arg The value to test
* @return {boolean} True if <code>arg</code> is a valid index, false otherwise
* @memberof core
* @private
* @since 0.4.0
*/
const isIndex = arg => Number.isSafeInteger(arg) && arg >= 0
const isIndex = isNaturalInteger

/**
* Tests whether <code>arg</code> is a valid slice index, that is an integer or <code>undefined</code>.
Expand Down Expand Up @@ -35,19 +56,8 @@ const isSlice = arg => {
return isSliceIndex(arg[0]) && isSliceIndex(arg[1])
}

/**
* Tests whether <code>arg</code> is either an index or a slice.
* @function
* @param {*} arg The value to test
* @return {boolean} True if <code>arg</code> is either an index or a slice, false otherwise
* @memberof core
* @private
* @since 0.4.0
*/
const isArrayProp = arg => isIndex(arg) || isSlice(arg)

export {
isArrayProp,
getSliceBounds,
isIndex,
isSlice,
isSliceIndex,
Expand Down
31 changes: 9 additions & 22 deletions src/core/path.utils.spec.js
Original file line number Diff line number Diff line change
@@ -1,33 +1,20 @@
/* eslint-env jest */
import {
isIndex,
getSliceBounds,
isSlice,
isSliceIndex,
} from './path.utils'

describe('Path Utils', () => {
describe('IsIndex', () => {
it('should return true for any non negative integer', () => {
expect(isIndex(0)).toBe(true)
expect(isIndex(1)).toBe(true)
expect(isIndex(6)).toBe(true)
expect(isIndex(100000000000)).toBe(true)
})

it('should return false for any negative integer', () => {
expect(isIndex(-1)).toBe(false)
expect(isIndex(-6)).toBe(false)
expect(isIndex(-100000000000)).toBe(false)
})
describe('GetSliceBounds', () => {
it('should return actual slice bounds', () => {
expect(getSliceBounds([undefined, undefined], 0)).toEqual([0, 0])
expect(getSliceBounds([-2, -1], 0)).toEqual([0, 0])
expect(getSliceBounds([1, 2], 0)).toEqual([1, 2])

it('should return false for any non integer', () => {
expect(isIndex(undefined)).toBe(false)
expect(isIndex(null)).toBe(false)
expect(isIndex(true)).toBe(false)
expect(isIndex({})).toBe(false)
expect(isIndex([])).toBe(false)
expect(isIndex('')).toBe(false)
expect(isIndex(.6)).toBe(false)
expect(getSliceBounds([undefined, undefined], 6)).toEqual([0, 6])
expect(getSliceBounds([1, -1], 6)).toEqual([1, 5])
expect(getSliceBounds([7, 8], 6)).toEqual([7, 8])
})
})

Expand Down
37 changes: 37 additions & 0 deletions src/util/lang.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,25 @@
/**
* Tests whether <code>arg</code> is a natural integer.
* @function
* @param {*} arg The value to test
* @return {boolean} True if <code>arg</code> is a natural integer, false otherwise
* @memberof util
* @private
* @since 0.4.0
*/
const isNaturalInteger = arg => Number.isSafeInteger(arg) && arg >= 0

/**
* Tests whether <code>arg</code> is a <code>undefined</code> or <code>null</code>.
* @function
* @param {*} arg The value to test
* @return {boolean} True if <code>arg</code> is <code>undefined</code> or <code>null</code>, false otherwise
* @memberof util
* @private
* @since 0.4.0
*/
const isNil = arg => arg === undefined || arg === null

/**
* Tests whether <code>arg</code> is a Symbol.
* @param {*} arg The value to test
Expand All @@ -9,6 +31,18 @@
*/
const isSymbol = arg => typeof arg === 'symbol'

/**
* Returns the length of <code>arg</code>.
* @param {*} arg The value of which length must be returned
* @returns {number} The length of <code>arg</code>
* @private
* @since 0.4.0
*/
const length = arg => {
if (isNil(arg) || !isNaturalInteger(arg.length)) return 0
return arg.length
}

/**
* Converts <code>arg</code> to a string using string interpolation.
* @param {*} arg The value to convert
Expand All @@ -20,6 +54,9 @@ const isSymbol = arg => typeof arg === 'symbol'
const toString = arg => typeof arg === 'string' ? arg : `${arg}`

export {
isNaturalInteger,
isNil,
isSymbol,
length,
toString,
}
Loading

0 comments on commit 33165c2

Please sign in to comment.