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
  • Loading branch information
ruyadorno committed Jul 13, 2021
1 parent efc4313 commit 76ac9ae
Show file tree
Hide file tree
Showing 4 changed files with 219 additions and 12 deletions.
7 changes: 7 additions & 0 deletions docs/content/commands/npm-pkg.md
Original file line number Diff line number Diff line change
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
63 changes: 51 additions & 12 deletions lib/utils/queryable.js
Original file line number Diff line number Diff line change
@@ -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 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 = []
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,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
Expand All @@ -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]))

Expand Down
32 changes: 32 additions & 0 deletions test/lib/pkg.js
Original file line number Diff line number Diff line change
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
129 changes: 129 additions & 0 deletions test/lib/utils/queryable.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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 => {
Expand Down

0 comments on commit 76ac9ae

Please sign in to comment.