diff --git a/.eslintrc.yml b/.eslintrc.yml new file mode 100644 index 0000000..d3af4fa --- /dev/null +++ b/.eslintrc.yml @@ -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"] diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2ccbe46 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/node_modules/ diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..8663459 --- /dev/null +++ b/.npmignore @@ -0,0 +1,8 @@ +tests/ +test/ +*.test.js +testing/ +node_modules +eslintrc* +examples/ +example/ diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..b6f27f1 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +engine-strict=true diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..926d143 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,6 @@ +{ + "editor.tokenColorCustomizations": { + "comments": "", + "textMateRules": [] + } +} \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..a088390 --- /dev/null +++ b/package.json @@ -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" + } +} \ No newline at end of file diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..f30a879 --- /dev/null +++ b/readme.md @@ -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 + + + + diff --git a/src/obj-nested-prop.js b/src/obj-nested-prop.js new file mode 100644 index 0000000..e828ff7 --- /dev/null +++ b/src/obj-nested-prop.js @@ -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(`(? 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 } diff --git a/test/obj-nested-prop.test.js b/test/obj-nested-prop.test.js new file mode 100644 index 0000000..58ab6ce --- /dev/null +++ b/test/obj-nested-prop.test.js @@ -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') + }) +})