From d64e27ba16e631a2982357da3df8a45f68494305 Mon Sep 17 00:00:00 2001 From: Richie Bendall Date: Fri, 21 Jan 2022 22:38:07 +1300 Subject: [PATCH] Support array indexes (#82) --- index.d.ts | 17 +++-- index.js | 162 ++++++++++++++++++++++++++++++++++++++++++----- readme.md | 11 +++- test.js | 182 ++++++++++++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 348 insertions(+), 24 deletions(-) diff --git a/index.d.ts b/index.d.ts index 7fbb14b..780daab 100644 --- a/index.d.ts +++ b/index.d.ts @@ -4,7 +4,7 @@ declare const dotProp: { /** Get the value of the property at the given path. - @param object - Object to get the `path` value. + @param object - Object or array to get the `path` value. @param path - Path of the property in the object, using `.` to separate each nested key. Use `\\.` if you have a `.` in the key. @param defaultValue - Default value. @@ -23,18 +23,21 @@ declare const dotProp: { dotProp.get({foo: {'dot.dot': 'unicorn'}}, 'foo.dot\\.dot'); //=> 'unicorn' + + dotProp.get({foo: [{bar: 'unicorn'}]}, 'foo[0].bar'); + //=> 'unicorn' ``` */ get: ( object: ObjectType, path: PathType, defaultValue?: DefaultValue - ) => ObjectType extends Record ? (Get extends unknown ? DefaultValue : Get) : undefined; // TODO: When adding array index support (https://github.com/sindresorhus/dot-prop/issues/71) add ` | unknown[]` after `Record` + ) => ObjectType extends Record | unknown[] ? (Get extends unknown ? DefaultValue : Get) : undefined; /** Set the property at the given path to the given value. - @param object - Object to set the `path` value. + @param object - Object or array to set the `path` value. @param path - Path of the property in the object, using `.` to separate each nested key. Use `\\.` if you have a `.` in the key. @param value - Value to set at `path`. @returns The object. @@ -55,6 +58,10 @@ declare const dotProp: { dotProp.set(object, 'foo.baz', 'x'); console.log(object); //=> {foo: {bar: 'b', baz: 'x'}} + + dotProp.set(object, 'foo.biz[0]', 'a'); + console.log(object); + //=> {foo: {bar: 'b', baz: 'x', biz: ['a']}} ``` */ set: ( @@ -66,7 +73,7 @@ declare const dotProp: { /** Check whether the property at the given path exists. - @param object - Object to test the `path` value. + @param object - Object or array to test the `path` value. @param path - Path of the property in the object, using `.` to separate each nested key. Use `\\.` if you have a `.` in the key. @example @@ -82,7 +89,7 @@ declare const dotProp: { /** Delete the property at the given path. - @param object - Object to delete the `path` value. + @param object - Object or array to delete the `path` value. @param path - Path of the property in the object, using `.` to separate each nested key. Use `\\.` if you have a `.` in the key. @returns A boolean of whether the property existed before being deleted. diff --git a/index.js b/index.js index 3fe9b42..307430e 100644 --- a/index.js +++ b/index.js @@ -7,30 +7,156 @@ const disallowedKeys = new Set([ 'constructor' ]); -const isValidPath = pathSegments => !pathSegments.some(segment => disallowedKeys.has(segment)); +const digits = new Set('0123456789'); function getPathSegments(path) { - const pathArray = path.split('.'); const parts = []; + let currentSegment = ''; + let currentPart = 'start'; + let isIgnoring = false; + + for (const character of path) { + switch (character) { + case '\\': + if (currentPart === 'index') { + throw new Error('Invalid character in an index'); + } + + if (currentPart === 'indexEnd') { + throw new Error('Invalid character after an index'); + } + + if (isIgnoring) { + currentSegment += character; + } + + currentPart = 'property'; + isIgnoring = !isIgnoring; + break; - for (let i = 0; i < pathArray.length; i++) { - let p = pathArray[i]; + case '.': + if (currentPart === 'index') { + throw new Error('Invalid character in an index'); + } + + if (currentPart === 'indexEnd') { + currentPart = 'property'; + break; + } + + if (isIgnoring) { + isIgnoring = false; + currentSegment += character; + break; + } + + if (disallowedKeys.has(currentSegment)) { + return []; + } + + parts.push(currentSegment); + currentSegment = ''; + currentPart = 'property'; + break; + + case '[': + if (currentPart === 'index') { + throw new Error('Invalid character in an index'); + } + + if (currentPart === 'indexEnd') { + currentPart = 'index'; + break; + } + + if (isIgnoring) { + isIgnoring = false; + currentSegment += character; + break; + } + + if (currentPart === 'property') { + if (disallowedKeys.has(currentSegment)) { + return []; + } + + parts.push(currentSegment); + currentSegment = ''; + } + + currentPart = 'index'; + break; - while (p[p.length - 1] === '\\' && pathArray[i + 1] !== undefined) { - p = p.slice(0, -1) + '.'; - p += pathArray[++i]; + case ']': + if (currentPart === 'index') { + parts.push(Number.parseInt(currentSegment, 10)); + currentSegment = ''; + currentPart = 'indexEnd'; + break; + } + + if (currentPart === 'indexEnd') { + throw new Error('Invalid character after an index'); + } + + // Falls through + + default: + if (currentPart === 'index' && !digits.has(character)) { + throw new Error('Invalid character in an index'); + } + + if (currentPart === 'indexEnd') { + throw new Error('Invalid character after an index'); + } + + if (currentPart === 'start') { + currentPart = 'property'; + } + + if (isIgnoring) { + isIgnoring = false; + currentSegment += '\\'; + } + + currentSegment += character; } + } - parts.push(p); + if (isIgnoring) { + currentSegment += '\\'; } - if (!isValidPath(parts)) { - return []; + if (currentPart === 'property') { + if (disallowedKeys.has(currentSegment)) { + return []; + } + + parts.push(currentSegment); + } else if (currentPart === 'index') { + throw new Error('Index was not closed'); + } else if (currentPart === 'start') { + parts.push(''); } return parts; } +function isStringIndex(object, key) { + if (typeof key !== 'number' && Array.isArray(object)) { + const index = Number.parseInt(key, 10); + return Number.isInteger(index) && object[index] === object[key]; + } + + return false; +} + +function assertNotStringIndex(object, key) { + if (isStringIndex(object, key)) { + throw new Error('Cannot use string index'); + } +} + module.exports = { get(object, path, value) { if (!isObject(object) || typeof path !== 'string') { @@ -43,12 +169,18 @@ module.exports = { } for (let i = 0; i < pathArray.length; i++) { - object = object[pathArray[i]]; + const key = pathArray[i]; + + if (isStringIndex(object, key)) { + object = i === pathArray.length - 1 ? undefined : null; + } else { + object = object[key]; + } if (object === undefined || object === null) { // `object` is either `undefined` or `null` so we want to stop the loop, and // if this is not the last bit of the path, and - // if it did't return `undefined` + // if it didn't return `undefined` // it would return `null` if `object` is `null` // but we want `get({foo: null}, 'foo.bar')` to equal `undefined`, or the supplied value, not `null` if (i !== pathArray.length - 1) { @@ -72,9 +204,10 @@ module.exports = { for (let i = 0; i < pathArray.length; i++) { const p = pathArray[i]; + assertNotStringIndex(object, p); if (!isObject(object[p])) { - object[p] = {}; + object[p] = Number.isInteger(pathArray[i + 1]) ? [] : {}; } if (i === pathArray.length - 1) { @@ -96,6 +229,7 @@ module.exports = { for (let i = 0; i < pathArray.length; i++) { const p = pathArray[i]; + assertNotStringIndex(object, p); if (i === pathArray.length - 1) { delete object[p]; @@ -123,7 +257,7 @@ module.exports = { // eslint-disable-next-line unicorn/no-for-loop for (let i = 0; i < pathArray.length; i++) { if (isObject(object)) { - if (!(pathArray[i] in object)) { + if (!(pathArray[i] in object && !isStringIndex(object, pathArray[i]))) { return false; } diff --git a/readme.md b/readme.md index 3556493..e63f301 100644 --- a/readme.md +++ b/readme.md @@ -26,6 +26,9 @@ dotProp.get({foo: {bar: 'a'}}, 'foo.notDefined.deep', 'default value'); dotProp.get({foo: {'dot.dot': 'unicorn'}}, 'foo.dot\\.dot'); //=> 'unicorn' +dotProp.get({foo: [{bar: 'unicorn'}]}, 'foo[0].bar'); +//=> 'unicorn' + // Setter const object = {foo: {bar: 'a'}}; dotProp.set(object, 'foo.bar', 'b'); @@ -40,6 +43,10 @@ dotProp.set(object, 'foo.baz', 'x'); console.log(object); //=> {foo: {bar: 'b', baz: 'x'}} +dotProp.set(object, 'foo.biz.0', 'a'); +console.log(object); +//=> {foo: {bar: 'b', baz: 'x', biz: ['a']}} + // Has dotProp.has({foo: {bar: 'unicorn'}}, 'foo.bar'); //=> true @@ -84,9 +91,9 @@ Returns a boolean of whether the property existed before being deleted. #### object -Type: `object` +Type: `object | array` -Object to get, set, or delete the `path` value. +Object or array to get, set, or delete the `path` value. You are allowed to pass in `undefined` as the object to the `get` and `has` functions. diff --git a/test.js b/test.js index a43c89e..4bb6fed 100644 --- a/test.js +++ b/test.js @@ -19,12 +19,17 @@ test('get', t => { t.is(dotProp.get({foo: {}}, 'foo.fake', 'some value'), 'some value'); t.true(dotProp.get({'\\': true}, '\\')); t.true(dotProp.get({'\\foo': true}, '\\foo')); + t.true(dotProp.get({'\\foo': true}, '\\\\foo')); + t.true(dotProp.get({'foo\\': true}, 'foo\\\\')); t.true(dotProp.get({'bar\\': true}, 'bar\\')); t.true(dotProp.get({'foo\\bar': true}, 'foo\\bar')); - t.true(dotProp.get({'\\.foo': true}, '\\\\.foo')); - t.true(dotProp.get({'bar\\.': true}, 'bar\\\\.')); - t.true(dotProp.get({'foo\\.bar': true}, 'foo\\\\.bar')); + t.true(dotProp.get({'\\': {foo: true}}, '\\\\.foo')); + t.true(dotProp.get({'bar\\.': true}, 'bar\\\\\\.')); + t.true(dotProp.get({'foo\\': { + bar: true + }}, 'foo\\\\.bar')); t.is(dotProp.get({foo: 1}, 'foo.bar'), undefined); + t.true(dotProp.get({'foo\\': true}, 'foo\\')); const fixture2 = {}; Object.defineProperty(fixture2, 'foo', { @@ -56,6 +61,103 @@ test('get', t => { F4Class.prototype.foo = 1; const f4 = new F4Class(); t.is(dotProp.get(f4, 'foo'), 1); // #46 + + t.true(dotProp.get({'': {'': {'': true}}}, '..')); + t.true(dotProp.get({'': {'': true}}, '.')); +}); + +test('get with array indexes', t => { + t.true(dotProp.get([true, false, false], '[0]')); + t.true(dotProp.get([[false, true, false], false, false], '[0][1]')); + t.true(dotProp.get([{foo: [true]}], '[0].foo[0]')); + t.true(dotProp.get({foo: [0, {bar: true}]}, 'foo[1].bar')); + + t.false(dotProp.get(['a', 'b', 'c'], '3', false)); + t.false(dotProp.get([{foo: [1]}], '[0].bar[0]', false)); + t.false(dotProp.get([{foo: [1]}], '[0].foo[1]', false)); + t.false(dotProp.get({foo: [0, {bar: 2}]}, 'foo[0].bar', false)); + t.false(dotProp.get({foo: [0, {bar: 2}]}, 'foo[2].bar', false)); + t.false(dotProp.get({foo: [0, {bar: 2}]}, 'foo[1].biz', false)); + t.false(dotProp.get({foo: [0, {bar: 2}]}, 'bar[0].bar', false)); + t.true(dotProp.get({ + bar: { + '[0]': true + } + }, 'bar.\\[0]')); + t.true(dotProp.get({ + bar: { + '': [true] + } + }, 'bar.[0]')); + t.throws(() => dotProp.get({ + 'foo[5[': true + }, 'foo[5['), { + message: 'Invalid character in an index' + }); + t.throws(() => dotProp.get({ + 'foo[5': { + bar: true + } + }, 'foo[5.bar'), { + message: 'Invalid character in an index' + }); + t.true(dotProp.get({ + 'foo[5]': { + bar: true + } + }, 'foo\\[5].bar')); + t.throws(() => dotProp.get({ + 'foo[5\\]': { + bar: true + } + }, 'foo[5\\].bar'), { + message: 'Invalid character in an index' + }); + t.throws(() => dotProp.get({ + 'foo[5': true + }, 'foo[5'), { + message: 'Index was not closed' + }); + t.throws(() => dotProp.get({ + 'foo[bar]': true + }, 'foo[bar]'), { + message: 'Invalid character in an index' + }); + t.false(dotProp.get({}, 'constructor[0]', false)); + t.throws(() => dotProp.get({}, 'foo[constructor]', false), { + message: 'Invalid character in an index' + }); + + t.false(dotProp.get([], 'foo[0].bar', false)); + t.true(dotProp.get({foo: [{bar: true}]}, 'foo[0].bar')); + t.false(dotProp.get({foo: ['bar']}, 'foo[1]', false)); + + t.false(dotProp.get([true], '0', false)); + + t.false(dotProp.get({foo: [true]}, 'foo.0', false)); + t.true(dotProp.get({foo: { + 0: true + }}, 'foo.0')); + + t.true(dotProp.get([{ + '[1]': true + }, false, false], '[0].\\[1]')); + + t.true(dotProp.get({foo: {'[0]': true}}, 'foo.\\[0]')); + t.throws(() => dotProp.get({foo: {'[0]': true}}, 'foo.[0\\]'), { + message: 'Invalid character in an index' + }); + t.true(dotProp.get({foo: {'\\': [true]}}, 'foo.\\\\[0]')); + t.throws(() => dotProp.get({foo: {'[0]': true}}, 'foo.[0\\]'), { + message: 'Invalid character in an index' + }); + + t.throws(() => dotProp.get({'foo[0': {'9]': true}}, 'foo[0.9]'), { + message: 'Invalid character in an index' + }); + t.throws(() => dotProp.get({'foo[-1]': true}, 'foo[-1]'), { + message: 'Invalid character in an index' + }); }); test('set', t => { @@ -117,6 +219,32 @@ test('set', t => { const output4 = dotProp.set(fixture4, 'foo.bar', 2); t.is(fixture4, 'noobject'); t.is(output4, fixture4); + + const fixture5 = []; + + dotProp.set(fixture5, '[1]', true); + t.is(fixture5[1], true); + + dotProp.set(fixture5, '[0].foo[0]', true); + t.is(fixture5[0].foo[0], true); + + t.throws(() => dotProp.set(fixture5, '1', true), { + message: 'Cannot use string index' + }); + + t.throws(() => dotProp.set(fixture5, '0.foo.0', true), { + message: 'Cannot use string index' + }); + + const fixture6 = {}; + + dotProp.set(fixture6, 'foo[0].bar', true); + t.true(fixture6.foo[0].bar); + t.deepEqual(fixture6, { + foo: [{ + bar: true + }] + }); }); test('delete', t => { @@ -180,6 +308,39 @@ test('delete', t => { const fixture3 = {foo: null}; t.false(dotProp.delete(fixture3, 'foo.bar')); t.deepEqual(fixture3, {foo: null}); + + const fixture4 = [{ + top: { + dog: 'sindre' + } + }]; + + t.throws(() => dotProp.delete(fixture4, '0.top.dog'), { + message: 'Cannot use string index' + }); + t.true(dotProp.delete(fixture4, '[0].top.dog')); + t.deepEqual(fixture4, [{top: {}}]); + + const fixture5 = { + foo: [{ + bar: ['foo', 'bar'] + }] + }; + + dotProp.delete(fixture5, 'foo[0].bar[0]'); + + const fixtureArray = []; + fixtureArray[1] = 'bar'; + + t.deepEqual(fixture5, { + foo: [{ + bar: fixtureArray + }] + }); + + const fixture6 = {}; + + dotProp.set(fixture6, 'foo.bar.0', 'fizz'); }); test('has', t => { @@ -205,6 +366,21 @@ test('has', t => { t.true(dotProp.has({'foo.baz': {bar: true}}, 'foo\\.baz.bar')); t.true(dotProp.has({'fo.ob.az': {bar: true}}, 'fo\\.ob\\.az.bar')); t.false(dotProp.has(undefined, 'fo\\.ob\\.az.bar')); + + t.false(dotProp.has({ + foo: [{bar: ['bar', 'bizz']}] + }, 'foo[0].bar.1')); + t.false(dotProp.has({ + foo: [{bar: ['bar', 'bizz']}] + }, 'foo[0].bar.2')); + t.false(dotProp.has({ + foo: [{bar: ['bar', 'bizz']}] + }, 'foo[1].bar.1')); + t.true(dotProp.has({ + foo: [{bar: { + 1: 'bar' + }}] + }, 'foo[0].bar.1')); }); test('prevent setting/getting `__proto__`', t => {