diff --git a/docs/content/commands/npm-pkg.md b/docs/content/commands/npm-pkg.md index 7ff0a4d97930f..78b13cf9e9a00 100644 --- a/docs/content/commands/npm-pkg.md +++ b/docs/content/commands/npm-pkg.md @@ -98,6 +98,13 @@ Returned values are always in **json** format. npm pkg set contributors[0].name='Foo' contributors[0].email='foo@bar.ca' ``` + You may also append items to the end of an array using the special + empty bracket notation: + + ```bash + npm pkg set contributors[].name='Foo' contributors[].name='Bar' + ``` + It's also possible to parse values as json prior to saving them to your `package.json` file, for example in order to set a `"private": true` property: diff --git a/lib/utils/queryable.js b/lib/utils/queryable.js index 173877e64817c..899acd5b2e592 100644 --- a/lib/utils/queryable.js +++ b/lib/utils/queryable.js @@ -1,14 +1,27 @@ const util = require('util') const _data = Symbol('data') const _delete = Symbol('delete') +const _append = Symbol('append') -const sqBracketsMatcher = str => str.match(/(.+)\[([^\]]+)\](.*)$/) +const sqBracketsMatcher = str => str.match(/(.+)\[([^\]]+)\]\.?(.*)$/) -const cleanLeadingDot = str => - str && str.startsWith('.') ? str.substr(1) : str +// replaces any occurence of an empty-brackets (e.g: []) with a special +// Symbol(append) to represent it, this is going to be useful for the setter +// method that will push values to the end of the array when finding these +const replaceAppendSymbols = str => { + const matchEmptyBracket = str.match(/^(.*)\[\]\.?(.*)$/) + + if (matchEmptyBracket) { + const [, pre, post] = matchEmptyBracket + return [...replaceAppendSymbols(pre), _append, post].filter(Boolean) + } + + return [str] +} const parseKeys = (key) => { const sqBracketItems = new Set() + sqBracketItems.add(_append) const parseSqBrackets = (str) => { const index = sqBracketsMatcher(str) @@ -21,7 +34,7 @@ const parseKeys = (key) => { // foo.bar[foo.bar] should split into { foo: { bar: { 'foo.bar': {} } } /* eslint-disable-next-line no-new-wrappers */ const foundKey = new String(index[2]) - const postSqBracketPortion = cleanLeadingDot(index[3]) + const postSqBracketPortion = index[3] // we keep track of items found during this step to make sure // we don't try to split-separate keys that were defined within @@ -43,7 +56,11 @@ const parseKeys = (key) => { ] } - return [str] + // at the end of parsing, any usage of the special empty-bracket syntax + // (e.g: foo.array[]) has not yet bene parsed, here we'll take care + // of parsing it and adding a special symbol to represent it in + // the resulting list of keys + return replaceAppendSymbols(str) } const res = [] @@ -79,6 +96,14 @@ const getter = ({ data, key }) => { let label = '' for (const k of keys) { + // empty-bracket-shortcut-syntax is not supported on getter + if (k === _append) { + throw Object.assign( + new Error('Empty brackets are not valid syntax for retrieving values.'), + { code: 'EINVALIDSYNTAX' } + ) + } + // extra logic to take into account printing array, along with its // special syntax in which using a dot-sep property name after an // arry will expand it's results, e.g: @@ -119,14 +144,27 @@ const setter = ({ data, key, value, force }) => { // ['foo', 'bar', 'baz'] -> { foo: { bar: { baz: {} } } const keys = parseKeys(key) const setKeys = (_data, _key) => { - // handles array indexes, making sure the new array is created if - // missing and properly casting the index to a number - const maybeIndex = Number(_key) - if (!Number.isNaN(maybeIndex)) { + // handles array indexes, converting valid integers to numbers, + // note that occurences of Symbol(append) will throw, + // so we just ignore these for now + let maybeIndex = Number.NaN + try { + maybeIndex = Number(_key) + } catch (err) {} + if (!Number.isNaN(maybeIndex)) _key = maybeIndex - if (!Object.keys(_data).length) - _data = [] - } + + // creates new array in case key is an index + // and the array obj is not yet defined + const keyIsAnArrayIndex = _key === maybeIndex || _key === _append + const dataHasNoItems = !Object.keys(_data).length + if (keyIsAnArrayIndex && dataHasNoItems) + _data = [] + + // the _append key is a special key that is used to represent + // the empty-bracket notation, e.g: arr[] -> arr[arr.length] + if (_key === _append) + _key = _data.length // retrieves the next data object to recursively iterate on, // throws if trying to override a literal value or add props to an array @@ -141,6 +179,7 @@ const setter = ({ data, key, value, force }) => { // appended to the resulting obj is not an array index, then it // should throw since we can't append arbitrary props to arrays const shouldNotAddPropsToArrays = + typeof keys[0] !== 'symbol' && Array.isArray(_data[_key]) && Number.isNaN(Number(keys[0])) diff --git a/test/lib/pkg.js b/test/lib/pkg.js index 42eb7c0cc5e9c..688df6859054a 100644 --- a/test/lib/pkg.js +++ b/test/lib/pkg.js @@ -291,6 +291,38 @@ t.test('set single field', t => { }) }) +t.test('push to array syntax', t => { + const json = { + name: 'foo', + version: '1.1.1', + keywords: [ + 'foo', + ], + } + npm.localPrefix = t.testdir({ + 'package.json': JSON.stringify(json), + }) + + pkg.exec(['set', 'keywords[]=bar', 'keywords[]=baz'], err => { + if (err) + throw err + + t.strictSame( + readPackageJson(), + { + ...json, + keywords: [ + 'foo', + 'bar', + 'baz', + ], + }, + 'should append to arrays using empty bracket syntax' + ) + t.end() + }) +}) + t.test('set multiple fields', t => { const json = { name: 'foo', diff --git a/test/lib/utils/queryable.js b/test/lib/utils/queryable.js index 2e66eeeb9e080..bb000ed9e0393 100644 --- a/test/lib/utils/queryable.js +++ b/test/lib/utils/queryable.js @@ -130,6 +130,14 @@ t.test('query', async t => { q.query('missing[bar]'), undefined, 'should return undefined also') + t.throws(() => q.query('lorem.dolor[]'), + { code: 'EINVALIDSYNTAX' }, + 'should throw if using empty brackets notation' + ) + t.throws(() => q.query('lorem.dolor[].sit[0]'), + { code: 'EINVALIDSYNTAX' }, + 'should throw if using nested empty brackets notation' + ) const qq = new Queryable({ foo: { @@ -602,6 +610,127 @@ t.test('set arrays', async t => { { code: 'EOVERRIDEVALUE' }, 'should throw an override error' ) + + qqq.set('arr[]', 'c') + t.strictSame( + qqq.toJSON(), + { + arr: [ + 'a', + 'b', + 'c', + ], + }, + 'should be able to append to array using empty bracket notation' + ) + + qqq.set('arr[].foo', 'foo') + t.strictSame( + qqq.toJSON(), + { + arr: [ + 'a', + 'b', + 'c', + { + foo: 'foo', + }, + ], + }, + 'should be able to append objects to array using empty bracket notation' + ) + + qqq.set('arr[].bar.name', 'BAR') + t.strictSame( + qqq.toJSON(), + { + arr: [ + 'a', + 'b', + 'c', + { + foo: 'foo', + }, + { + bar: { + name: 'BAR', + }, + }, + ], + }, + 'should be able to append more objects to array using empty brackets' + ) + + qqq.set('foo.bar.baz[].lorem.ipsum', 'something') + t.strictSame( + qqq.toJSON(), + { + arr: [ + 'a', + 'b', + 'c', + { + foo: 'foo', + }, + { + bar: { + name: 'BAR', + }, + }, + ], + foo: { + bar: { + baz: [ + { + lorem: { + ipsum: 'something', + }, + }, + ], + }, + }, + }, + 'should be able to append to array using empty brackets in nested objs' + ) + + qqq.set('foo.bar.baz[].lorem.array[]', 'new item') + t.strictSame( + qqq.toJSON(), + { + arr: [ + 'a', + 'b', + 'c', + { + foo: 'foo', + }, + { + bar: { + name: 'BAR', + }, + }, + ], + foo: { + bar: { + baz: [ + { + lorem: { + ipsum: 'something', + }, + }, + { + lorem: { + array: [ + 'new item', + ], + }, + }, + ], + }, + }, + }, + 'should be able to append to array using empty brackets in nested objs' + ) }) t.test('delete values', async t => {