diff --git a/src/utils/access.ts b/src/utils/access.ts new file mode 100644 index 0000000..a201876 --- /dev/null +++ b/src/utils/access.ts @@ -0,0 +1,93 @@ +import has from './has' +import { isRecordLike, isScalar } from '@/types' + +const extractIntOrNaN = (value: string): number => { + const numeric = parseInt(value) + + return numeric.toString() === value ? numeric : NaN +} + +const extractPath = (field: string): string[] => { + const path = [] as string[] + + field.split('.').forEach(key => { + if (/(.*)\[(\d+)]$/.test(key)) { + path.push(...key.substr(0, key.length - 1).split('[').filter(k => k.length)) + } else { + path.push(key) + } + }) + + return path +} + +const unsetInRecord = (record: Record, prop: string): Record => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { [prop]: _, ...copy } = record + + return copy +} + +export function get (state: unknown, fieldOrPath: string|string[]): unknown { + const path = typeof fieldOrPath === 'string' ? extractPath(fieldOrPath) : fieldOrPath + + if (isScalar(state) || path.length === 0) { + return undefined + } + + const key = path.shift() as string + const index = extractIntOrNaN(key) + + if (!isNaN(index)) { + if (Array.isArray(state) && index >= 0 && index < state.length) { + return path.length === 0 ? state[index] : get(state[index], path) + } + + return undefined + } + + if (has(state as Record, key)) { + const values = state as Record + + return path.length === 0 ? values[key] : get(values[key], path) + } + + return undefined +} + +export function unset (state: unknown, fieldOrPath: string|string[]): unknown { + if (!isRecordLike(state)) { + return state + } + + const path = typeof fieldOrPath === 'string' ? extractPath(fieldOrPath) : fieldOrPath + + if (path.length === 0) { + return state + } + + const key = path.shift() as string + const index = extractIntOrNaN(key) + + if (!isNaN(index) && Array.isArray(state) && index >= 0 && index < state.length) { + const values = (state as unknown[]).slice() + + if (path.length === 0) { + values.splice(index, 1) + } else { + values[index] = unset(values[index], path) + } + + return values + } + + if (has(state as Record, key)) { + const values = state as Record + + return path.length === 0 + ? unsetInRecord(values, key) + : { ...values, [key]: unset(values[key], path) } + } + + return state +} diff --git a/src/utils/index.ts b/src/utils/index.ts index 120562d..0008ee5 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,6 +1,7 @@ export { default as clone } from './clone' export { default as has } from './has' export { default as merge } from './merge' +export { get, unset } from './access' export { default as regexForFormat } from './regexForFormat' export { default as shallowEquals } from './shallowEquals' export { default as snakeToCamel } from './snakeToCamel' diff --git a/test/unit/utils/access.test.js b/test/unit/utils/access.test.js new file mode 100644 index 0000000..bded0c2 --- /dev/null +++ b/test/unit/utils/access.test.js @@ -0,0 +1,72 @@ +import { get, unset } from '@/utils/access' + +class Sample { + constructor() { + this.fieldA = 'fieldA' + this.fieldB = 'fieldB' + } + + doSomething () {} +} + +describe('access', () => { + describe('get', () => { + test.each([ + [{ a: { b: { c: 1 } } }, 'a', { b: { c: 1 } }], + [{ a: { b: { c: 1 } }, d: 1 }, 'a', { b: { c: 1 } }], + [{ a: { b: { c: 1 } } }, 'a.b.c', 1], + [{ a: { b: [1] } }, 'a.b[0]', 1], + [{ a: { b: [1, 2, 3] } }, 'a.b[0]', 1], + [{ a: { b: [1, 2, 3] } }, 'a.b[1]', 2], + [{ a: { b: [1, 2, 3] } }, 'a.b[2]', 3], + [{ a: { b: [1, 2, 3] } }, 'a.b[3]', undefined], + [{ a: { b: [{ c: 1 }, 2, 3] } }, 'a.b[0].c', 1], + [{ a: { b: [{ c: 1 }, 2, 3] } }, 'a.b[1].c', undefined], + [[{ c: 1 }, 2, 3], '[0].c', 1], + [[{ c: 2 }, 2, 3], '[0].c', 2], + [new Sample(), 'fieldA', 'fieldA'], + ])('gets by path', (record, path, expected) => { + expect(get(record, path)).toEqual(expected) + }) + }) + + describe('unset', () => { + test.each([ + [{ a: { b: { c: 1 } } }, 'a', {}], + [{ a: { b: { c: 1 } }, d: 1 }, 'a', { d: 1 }], + [{ a: { b: { c: 1 } } }, 'a.b.c', { a: { b: {} } }], + [{ a: { b: [1] } }, 'a.b[0]', { a: { b: [] } }], + [{ a: { b: [1, 2, 3] } }, 'a.b[0]', { a: { b: [2, 3] } }], + [{ a: { b: [1, 2, 3] } }, 'a.b[1]', { a: { b: [1, 3] } }], + [{ a: { b: [1, 2, 3] } }, 'a.b[2]', { a: { b: [1, 2] } }], + [{ a: { b: [1, 2, 3] } }, 'a.b[3]', { a: { b: [1, 2, 3] } }], + [{ a: { b: [{ c: 1 }, 2, 3] } }, 'a.b[0].c', { a: { b: [{}, 2, 3] } }], + [{ a: { b: [{ c: 1 }, 2, 3] } }, 'a.b[1].c', { a: { b: [{ c: 1 }, 2, 3] } }], + [[{ c: 1 }, 2, 3], '[0].c', [{}, 2, 3]], + ])('unsets by path', (record, path, expected) => { + const processed = unset(record, path) + + expect(processed).toEqual(expected) + expect(processed === record).toBeFalsy() + }) + + test.each` + type | scalar + ${'booleans'} | ${false} + ${'numbers'} | ${123} + ${'strings'} | ${'hello'} + ${'symbols'} | ${Symbol(123)} + ${'undefined'} | ${undefined} + ${'null'} | ${null} + `('not unsets for $type', ({ scalar }) => { + expect(unset(scalar, 'key')).toStrictEqual(scalar) + }) + + test('not unsets for class instance', () => { + const sample = new Sample() + const processed = unset(sample, 'fieldA') + + expect(processed.fieldA).toStrictEqual('fieldA') + }) + }) +})