parent
cd9016d8f0
commit
958e46afd9
|
@ -0,0 +1,15 @@
|
|||
env:
|
||||
node: true
|
||||
es2021: true
|
||||
mocha: true
|
||||
extends:
|
||||
- standard
|
||||
parserOptions:
|
||||
ecmaVersion: 12
|
||||
sourceType: module
|
||||
rules:
|
||||
indent: ["error", 2]
|
||||
no-console: 0
|
||||
semi: ["error", "never"]
|
||||
# linebreak-style: ["error", "unix"]
|
||||
quotes: ["error", "single"]
|
|
@ -0,0 +1 @@
|
|||
/node_modules/
|
|
@ -0,0 +1,8 @@
|
|||
tests/
|
||||
test/
|
||||
*.test.js
|
||||
testing/
|
||||
node_modules
|
||||
eslintrc*
|
||||
examples/
|
||||
example/
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"editor.tokenColorCustomizations": {
|
||||
"comments": "",
|
||||
"textMateRules": []
|
||||
}
|
||||
}
|
|
@ -0,0 +1,46 @@
|
|||
{
|
||||
"name": "@uci-utils/obj-nested-prop",
|
||||
"version": "0.1.0",
|
||||
"description": "Functions to get set, delete, and search nested properties of an object",
|
||||
"main": "src/obj-nested-prop.js",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "./node_modules/.bin/mocha --timeout 30000",
|
||||
"test:log": "UCI_ENV=pro UCI_LOG_PATH=./test/test.log 0 npx run test || exit 0",
|
||||
"test:dev": "UCI_ENV=dev ./node_modules/.bin/nodemon --exec './node_modules/.bin/mocha --timeout 30000' || exit 0",
|
||||
"test:dev:trace": "UCI_LOG_LEVEL='trace' npm run test:dev",
|
||||
"test:dev:debug": "UCI_LOG_LEVEL='debug' npm run test:dev",
|
||||
"test:dev:error": "UCI_LOG_LEVEL='error' npm run test:dev"
|
||||
},
|
||||
"author": "David Kebler",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/uCOMmandIt/uci-utils.git"
|
||||
},
|
||||
"keywords": [
|
||||
"node.js",
|
||||
"object",
|
||||
"nested",
|
||||
"properties",
|
||||
"utilities",
|
||||
"helpers"
|
||||
],
|
||||
"bugs": {
|
||||
"url": "https://github.com/uCOMmandIt/uci-utils/issues"
|
||||
},
|
||||
"homepage": "https://github.com/uCOMmandIt/uci-utils#readme",
|
||||
"dependencies": {
|
||||
"@uci-utils/logger": "^0.1.0",
|
||||
"@uci-utils/type": "^0.6.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"eslint": "^7.25.0",
|
||||
"eslint-config-standard": "^16.0.2",
|
||||
"mocha": "^8.4.0",
|
||||
"nodemon": "^2.0.7"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
# Object Nested Property Functions
|
||||
#### a uCOMmandIt Utiltiy
|
||||
|
||||
|
||||
Set, access and delete nested object properties with a path string and or array
|
||||
|
||||
|
||||
exported set,get,del,getKeys,walkPath
|
||||
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,147 @@
|
|||
import { check } from '@uci-utils/type'
|
||||
import { logger } from '@uci-utils/logger'
|
||||
|
||||
const log = logger({ file: '/src/obj-prop.js', package: '@uci-utils/obj-prop' })
|
||||
|
||||
const set = (obj, path, value, opts = {}) => {
|
||||
if (value === undefined) return del(obj, path)
|
||||
const keys = validateArgs(obj, path, opts)
|
||||
if (!keys) return obj
|
||||
const target = obj
|
||||
|
||||
log.debug({ function: 'set', obj: obj, path: path, keys: keys, value: value, msg: 'setting value' })
|
||||
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
const key = keys[i]
|
||||
if (!isValidKey(key)) {
|
||||
break
|
||||
}
|
||||
const next = keys[i + 1]
|
||||
|
||||
log.trace({ function: 'set', obj: target, key: key, next: next, msg: 'iterate keys' })
|
||||
|
||||
// validateKey(key); ?? necessary
|
||||
|
||||
if (next === undefined) { // at a leaf, set
|
||||
log.debug({ function: 'set', value: value, msg: 'no more props, setting value' })
|
||||
if (opts.merge && check.isPlainObject(obj[key]) && check.isPlainObject(value)) {
|
||||
const merge = opts.merge === true ? Object.assign : opts.merge
|
||||
// Only merge plain objects
|
||||
obj[key] = merge(obj[key], value)
|
||||
} else {
|
||||
obj[key] = value
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
if (check.isNumber(next) && !check.isArray(obj[key])) {
|
||||
log.debug({ function: 'set', obj: obj, key: key, msg: 'adding array' })
|
||||
obj[key] = [] // add array
|
||||
} else if (!(check.isObject(obj[key]) || check.isFunction(obj[key]))) obj[key] = {} // add empty object
|
||||
obj = obj[key]
|
||||
} // next key
|
||||
|
||||
log.debug({ function: 'set', obj: target, path: path, value: value, msg: 'value was set' })
|
||||
return target
|
||||
}
|
||||
|
||||
function del (obj, path) {
|
||||
const keys = validateArgs(obj, path)
|
||||
if (!keys) return obj
|
||||
log.debug({ function: 'del', obj: obj, path: path, keys: keys, msg: 'deleting property' })
|
||||
const last = walkPath(obj, keys.slice(0, -1))
|
||||
if (check.isObject(last)) {
|
||||
delete last[keys.slice(-1)]
|
||||
log.debug({ obj: obj, path: path, keys: keys, deleted: keys.slice(-1), msg: 'deleted property from object' })
|
||||
}
|
||||
return obj
|
||||
}
|
||||
|
||||
function get (obj, path) {
|
||||
const keys = validateArgs(obj, path)
|
||||
if (!keys) return undefined
|
||||
const value = walkPath(obj, keys)
|
||||
log.debug({ obj: obj, path: path, keys: keys, value: value, msg: 'retrieved value from object' })
|
||||
return value
|
||||
}
|
||||
|
||||
const getKeys = (path, opts = {}) => {
|
||||
if (!(check.isString(path) || check.isArray(path) || check.isSymbol(path))) {
|
||||
log.warn({ function: 'getKeys', path: path, msg: 'path was not valid' })
|
||||
return false
|
||||
}
|
||||
if (check.isFunction(opts.split)) return opts.split(path, opts)
|
||||
const sep = opts.separator || opts.delimiter || '.'
|
||||
if (typeof path === 'symbol') {
|
||||
return [path]
|
||||
}
|
||||
// custom get keys
|
||||
if (typeof opts.split === 'function') {
|
||||
return opts.split(path, opts)
|
||||
}
|
||||
|
||||
const regx = new RegExp(`(?<!\\\\)[${sep}]`, 'g')
|
||||
const regxr = /\\/ig
|
||||
const splitr = i => i.split(regx).map(e => e.replace(regxr, ''))
|
||||
const keys = Array.isArray(path) ? path.filter(Boolean).map(e => typeof e === 'string' && opts.elSplit !== false ? splitr(e) : e).flat() : splitr(path)
|
||||
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
if (!check.isString(keys[i])) break
|
||||
const { is, number } = isNumber(keys[i])
|
||||
|
||||
if (is) {
|
||||
keys[i] = number
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return keys
|
||||
}
|
||||
|
||||
function validateArgs (obj, path, opts) {
|
||||
if (!check.isObject(obj)) {
|
||||
log.warn({ function: 'del', obj: obj, msg: 'passed is not an object' })
|
||||
return false
|
||||
}
|
||||
return getKeys(path, opts)
|
||||
}
|
||||
|
||||
function walkPath (obj, keys) {
|
||||
let last = obj
|
||||
if (!check.isArray(keys)) return null
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
if (!isValidKey(keys[i])) {
|
||||
last = null
|
||||
break
|
||||
}
|
||||
last = last[keys[i]]
|
||||
if (!last) {
|
||||
log.warn({ function: 'walkPath', obj: obj, keys: keys, key: keys[i], msg: 'key is not in object, aborting walk' })
|
||||
break
|
||||
}
|
||||
log.trace({ function: 'walkPath', curProp: last, msg: 'fetching... deep property' })
|
||||
}
|
||||
return last
|
||||
}
|
||||
|
||||
const isNumber = value => {
|
||||
if (value.trim() !== '') {
|
||||
const number = Number(value)
|
||||
return { is: Number.isInteger(number), number }
|
||||
}
|
||||
return { is: false }
|
||||
}
|
||||
|
||||
const isUnsafeKey = key => {
|
||||
return key === '__proto__' || key === 'constructor' || key === 'prototype'
|
||||
}
|
||||
|
||||
const isValidKey = key => {
|
||||
if (isUnsafeKey(key)) {
|
||||
throw new Error(`Cannot set unsafe key: "${key}"`)
|
||||
}
|
||||
// TODO custom key validation if needed
|
||||
return true
|
||||
}
|
||||
|
||||
export { set, get, del, getKeys, walkPath }
|
|
@ -0,0 +1,293 @@
|
|||
import assert from 'assert'
|
||||
import { get, set, del } from '../src/obj-nested-prop.js'
|
||||
import { check } from '@uci-utils/type'
|
||||
|
||||
const date = new Date(Date.now())
|
||||
console.log('run:', date.getMinutes(), ':', date.getSeconds())
|
||||
|
||||
const obj = { one: { two: { three: 'test', four: ['el0', 'el1'] } } }
|
||||
|
||||
describe('get', function () {
|
||||
it('Should get value deep in object', function () {
|
||||
assert.strictEqual(get(obj, 'one.two.three'), 'test')
|
||||
})
|
||||
|
||||
it('Should return undefined if path is not in object', function () {
|
||||
assert.strictEqual(get(obj, 'one.seven.three'), undefined)
|
||||
})
|
||||
|
||||
it('Should get value from array deep in object', function () {
|
||||
assert.strictEqual(get(obj, 'one.two.four.1'), 'el1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('del', function () {
|
||||
it('deep property can be deleted', function () {
|
||||
del(obj, 'one.two.three')
|
||||
assert.deepStrictEqual(obj, { one: { two: { four: ['el0', 'el1'] } } })
|
||||
})
|
||||
})
|
||||
|
||||
describe('unsafe properties', () => {
|
||||
it('should not allow setting constructor', () => {
|
||||
// assert.deepStrictEqual(set({}, 'a.constructor.b', 'c'), {})
|
||||
assert.throws(() => set({}, 'a.constructor', 'c'))
|
||||
// assert.throws(() => set({}, 'constructor', 'c'))
|
||||
})
|
||||
it('should not allow setting prototype', () => {
|
||||
assert.throws(() => set({}, 'a.prototype.b', 'c'))
|
||||
assert.throws(() => set({}, 'a.prototype', 'c'))
|
||||
assert.throws(() => set({}, 'prototype', 'c'))
|
||||
})
|
||||
|
||||
it('should not allow setting __proto__', () => {
|
||||
assert.throws(() => set({}, 'a.__proto__.b', 'c'))
|
||||
assert.throws(() => set({}, 'a.__proto__', 'c'))
|
||||
assert.throws(() => set({}, '__proto__', 'c'))
|
||||
})
|
||||
})
|
||||
|
||||
describe('set', () => {
|
||||
it('should support immediate properties', () => {
|
||||
const o = {}
|
||||
set(o, 'a', 'b')
|
||||
assert.strictEqual(o.a, 'b')
|
||||
})
|
||||
|
||||
it('should return non-objects', () => {
|
||||
const str = set('foo', 'a.b', 'c')
|
||||
assert.strictEqual(str, 'foo')
|
||||
const _null = set(null, 'a.b', 'c')
|
||||
assert.strictEqual(_null, null)
|
||||
})
|
||||
|
||||
it('should set when key is a symbol', () => {
|
||||
const key = Symbol('foo')
|
||||
const obj = {}
|
||||
set(obj, key, 'bar')
|
||||
assert.strictEqual(obj[key], 'bar')
|
||||
})
|
||||
|
||||
it('should set on the root of the object', () => {
|
||||
const o = {}
|
||||
set(o, 'foo', 'bar')
|
||||
assert.strictEqual(o.foo, 'bar')
|
||||
})
|
||||
|
||||
it('deep property can be set', function () {
|
||||
set(obj, 'one.two.three', 'test2')
|
||||
assert.strictEqual(obj.one.two.three, 'test2')
|
||||
})
|
||||
|
||||
it('should set the specified property.', () => {
|
||||
assert.deepStrictEqual(set({ a: 'aaa', b: 'b' }, 'a', 'bbb'), { a: 'bbb', b: 'b' })
|
||||
})
|
||||
|
||||
it('should set a nested property', () => {
|
||||
const o = {}
|
||||
set(o, 'a.b', 'c')
|
||||
assert.strictEqual(o.a.b, 'c')
|
||||
})
|
||||
|
||||
it('should set a nested property where the last key is a symbol', () => {
|
||||
const o = {}
|
||||
set(o, 'a.b', 'c')
|
||||
assert.strictEqual(o.a.b, 'c')
|
||||
})
|
||||
|
||||
it('should support passing an array as the key', () => {
|
||||
const actual = set({ a: 'a', b: { c: 'd' } }, ['b', 'c', 'd'], 'eee')
|
||||
assert.deepStrictEqual(actual, { a: 'a', b: { c: { d: 'eee' } } })
|
||||
})
|
||||
|
||||
it('should set a deeply nested value.', () => {
|
||||
const actual = set({ a: 'a', b: { c: 'd' } }, 'b.c.d', 'eee')
|
||||
assert.deepStrictEqual(actual, { a: 'a', b: { c: { d: 'eee' } } })
|
||||
})
|
||||
|
||||
it('should allow keys to be whitespace', () => {
|
||||
const o = {}
|
||||
set(o, 'a. .a', { y: 'z' })
|
||||
assert.deepStrictEqual(o.a[' '].a, { y: 'z' })
|
||||
})
|
||||
|
||||
it('should set an array element', () => {
|
||||
const o = { a: ['zero', 'one'] }
|
||||
set(o, 'a.0', { y: 'z' })
|
||||
assert(Array.isArray(o.a))
|
||||
assert.deepStrictEqual(o.a[0], { y: 'z' })
|
||||
assert.deepStrictEqual(o.a[1], 'one')
|
||||
})
|
||||
|
||||
it('should create an array if it does not already exist', () => {
|
||||
const o = {}
|
||||
set(o, 'a.0', 'zero')
|
||||
set(o, 'a.1.b', { y: 'z' })
|
||||
set(o, 'a.1.b.x', 'eggs')
|
||||
set(o, 'c.1', 'see')
|
||||
set(o, '0', 'not array')
|
||||
assert(check.isArray(o.a))
|
||||
assert.deepStrictEqual(o.a[0], 'zero')
|
||||
assert.deepStrictEqual(o.a[1].b, { y: 'z', x: 'eggs' })
|
||||
assert.deepStrictEqual(o.c[1], 'see')
|
||||
assert.deepStrictEqual(o[0], 'not array')
|
||||
})
|
||||
|
||||
it('should extend a function', () => {
|
||||
const log = () => { }
|
||||
const warning = () => { }
|
||||
const o = {}
|
||||
|
||||
set(o, 'helpers.foo', log)
|
||||
set(o, 'helpers.foo.warning', warning)
|
||||
assert(check.isFunction(o.helpers.foo))
|
||||
assert(check.isFunction(o.helpers.foo))
|
||||
})
|
||||
|
||||
it('should extend an object in an array', () => {
|
||||
const o = { a: [{}, {}, {}] }
|
||||
set(o, 'a.0.a', { y: 'z' })
|
||||
set(o, 'a.1.b', { y: 'z' })
|
||||
set(o, 'a.2.c', { y: 'z' })
|
||||
assert(Array.isArray(o.a))
|
||||
assert.deepStrictEqual(o.a[0].a, { y: 'z' })
|
||||
assert.deepStrictEqual(o.a[1].b, { y: 'z' })
|
||||
assert.deepStrictEqual(o.a[2].c, { y: 'z' })
|
||||
})
|
||||
|
||||
it('should not create a nested property if it does already exist', () => {
|
||||
const first = { name: 'Halle' }
|
||||
const o = { a: first }
|
||||
set(o, 'a.b', 'c')
|
||||
assert.strictEqual(o.a.b, 'c')
|
||||
assert.strictEqual(o.a, first)
|
||||
assert.strictEqual(o.a.name, 'Halle')
|
||||
})
|
||||
|
||||
it('should use property paths to set nested values from the source object.', () => {
|
||||
const o = {}
|
||||
set(o, 'a.locals.name', { first: 'Brian' })
|
||||
set(o, 'b.locals.name', { last: 'Woodward' })
|
||||
set(o, 'b.locals.name.last', 'Woodward')
|
||||
assert.deepStrictEqual(o, { a: { locals: { name: { first: 'Brian' } } }, b: { locals: { name: { last: 'Woodward' } } } })
|
||||
})
|
||||
|
||||
it('should delete the property when value is undefined', () => {
|
||||
const o = {}
|
||||
assert.deepStrictEqual(set(o, 'a.locals.name', 'test'), o)
|
||||
assert.deepStrictEqual(set(o, 'b.locals.name'), o)
|
||||
assert.deepStrictEqual(set(o, 'a.locals.name'), { a: { locals: {} } })
|
||||
assert.deepStrictEqual(set({ a: 'a', b: { c: 'd' } }, 'b.c'), { a: 'a', b: {} })
|
||||
})
|
||||
|
||||
it('should return the entire object if no or improper path is passed.', () => {
|
||||
assert.deepStrictEqual(set({ a: 'a', b: { c: 'd' } }), { a: 'a', b: { c: 'd' } })
|
||||
})
|
||||
|
||||
it('should set non-plain objects', done => {
|
||||
const o = {}
|
||||
|
||||
set(o, 'a.b', new Date())
|
||||
const firstDate = o.a.b.getTime()
|
||||
|
||||
setTimeout(function () {
|
||||
set(o, 'a.b', new Date())
|
||||
const secondDate = o.a.b.getTime()
|
||||
|
||||
assert.notDeepStrictEqual(firstDate, secondDate)
|
||||
done()
|
||||
}, 10)
|
||||
})
|
||||
})
|
||||
|
||||
describe('escaping', () => {
|
||||
it('should not split escaped dots. Works with set, get or del', () => {
|
||||
const o = {}
|
||||
set(o, 'a\\.b.c.d.e', 'test')
|
||||
assert.strictEqual(o['a.b'].c.d.e, 'test')
|
||||
assert.strictEqual(get(o, 'a\\.b.c.d.e'), 'test')
|
||||
assert.deepStrictEqual(del(o, 'a\\.b.c.d.e'), { 'a.b': { c: { d: {} } } })
|
||||
})
|
||||
|
||||
it('should work with multiple escaped dots', () => {
|
||||
const obj1 = {}
|
||||
set(obj1, 'e\\.f\\.g', 1)
|
||||
assert.strictEqual(obj1['e.f.g'], 1)
|
||||
|
||||
const obj2 = {}
|
||||
set(obj2, 'e\\.f.g\\.h\\.i.j', 1)
|
||||
assert.deepStrictEqual(obj2, { 'e.f': { 'g.h.i': { j: 1 } } })
|
||||
})
|
||||
|
||||
it('should work with multiple escaped any separator', () => {
|
||||
const obj2 = {}
|
||||
set(obj2, 'e\\.f.g\\/h\\%i.j', 1, { separator: './%' })
|
||||
assert.deepStrictEqual(obj2, { 'e.f': { 'g/h%i': { j: 1 } } })
|
||||
})
|
||||
})
|
||||
|
||||
describe('options.merge', () => {
|
||||
it('should merge an existing value with the given value', () => {
|
||||
const o = { a: { b: { c: 'd' } } }
|
||||
set(o, 'a.b', { y: 'z' }, { merge: true })
|
||||
assert.deepStrictEqual(o.a.b, { c: 'd', y: 'z' })
|
||||
|
||||
const obj = { foo: { bar: { baz: 'qux' } } }
|
||||
set(obj, 'foo.bar.fez', 'zzz', { merge: true })
|
||||
assert.deepStrictEqual(obj, { foo: { bar: { baz: 'qux', fez: 'zzz' } } })
|
||||
})
|
||||
|
||||
it('should update an object by merging values', () => {
|
||||
const o = {}
|
||||
set(o, 'a', { b: 'c' })
|
||||
set(o, 'a', { c: 'd' }, { merge: true })
|
||||
assert.deepStrictEqual(o, { a: { b: 'c', c: 'd' } })
|
||||
set(o, 'a', 'b')
|
||||
assert.strictEqual(o.a, 'b')
|
||||
})
|
||||
})
|
||||
|
||||
describe('options.separator', () => {
|
||||
it('should allow a custom separator', () => {
|
||||
const o = {}
|
||||
set(o, 'a/b/c/d/e', 'c', { separator: '/' })
|
||||
assert.strictEqual(o.a.b.c.d.e, 'c')
|
||||
})
|
||||
|
||||
it('should allow multiple custom separators', () => {
|
||||
const o = {}
|
||||
set(o, 'a/b;c/d.e', 'c', { separator: '/;.' })
|
||||
assert.strictEqual(o.a.b.c.d.e, 'c')
|
||||
})
|
||||
|
||||
it('should accept "delimiter" in place of "separator"', () => {
|
||||
const o = {}
|
||||
set(o, 'a/b;c/d.e', 'c', { delimiter: '/;.' })
|
||||
assert.strictEqual(o.a.b.c.d.e, 'c')
|
||||
})
|
||||
|
||||
it('should split any string elements of an array path', () => {
|
||||
const o = {}
|
||||
const key = Symbol('key-1')
|
||||
set(o, ['z.b', key, 'c'], 'd')
|
||||
assert.strictEqual(o.z.b[key].c, 'd')
|
||||
})
|
||||
|
||||
it('should not split any string elements of an array path if requested', () => {
|
||||
const o = {}
|
||||
const key = Symbol('key-1')
|
||||
set(o, ['a.b', key, 'c'], 'd', { elSplit: false })
|
||||
assert.strictEqual(o['a.b'][key].c, 'd')
|
||||
})
|
||||
})
|
||||
|
||||
describe('options.split', () => {
|
||||
it('should use a custom function to split on , not .', () => {
|
||||
const o = {}
|
||||
const splitfn = (path) => {
|
||||
return path.split(',')
|
||||
}
|
||||
set(o, 'a,b.c.d,e', 'c', { split: splitfn })
|
||||
assert.strictEqual(o.a['b.c.d'].e, 'c')
|
||||
})
|
||||
})
|
Loading…
Reference in New Issue