Skip to content

Commit

Permalink
feat(pkg): add support to empty bracket syntax
Browse files Browse the repository at this point in the history
Adds ability to using empty bracket syntax as a shortcut to appending
items to the end of an array when using `npm pkg set`, e.g:

npm pkg set keywords[]=foo

Relates to: npm/rfcs#402

PR-URL: #3539
Credit: @ruyadorno
Close: #3539
Reviewed-by: @darcyclarke, @ljharb
  • Loading branch information
ruyadorno committed Jul 15, 2021
1 parent 98905ae commit 8371d7d
Show file tree
Hide file tree
Showing 4 changed files with 376 additions and 16 deletions.
7 changes: 7 additions & 0 deletions docs/content/commands/npm-pkg.md
Expand Up @@ -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:
Expand Down
89 changes: 75 additions & 14 deletions 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)

Expand All @@ -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
Expand All @@ -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 = []
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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,
Expand All @@ -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] || {} : {}
}

Expand Down
32 changes: 32 additions & 0 deletions test/lib/pkg.js
Expand Up @@ -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',
Expand Down

0 comments on commit 8371d7d

Please sign in to comment.