Repository: sindresorhus/query-string
Pull request #276 (2020-09-25 15:57:43)

Add support for `arrayFormat: 'bracket-separator'`
The idea behind this format is to gain the advantages of short query strings that you have with `comma` and `separator` formats while marking a field as explicitly an array via a postfixed `[]` syntax.

```js
import queryString from 'query-string'

queryString.stringify({ foo: ['one'] }, { arrayFormat: 'bracket-separator' })
// => foo[]=one

queryString.stringify({ foo: ['one', 'two', '', 'four' ] }, { arrayFormat: 'bracket-separator' })
// => foo[]=one,two,,four

queryString.stringify({ foo: ['one', 'two', '', 'four'] }, { arrayFormat: 'bracket-separator', arrayFormatSeparator: '|' })
// => foo[]=one|two||four

queryString.parse('?foo[]=one', { arrayFormat: 'bracket-separator' })
// => {foo: ['one']

queryString.parse('?foo[]=one,two,,four', { arrayFormat: 'bracket-separator' })
// => {foo: ['one', 'two', '', 'four']}

queryString.parse('?foo[]=one|two||four', { arrayFormat: 'bracket-separator', arrayFormatSeparator: '|' })
// => {foo: ['one', 'two', '', 'four']}
```

There are still are 2 question in regards to implementation:
#### What should happen for `{foo: []}`? Currently, it would drop the parameter from the stringified query which is the same as `{foo: null}`

This is undesirable personally. I feel like `{foo: []}` should produce `?foo[]` unless I specify a flag to drop it.
This would be a deviation from the `?bar&biz` that non-arrays get the treatment of when they are null (no trailing `=`), but I feel like it makes sense as we have enough information to say, explicitly, that this is an array. Whereas if it were `{foo: null}` we don't know, explicitly, that this is an array, so it would produce `?foo` which would mean that it would roundtrip correctly.
However, the only way to produce this would be to change how the system is using Array.prototype.reduce as the reducer function is not ever invoked when the array is empty.

#### What should happen for `{foo: ['one', null, 'three']}`. Currently it will drop the null and produce `foo[]=one,three`

~I haven't a strong opinion on this as it matches the functionality of `separator`. The only deviation from the `separator` functionality in regards to value dropping is that `bracket-separator` _does not_ drop empty strings.~

**[Edit]**: This has been changed to follow the `skipNull` and `skipEmptyString` options. As such, by default, `null` and `''` will not be dropped. See https://github.com/sindresorhus/query-string/pull/304 for details.
Thus, the following are true:
| Input | Options | Output |
| ------------- | ------------- | ------------ |
| `{foo: ['one', '', null, 'four']}`  | _default settings_  | `foo[]=one,,,four` |
| `{foo: ['one', '', null, 'four']}`  | `skipNull: true`  |  `foo[]=one,,four` |
| `{foo: ['one', '', null, 'four']}`  | `skipEmptyString true`  |  `foo[]=one,,four` |
| `{foo: ['one', '', null, 'four']}`  | `skipEmptyString true, skipNull: true`  |  `foo[]=one,four` |


sindresorhus: > What should happen for {foo: []}?

`?foo[]` would indeed be the best output.

> What should happen for {foo: ['one', null, 'three']}

I think dropping the `null` is the best option.
sindresorhus: You need to explicitly document the behavior with empty array, null in array, and empty string in array in the docs.
sindresorhus: I would also like to see more tests.
sindresorhus: And sorry for the slow reply. I've just been overwhelmed with notifications.
DV8FromTheWorld: I believe I've covered all that was asked. Please let me know if something else needs attention.
Thanks!
sindresorhus: Bump
DV8FromTheWorld: Still have eyes on this, just haven't had a second to address. Will l fix this up soon
DV8FromTheWorld: Review comments have been addressed. Thank you for your patience.
sindresorhus: Can you fix the merge conflict?
DV8FromTheWorld: Merge conflicts resolved.
Documentation updated to reflect new `skipNull` and `skipEmptyString` changes.
PR suggestions included.

Patch:
diff --git a/benchmark.js b/benchmark.js
index af120ea..b152460 100644
--- a/benchmark.js
+++ b/benchmark.js
@@ -20,6 +20,7 @@ const TEST_STRING = stringify(TEST_OBJECT);
 const TEST_BRACKETS_STRING = stringify(TEST_OBJECT, {arrayFormat: 'bracket'});
 const TEST_INDEX_STRING = stringify(TEST_OBJECT, {arrayFormat: 'index'});
 const TEST_COMMA_STRING = stringify(TEST_OBJECT, {arrayFormat: 'comma'});
+const TEST_BRACKET_SEPARATOR_STRING = stringify(TEST_OBJECT, {arrayFormat: 'bracket-separator'});
 const TEST_URL = stringifyUrl({url: TEST_HOST, query: TEST_OBJECT});
 
 // Creates a test case and adds it to the suite
@@ -41,6 +42,7 @@ defineTestCase('parse', TEST_STRING, {decode: false});
 defineTestCase('parse', TEST_BRACKETS_STRING, {arrayFormat: 'bracket'});
 defineTestCase('parse', TEST_INDEX_STRING, {arrayFormat: 'index'});
 defineTestCase('parse', TEST_COMMA_STRING, {arrayFormat: 'comma'});
+defineTestCase('parse', TEST_BRACKET_SEPARATOR_STRING, {arrayFormat: 'bracket-separator'});
 
 // Stringify
 defineTestCase('stringify', TEST_OBJECT);
@@ -51,6 +53,7 @@ defineTestCase('stringify', TEST_OBJECT, {skipEmptyString: true});
 defineTestCase('stringify', TEST_OBJECT, {arrayFormat: 'bracket'});
 defineTestCase('stringify', TEST_OBJECT, {arrayFormat: 'index'});
 defineTestCase('stringify', TEST_OBJECT, {arrayFormat: 'comma'});
+defineTestCase('stringify', TEST_OBJECT, {arrayFormat: 'bracket-separator'});
 
 // Extract
 defineTestCase('extract', TEST_URL);
@@ -66,7 +69,7 @@ suite.on('cycle', event => {
 	const {name, hz} = event.target;
 	const opsPerSec = Math.round(hz).toLocaleString();
 
-	console.log(name.padEnd(36, '_') + opsPerSec.padStart(12, '_') + ' ops/s');
+	console.log(name.padEnd(46, '_') + opsPerSec.padStart(3, '_') + ' ops/s');
 });
 
 suite.run();
diff --git a/index.d.ts b/index.d.ts
index 035ded8..847336d 100644
--- a/index.d.ts
+++ b/index.d.ts
@@ -45,6 +45,30 @@ export interface ParseOptions {
 		//=> {foo: ['1', '2', '3']}
 		```
 
+	- `bracket-separator`: Parse arrays (that are explicitly marked with brackets) with elements separated by a custom character:
+
+		```
+		import queryString = require('query-string');
+
+		queryString.parse('foo[]', {arrayFormat: 'bracket-separator', arrayFormatSeparator: '|'});
+		//=> {foo: []}
+
+		queryString.parse('foo[]=', {arrayFormat: 'bracket-separator', arrayFormatSeparator: '|'});
+		//=> {foo: ['']}
+
+		queryString.parse('foo[]=1', {arrayFormat: 'bracket-separator', arrayFormatSeparator: '|'});
+	 	//=> {foo: ['1']}
+
+		queryString.parse('foo[]=1|2|3', {arrayFormat: 'bracket-separator', arrayFormatSeparator: '|'});
+		//=> {foo: ['1', '2', '3']}
+
+		queryString.parse('foo[]=1||3|||6', {arrayFormat: 'bracket-separator', arrayFormatSeparator: '|'});
+		//=> {foo: ['1', '', 3, '', '', '6']}
+
+		queryString.parse('foo[]=1|2|3&bar=fluffy&baz[]=4', {arrayFormat: 'bracket-separator', arrayFormatSeparator: '|'});
+		//=> {foo: ['1', '2', '3'], bar: 'fluffy', baz:['4']}
+		```
+
 	- `none`: Parse arrays with elements using duplicate keys:
 
 		```
@@ -54,7 +78,7 @@ export interface ParseOptions {
 		//=> {foo: ['1', '2', '3']}
 		```
 	*/
-	readonly arrayFormat?: 'bracket' | 'index' | 'comma' | 'separator' | 'none';
+	readonly arrayFormat?: 'bracket' | 'index' | 'comma' | 'separator' | 'bracket-separator' | 'none';
 
 	/**
 	The character used to separate array elements when using `{arrayFormat: 'separator'}`.
@@ -236,7 +260,7 @@ export interface StringifyOptions {
 		// and `.parse('foo=1,,')` would return `{foo: [1, '', '']}`.
 		```
 
-  - `separator`: Serialize arrays by separating elements with character:
+	- `separator`: Serialize arrays by separating elements with character:
 
 		```
 		import queryString = require('query-string');
@@ -245,6 +269,33 @@ export interface StringifyOptions {
 		//=> 'foo=1|2|3'
 		```
 
+	- `bracket-separator`: Serialize arrays by explicitly post-fixing array names with brackets and separating elements with a custom character:
+
+		```
+		import queryString = require('query-string');
+
+		queryString.stringify({foo: []}, {arrayFormat: 'bracket-separator', arrayFormatSeparator: '|'});
+		//=> 'foo[]'
+
+		queryString.stringify({foo: ['']}, {arrayFormat: 'bracket-separator', arrayFormatSeparator: '|'});
+		//=> 'foo[]='
+
+		queryString.stringify({foo: [1]}, {arrayFormat: 'bracket-separator', arrayFormatSeparator: '|'});
+		//=> 'foo[]=1'
+
+		queryString.stringify({foo: [1, 2, 3]}, {arrayFormat: 'bracket-separator', arrayFormatSeparator: '|'});
+		//=> 'foo[]=1|2|3'
+
+		queryString.stringify({foo: [1, '', 3, null, null, 6]}, {arrayFormat: 'bracket-separator', arrayFormatSeparator: '|'});
+		//=> 'foo[]=1||3|||6'
+
+		queryString.stringify({foo: [1, '', 3, null, null, 6]}, {arrayFormat: 'bracket-separator', arrayFormatSeparator: '|', skipNull: true});
+		//=> 'foo[]=1||3|6'
+
+		queryString.stringify({foo: [1, 2, 3], bar: 'fluffy', baz: [4]}, {arrayFormat: 'bracket-separator', arrayFormatSeparator: '|'});
+		//=> 'foo[]=1|2|3&bar=fluffy&baz[]=4'
+		```
+
 	- `none`: Serialize arrays by using duplicate keys:
 
 		```
@@ -254,7 +305,7 @@ export interface StringifyOptions {
 		//=> 'foo=1&foo=2&foo=3'
 		```
 	*/
-	readonly arrayFormat?: 'bracket' | 'index' | 'comma' | 'separator' | 'none';
+	readonly arrayFormat?: 'bracket' | 'index' | 'comma' | 'separator' | 'bracket-separator' | 'none';
 
 	/**
 	The character used to separate array elements when using `{arrayFormat: 'separator'}`.
diff --git a/index.js b/index.js
index 07764d5..7ab5d92 100644
--- a/index.js
+++ b/index.js
@@ -49,6 +49,11 @@ function encoderForArrayFormat(options) {
 
 		case 'comma':
 		case 'separator':
+		case 'bracket-separator': {
+			const keyValueSep = options.arrayFormat === 'bracket-separator' ?
+				'[]=' :
+				'=';
+
 			return key => (result, value) => {
 				if (
 					value === undefined ||
@@ -58,16 +63,16 @@ function encoderForArrayFormat(options) {
 					return result;
 				}
 
-				if (result.length === 0) {
-					return [[encode(key, options), '=', encode(value === null ? '' : value, options)].join('')];
-				}
+				// Translate null to an empty string so that it doesn't serialize as 'null'
+				value = value === null ? '' : value;
 
-				if (value === null || value === '') {
-					return [[result, ''].join(options.arrayFormatSeparator)];
+				if (result.length === 0) {
+					return [[encode(key, options), keyValueSep, encode(value, options)].join('')];
 				}
 
 				return [[result, encode(value, options)].join(options.arrayFormatSeparator)];
 			};
+		}
 
 		default:
 			return key => (result, value) => {
@@ -138,6 +143,28 @@ function parserForArrayFormat(options) {
 				accumulator[key] = newValue;
 			};
 
+		case 'bracket-separator':
+			return (key, value, accumulator) => {
+				const isArray = /(\[\])$/.test(key);
+				key = key.replace(/\[\]$/, '');
+
+				if (!isArray) {
+					accumulator[key] = value ? decode(value, options) : value;
+					return;
+				}
+
+				const arrayValue = value === null ?
+					[] :
+					value.split(options.arrayFormatSeparator).map(item => decode(item, options));
+
+				if (accumulator[key] === undefined) {
+					accumulator[key] = arrayValue;
+					return;
+				}
+
+				accumulator[key] = [].concat(accumulator[key], arrayValue);
+			};
+
 		default:
 			return (key, value, accumulator) => {
 				if (accumulator[key] === undefined) {
@@ -261,7 +288,7 @@ function parse(query, options) {
 
 		// Missing `=` should be `null`:
 		// http://w3.org/TR/2012/WD-url-20120524/#collect-url-parameters
-		value = value === undefined ? null : ['comma', 'separator'].includes(options.arrayFormat) ? value : decode(value, options);
+		value = value === undefined ? null : ['comma', 'separator', 'bracket-separator'].includes(options.arrayFormat) ? value : decode(value, options);
 		formatter(decode(key, options), value, ret);
 	}
 
@@ -343,6 +370,10 @@ exports.stringify = (object, options) => {
 		}
 
 		if (Array.isArray(value)) {
+			if (value.length === 0 && options.arrayFormat === 'bracket-separator') {
+				return encode(key, options) + '[]';
+			}
+
 			return value
 				.reduce(formatter(key), [])
 				.join('&');
diff --git a/readme.md b/readme.md
index f90aba6..600a971 100644
--- a/readme.md
+++ b/readme.md
@@ -138,6 +138,30 @@ queryString.parse('foo=1|2|3', {arrayFormat: 'separator', arrayFormatSeparator:
 //=> {foo: ['1', '2', '3']}
 ```
 
+- `'bracket-separator'`: Parse arrays (that are explicitly marked with brackets) with elements separated by a custom character:
+
+```js
+const queryString = require('query-string');
+
+queryString.parse('foo[]', {arrayFormat: 'bracket-separator', arrayFormatSeparator: '|'});
+//=> {foo: []}
+
+queryString.parse('foo[]=', {arrayFormat: 'bracket-separator', arrayFormatSeparator: '|'});
+//=> {foo: ['']}
+
+queryString.parse('foo[]=1', {arrayFormat: 'bracket-separator', arrayFormatSeparator: '|'});
+//=> {foo: ['1']}
+
+queryString.parse('foo[]=1|2|3', {arrayFormat: 'bracket-separator', arrayFormatSeparator: '|'});
+//=> {foo: ['1', '2', '3']}
+
+queryString.parse('foo[]=1||3|||6', {arrayFormat: 'bracket-separator', arrayFormatSeparator: '|'});
+//=> {foo: ['1', '', 3, '', '', '6']}
+
+queryString.parse('foo[]=1|2|3&bar=fluffy&baz[]=4', {arrayFormat: 'bracket-separator', arrayFormatSeparator: '|'});
+//=> {foo: ['1', '2', '3'], bar: 'fluffy', baz:['4']}
+```
+
 - `'none'`: Parse arrays with elements using duplicate keys:
 
 ```js
@@ -248,6 +272,42 @@ queryString.stringify({foo: [1, null, '']}, {arrayFormat: 'comma'});
 // and `.parse('foo=1,,')` would return `{foo: [1, '', '']}`.
 ```
 
+- `'separator'`: Serialize arrays by separating elements with a custom character:
+
+```js
+const queryString = require('query-string');
+
+queryString.stringify({foo: [1, 2, 3]}, {arrayFormat: 'separator', arrayFormatSeparator: '|'});
+//=> 'foo=1|2|3'
+```
+
+- `'bracket-separator'`: Serialize arrays by explicitly post-fixing array names with brackets and separating elements with a custom character:
+
+```js
+const queryString = require('query-string');
+
+queryString.stringify({foo: []}, {arrayFormat: 'bracket-separator', arrayFormatSeparator: '|'});
+//=> 'foo[]'
+
+queryString.stringify({foo: ['']}, {arrayFormat: 'bracket-separator', arrayFormatSeparator: '|'});
+//=> 'foo[]='
+
+queryString.stringify({foo: [1]}, {arrayFormat: 'bracket-separator', arrayFormatSeparator: '|'});
+//=> 'foo[]=1'
+
+queryString.stringify({foo: [1, 2, 3]}, {arrayFormat: 'bracket-separator', arrayFormatSeparator: '|'});
+//=> 'foo[]=1|2|3'
+
+queryString.stringify({foo: [1, '', 3, null, null, 6]}, {arrayFormat: 'bracket-separator', arrayFormatSeparator: '|'});
+//=> 'foo[]=1||3|||6'
+
+queryString.stringify({foo: [1, '', 3, null, null, 6]}, {arrayFormat: 'bracket-separator', arrayFormatSeparator: '|', skipNull: true});
+//=> 'foo[]=1||3|6'
+
+queryString.stringify({foo: [1, 2, 3], bar: 'fluffy', baz: [4]}, {arrayFormat: 'bracket-separator', arrayFormatSeparator: '|'});
+//=> 'foo[]=1|2|3&bar=fluffy&baz[]=4'
+```
+
 - `'none'`: Serialize arrays by using duplicate keys:
 
 ```js


Test patch:
diff --git a/test/parse.js b/test/parse.js
index 70759f1..731a848 100644
--- a/test/parse.js
+++ b/test/parse.js
@@ -184,6 +184,36 @@ test('query strings having indexed arrays and format option as `index`', t => {
 	}), {foo: ['bar', 'baz']});
 });
 
+test('query strings having brackets+separator arrays and format option as `bracket-separator` with 1 value', t => {
+	t.deepEqual(queryString.parse('foo[]=bar', {
+		arrayFormat: 'bracket-separator'
+	}), {foo: ['bar']});
+});
+
+test('query strings having brackets+separator arrays and format option as `bracket-separator` with multiple values', t => {
+	t.deepEqual(queryString.parse('foo[]=bar,baz,,,biz', {
+		arrayFormat: 'bracket-separator'
+	}), {foo: ['bar', 'baz', '', '', 'biz']});
+});
+
+test('query strings with multiple brackets+separator arrays and format option as `bracket-separator` using same key name', t => {
+	t.deepEqual(queryString.parse('foo[]=bar,baz&foo[]=biz,boz', {
+		arrayFormat: 'bracket-separator'
+	}), {foo: ['bar', 'baz', 'biz', 'boz']});
+});
+
+test('query strings having an empty brackets+separator array and format option as `bracket-separator`', t => {
+	t.deepEqual(queryString.parse('foo[]', {
+		arrayFormat: 'bracket-separator'
+	}), {foo: []});
+});
+
+test('query strings having a brackets+separator array and format option as `bracket-separator` with a single empty string', t => {
+	t.deepEqual(queryString.parse('foo[]=', {
+		arrayFormat: 'bracket-separator'
+	}), {foo: ['']});
+});
+
 test('query strings having = within parameters (i.e. GraphQL IDs)', t => {
 	t.deepEqual(queryString.parse('foo=bar=&foo=ba=z='), {foo: ['bar=', 'ba=z=']});
 });
diff --git a/test/stringify.js b/test/stringify.js
index 4b8bda5..c8751eb 100644
--- a/test/stringify.js
+++ b/test/stringify.js
@@ -172,6 +172,71 @@ test('array stringify representation with array indexes and sparse array', t =>
 	t.is(queryString.stringify({bar: fixture}, {arrayFormat: 'index'}), 'bar[0]=one&bar[1]=two&bar[2]=three');
 });
 
+test('array stringify representation with brackets and separators with empty array', t => {
+	t.is(queryString.stringify({
+		foo: null,
+		bar: []
+	}, {
+		arrayFormat: 'bracket-separator'
+	}), 'bar[]&foo');
+});
+
+test('array stringify representation with brackets and separators with single value', t => {
+	t.is(queryString.stringify({
+		foo: null,
+		bar: ['one']
+	}, {
+		arrayFormat: 'bracket-separator'
+	}), 'bar[]=one&foo');
+});
+
+test('array stringify representation with brackets and separators with multiple values', t => {
+	t.is(queryString.stringify({
+		foo: null,
+		bar: ['one', 'two', 'three']
+	}, {
+		arrayFormat: 'bracket-separator'
+	}), 'bar[]=one,two,three&foo');
+});
+
+test('array stringify representation with brackets and separators with a single empty string', t => {
+	t.is(queryString.stringify({
+		foo: null,
+		bar: ['']
+	}, {
+		arrayFormat: 'bracket-separator'
+	}), 'bar[]=&foo');
+});
+
+test('array stringify representation with brackets and separators with a multiple empty string', t => {
+	t.is(queryString.stringify({
+		foo: null,
+		bar: ['', 'two', '']
+	}, {
+		arrayFormat: 'bracket-separator'
+	}), 'bar[]=,two,&foo');
+});
+
+test('array stringify representation with brackets and separators with dropped empty strings', t => {
+	t.is(queryString.stringify({
+		foo: null,
+		bar: ['', 'two', '']
+	}, {
+		arrayFormat: 'bracket-separator',
+		skipEmptyString: true
+	}), 'bar[]=two&foo');
+});
+
+test('array stringify representation with brackets and separators with dropped null values', t => {
+	t.is(queryString.stringify({
+		foo: null,
+		bar: ['one', null, 'three', null, '', 'six']
+	}, {
+		arrayFormat: 'bracket-separator',
+		skipNull: true
+	}), 'bar[]=one,three,,six');
+});
+
 test('should sort keys in given order', t => {
 	const fixture = ['c', 'a', 'b'];
 	const sort = (key1, key2) => fixture.indexOf(key1) - fixture.indexOf(key2);