Skip to content

Commit 33ee276

Browse files
committed
feat(pkg): add support to empty bracket syntax
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
1 parent efc4313 commit 33ee276

File tree

4 files changed

+244
-13
lines changed

4 files changed

+244
-13
lines changed

docs/content/commands/npm-pkg.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,13 @@ Returned values are always in **json** format.
9898
npm pkg set contributors[0].name='Foo' contributors[0].email='[email protected]'
9999
```
100100
101+
You may also append items to the end of an array using the special
102+
empty bracket notation:
103+
104+
```bash
105+
npm pkg set contributors[].name='Foo' contributors[].name='Bar'
106+
```
107+
101108
It's also possible to parse values as json prior to saving them to your
102109
`package.json` file, for example in order to set a `"private": true`
103110
property:

lib/utils/queryable.js

Lines changed: 57 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,27 @@
11
const util = require('util')
22
const _data = Symbol('data')
33
const _delete = Symbol('delete')
4+
const _append = Symbol('append')
45

5-
const sqBracketsMatcher = str => str.match(/(.+)\[([^\]]+)\](.*)$/)
6+
const sqBracketsMatcher = str => str.match(/(.+)\[([^\]]+)\]\.?(.*)$/)
67

7-
const cleanLeadingDot = str =>
8-
str && str.startsWith('.') ? str.substr(1) : str
8+
// replaces any occurence of an empty-brackets (e.g: []) with a special
9+
// Symbol(append) to represent it, this is going to be useful for the setter
10+
// method that will push values to the end of the array when finding these
11+
const replaceAppendSymbols = str => {
12+
const matchEmptyBracket = str.match(/^(.*)\[\]\.?(.*)$/)
13+
14+
if (matchEmptyBracket) {
15+
const [, pre, post] = matchEmptyBracket
16+
return [...replaceAppendSymbols(pre), _append, post].filter(Boolean)
17+
}
18+
19+
return [str]
20+
}
921

1022
const parseKeys = (key) => {
1123
const sqBracketItems = new Set()
24+
sqBracketItems.add(_append)
1225
const parseSqBrackets = (str) => {
1326
const index = sqBracketsMatcher(str)
1427

@@ -21,7 +34,7 @@ const parseKeys = (key) => {
2134
// foo.bar[foo.bar] should split into { foo: { bar: { 'foo.bar': {} } }
2235
/* eslint-disable-next-line no-new-wrappers */
2336
const foundKey = new String(index[2])
24-
const postSqBracketPortion = cleanLeadingDot(index[3])
37+
const postSqBracketPortion = index[3]
2538

2639
// we keep track of items found during this step to make sure
2740
// we don't try to split-separate keys that were defined within
@@ -43,7 +56,11 @@ const parseKeys = (key) => {
4356
]
4457
}
4558

46-
return [str]
59+
// at the end of parsing, any usage of the special empty-bracket syntax
60+
// (e.g: foo.array[]) has not yet bene parsed, here we'll take care
61+
// of parsing it and adding a special symbol to represent it in
62+
// the resulting list of keys
63+
return replaceAppendSymbols(str)
4764
}
4865

4966
const res = []
@@ -79,6 +96,14 @@ const getter = ({ data, key }) => {
7996
let label = ''
8097

8198
for (const k of keys) {
99+
// empty-bracket-shortcut-syntax is not supported on getter
100+
if (k === _append) {
101+
throw Object.assign(
102+
new Error('Empty brackets are not valid syntax for retrieving values.'),
103+
{ code: 'EINVALIDSYNTAX' }
104+
)
105+
}
106+
82107
// extra logic to take into account printing array, along with its
83108
// special syntax in which using a dot-sep property name after an
84109
// arry will expand it's results, e.g:
@@ -119,14 +144,33 @@ const setter = ({ data, key, value, force }) => {
119144
// ['foo', 'bar', 'baz'] -> { foo: { bar: { baz: {} } }
120145
const keys = parseKeys(key)
121146
const setKeys = (_data, _key) => {
122-
// handles array indexes, making sure the new array is created if
123-
// missing and properly casting the index to a number
124-
const maybeIndex = Number(_key)
125-
if (!Number.isNaN(maybeIndex)) {
147+
// handles array indexes, converting valid integers to numbers,
148+
// note that occurences of Symbol(append) will throw,
149+
// so we just ignore these for now
150+
let maybeIndex = Number.NaN
151+
try {
152+
maybeIndex = Number(_key)
153+
} catch (err) {}
154+
if (!Number.isNaN(maybeIndex))
126155
_key = maybeIndex
127-
if (!Object.keys(_data).length)
128-
_data = []
129-
}
156+
157+
// creates new array in case key is an index
158+
// and the array obj is not yet defined
159+
const keyIsAnArrayIndex = _key === maybeIndex || _key === _append
160+
const dataHasNoItems = !Object.keys(_data).length
161+
if (keyIsAnArrayIndex && dataHasNoItems)
162+
_data = []
163+
164+
// converting from array to an object is also possible, in case the
165+
// user is using force mode, we should also convert existing arrays
166+
// to an empty object if the current _data is an array
167+
if (force && Array.isArray(_data) && _key !== maybeIndex)
168+
_data = {}
169+
170+
// the _append key is a special key that is used to represent
171+
// the empty-bracket notation, e.g: arr[] -> arr[arr.length]
172+
if (_key === _append)
173+
_key = _data.length
130174

131175
// retrieves the next data object to recursively iterate on,
132176
// throws if trying to override a literal value or add props to an array
@@ -141,6 +185,7 @@ const setter = ({ data, key, value, force }) => {
141185
// appended to the resulting obj is not an array index, then it
142186
// should throw since we can't append arbitrary props to arrays
143187
const shouldNotAddPropsToArrays =
188+
typeof keys[0] !== 'symbol' &&
144189
Array.isArray(_data[_key]) &&
145190
Number.isNaN(Number(keys[0]))
146191

test/lib/pkg.js

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,38 @@ t.test('set single field', t => {
291291
})
292292
})
293293

294+
t.test('push to array syntax', t => {
295+
const json = {
296+
name: 'foo',
297+
version: '1.1.1',
298+
keywords: [
299+
'foo',
300+
],
301+
}
302+
npm.localPrefix = t.testdir({
303+
'package.json': JSON.stringify(json),
304+
})
305+
306+
pkg.exec(['set', 'keywords[]=bar', 'keywords[]=baz'], err => {
307+
if (err)
308+
throw err
309+
310+
t.strictSame(
311+
readPackageJson(),
312+
{
313+
...json,
314+
keywords: [
315+
'foo',
316+
'bar',
317+
'baz',
318+
],
319+
},
320+
'should append to arrays using empty bracket syntax'
321+
)
322+
t.end()
323+
})
324+
})
325+
294326
t.test('set multiple fields', t => {
295327
const json = {
296328
name: 'foo',

test/lib/utils/queryable.js

Lines changed: 148 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,14 @@ t.test('query', async t => {
130130
q.query('missing[bar]'),
131131
undefined,
132132
'should return undefined also')
133+
t.throws(() => q.query('lorem.dolor[]'),
134+
{ code: 'EINVALIDSYNTAX' },
135+
'should throw if using empty brackets notation'
136+
)
137+
t.throws(() => q.query('lorem.dolor[].sit[0]'),
138+
{ code: 'EINVALIDSYNTAX' },
139+
'should throw if using nested empty brackets notation'
140+
)
133141

134142
const qq = new Queryable({
135143
foo: {
@@ -597,11 +605,150 @@ t.test('set arrays', async t => {
597605
'b',
598606
],
599607
})
608+
609+
qqq.set('arr[]', 'c')
610+
t.strictSame(
611+
qqq.toJSON(),
612+
{
613+
arr: [
614+
'a',
615+
'b',
616+
'c',
617+
],
618+
},
619+
'should be able to append to array using empty bracket notation'
620+
)
621+
622+
qqq.set('arr[].foo', 'foo')
623+
t.strictSame(
624+
qqq.toJSON(),
625+
{
626+
arr: [
627+
'a',
628+
'b',
629+
'c',
630+
{
631+
foo: 'foo',
632+
},
633+
],
634+
},
635+
'should be able to append objects to array using empty bracket notation'
636+
)
637+
638+
qqq.set('arr[].bar.name', 'BAR')
639+
t.strictSame(
640+
qqq.toJSON(),
641+
{
642+
arr: [
643+
'a',
644+
'b',
645+
'c',
646+
{
647+
foo: 'foo',
648+
},
649+
{
650+
bar: {
651+
name: 'BAR',
652+
},
653+
},
654+
],
655+
},
656+
'should be able to append more objects to array using empty brackets'
657+
)
658+
659+
qqq.set('foo.bar.baz[].lorem.ipsum', 'something')
660+
t.strictSame(
661+
qqq.toJSON(),
662+
{
663+
arr: [
664+
'a',
665+
'b',
666+
'c',
667+
{
668+
foo: 'foo',
669+
},
670+
{
671+
bar: {
672+
name: 'BAR',
673+
},
674+
},
675+
],
676+
foo: {
677+
bar: {
678+
baz: [
679+
{
680+
lorem: {
681+
ipsum: 'something',
682+
},
683+
},
684+
],
685+
},
686+
},
687+
},
688+
'should be able to append to array using empty brackets in nested objs'
689+
)
690+
691+
qqq.set('foo.bar.baz[].lorem.array[]', 'new item')
692+
t.strictSame(
693+
qqq.toJSON(),
694+
{
695+
arr: [
696+
'a',
697+
'b',
698+
'c',
699+
{
700+
foo: 'foo',
701+
},
702+
{
703+
bar: {
704+
name: 'BAR',
705+
},
706+
},
707+
],
708+
foo: {
709+
bar: {
710+
baz: [
711+
{
712+
lorem: {
713+
ipsum: 'something',
714+
},
715+
},
716+
{
717+
lorem: {
718+
array: [
719+
'new item',
720+
],
721+
},
722+
},
723+
],
724+
},
725+
},
726+
},
727+
'should be able to append to array using empty brackets in nested objs'
728+
)
729+
730+
const qqqq = new Queryable({
731+
arr: [
732+
'a',
733+
'b',
734+
],
735+
})
600736
t.throws(
601-
() => qqq.set('arr.foo', 'foo'),
737+
() => qqqq.set('arr.foo', 'foo'),
602738
{ code: 'EOVERRIDEVALUE' },
603739
'should throw an override error'
604740
)
741+
742+
qqqq.set('arr.foo', 'foo', { force: true })
743+
t.strictSame(
744+
qqqq.toJSON(),
745+
{
746+
arr: {
747+
foo: 'foo',
748+
},
749+
},
750+
'should be able to override arrays with objects when using force=true'
751+
)
605752
})
606753

607754
t.test('delete values', async t => {

0 commit comments

Comments
 (0)