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..e10eba3b5f092 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 been 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,13 +144,39 @@ 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 && !Array.isArray(_data)) + _data = [] + + // converting from array to an object is also possible, in case the + // user is using force mode, we should also convert existing arrays + // to an empty object if the current _data is an array + if (force && Array.isArray(_data) && !keyIsAnArrayIndex) + _data = { ..._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) { + if (!Array.isArray(_data)) { + throw Object.assign( + new Error(`Can't use append syntax in non-Array element`), + { code: 'ENOAPPEND' } + ) + } + _key = _data.length } // retrieves the next data object to recursively iterate on, @@ -141,20 +192,30 @@ 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])) const overrideError = haveContents && - (shouldNotOverrideLiteralValue || shouldNotAddPropsToArrays) - + shouldNotOverrideLiteralValue if (overrideError) { throw Object.assign( - new Error(`Property ${key} already has a value in place.`), + new Error(`Property ${_key} already exists and is not an Array or Object.`), { code: 'EOVERRIDEVALUE' } ) } + const addPropsToArrayError = + haveContents && + shouldNotAddPropsToArrays + if (addPropsToArrayError) { + throw Object.assign( + new Error(`Can't add property ${key} to an Array.`), + { code: 'ENOADDPROP' } + ) + } + return typeof _data[_key] === 'object' ? _data[_key] || {} : {} } 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..bde3ea66238f2 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: { @@ -597,11 +605,263 @@ t.test('set arrays', async t => { 'b', ], }) + + 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' + ) + + const qqqq = new Queryable({ + arr: [ + 'a', + 'b', + ], + }) t.throws( - () => qqq.set('arr.foo', 'foo'), - { code: 'EOVERRIDEVALUE' }, + () => qqqq.set('arr.foo', 'foo'), + { code: 'ENOADDPROP' }, 'should throw an override error' ) + + qqqq.set('arr.foo', 'foo', { force: true }) + t.strictSame( + qqqq.toJSON(), + { + arr: { + 0: 'a', + 1: 'b', + foo: 'foo', + }, + }, + 'should be able to override arrays with objects when using force=true' + ) + + qqqq.set('bar[]', 'item', { force: true }) + t.strictSame( + qqqq.toJSON(), + { + arr: { + 0: 'a', + 1: 'b', + foo: 'foo', + }, + bar: [ + 'item', + ], + }, + 'should be able to create new array with item when using force=true' + ) + + qqqq.set('bar[]', 'something else', { force: true }) + t.strictSame( + qqqq.toJSON(), + { + arr: { + 0: 'a', + 1: 'b', + foo: 'foo', + }, + bar: [ + 'item', + 'something else', + ], + }, + 'should be able to append items to arrays when using force=true' + ) + + const qqqqq = new Queryable({ + arr: [ + null, + ], + }) + qqqqq.set('arr[]', 'b') + t.strictSame( + qqqqq.toJSON(), + { + arr: [ + null, + 'b', + ], + }, + 'should be able to append items with empty items' + ) + qqqqq.set('arr[0]', 'a') + t.strictSame( + qqqqq.toJSON(), + { + arr: [ + 'a', + 'b', + ], + }, + 'should be able to replace empty items in an array' + ) + qqqqq.set('lorem.ipsum', 3) + t.strictSame( + qqqqq.toJSON(), + { + arr: [ + 'a', + 'b', + ], + lorem: { + ipsum: 3, + }, + }, + 'should be able to replace empty items in an array' + ) + t.throws( + () => qqqqq.set('lorem[]', 4), + { code: 'ENOAPPEND' }, + 'should throw error if using empty square bracket in an non-array item' + ) + qqqqq.set('lorem[0]', 3) + t.strictSame( + qqqqq.toJSON(), + { + arr: [ + 'a', + 'b', + ], + lorem: { + 0: 3, + ipsum: 3, + }, + }, + 'should be able add indexes as props when finding an object' + ) + qqqqq.set('lorem.1', 3) + t.strictSame( + qqqqq.toJSON(), + { + arr: [ + 'a', + 'b', + ], + lorem: { + 0: 3, + 1: 3, + ipsum: 3, + }, + }, + 'should be able add numeric props to an obj' + ) }) t.test('delete values', async t => {