Skip to content

Commit

Permalink
[New] parse/stringify: add decodeDotInKeys/encodeDotKeys options
Browse files Browse the repository at this point in the history
  • Loading branch information
aks- authored and ljharb committed Jan 28, 2024
1 parent 82a098e commit 30004b2
Show file tree
Hide file tree
Showing 6 changed files with 255 additions and 12 deletions.
2 changes: 1 addition & 1 deletion .eslintrc
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
"id-length": [2, { "min": 1, "max": 25, "properties": "never" }],
"indent": [2, 4],
"max-lines-per-function": [2, { "max": 150 }],
"max-params": [2, 17],
"max-params": [2, 18],
"max-statements": [2, 100],
"multiline-comment-style": 0,
"no-continue": 1,
Expand Down
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,14 @@ var withDots = qs.parse('a.b=c', { allowDots: true });
assert.deepEqual(withDots, { a: { b: 'c' } });
```

Option `decodeDotInKeys` can be used to decode dots in keys
Note: it implies `allowDots`, so `parse` will error if you set `decodeDotInKeys` to `true`, and `allowDots` to `false`.

```javascript
var withDots = qs.parse('name%252Eobj.first=John&name%252Eobj.last=Doe', { decodeDotInKeys: true });
assert.deepEqual(withDots, { 'name.obj': { first: 'John', last: 'Doe' }});
```

Option `allowEmptyArrays` can be used to allowing empty array values in object
```javascript
var withEmptyArrays = qs.parse('foo[]&bar=baz', { allowEmptyArrays: true });
Expand Down Expand Up @@ -426,6 +434,14 @@ qs.stringify({ a: { b: { c: 'd', e: 'f' } } }, { allowDots: true });
// 'a.b.c=d&a.b.e=f'
```

You may encode the dot notation in the keys of object with option `encodeDotInKeys` by setting it to `true`:
Note: it implies `allowDots`, so `stringify` will error if you set `decodeDotInKeys` to `true`, and `allowDots` to `false`.
Caveat: when `encodeValuesOnly` is `true` as well as `encodeDotInKeys`, only dots in keys and nothing else will be encoded.
```javascript
qs.stringify({ "name.obj": { "first": "John", "last": "Doe" } }, { allowDots: true, encodeDotInKeys: true })
// 'name%252Eobj.first=John&name%252Eobj.last=Doe'
```

You may allow empty array values by setting the `allowEmptyArrays` option to `true`:
```javascript
qs.stringify({ foo: [], bar: 'baz' }, { allowEmptyArrays: true });
Expand Down
21 changes: 14 additions & 7 deletions lib/parse.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ var defaults = {
charset: 'utf-8',
charsetSentinel: false,
comma: false,
decodeDotInKeys: true,
decoder: utils.decode,
delimiter: '&',
depth: 5,
Expand Down Expand Up @@ -128,20 +129,21 @@ var parseObject = function (chain, val, options, valuesParsed) {
} else {
obj = options.plainObjects ? Object.create(null) : {};
var cleanRoot = root.charAt(0) === '[' && root.charAt(root.length - 1) === ']' ? root.slice(1, -1) : root;
var index = parseInt(cleanRoot, 10);
if (!options.parseArrays && cleanRoot === '') {
var decodedRoot = options.decodeDotInKeys ? cleanRoot.replace(/%2E/g, '.') : cleanRoot;
var index = parseInt(decodedRoot, 10);
if (!options.parseArrays && decodedRoot === '') {
obj = { 0: leaf };
} else if (
!isNaN(index)
&& root !== cleanRoot
&& String(index) === cleanRoot
&& root !== decodedRoot
&& String(index) === decodedRoot
&& index >= 0
&& (options.parseArrays && index <= options.arrayLimit)
) {
obj = [];
obj[index] = leaf;
} else if (cleanRoot !== '__proto__') {
obj[cleanRoot] = leaf;
} else if (decodedRoot !== '__proto__') {
obj[decodedRoot] = leaf;
}
}

Expand Down Expand Up @@ -214,6 +216,10 @@ var normalizeParseOptions = function normalizeParseOptions(opts) {
throw new TypeError('`allowEmptyArrays` option can only be `true` or `false`, when provided');
}

if (typeof opts.decodeDotInKeys !== 'undefined' && typeof opts.decodeDotInKeys !== 'boolean') {
throw new TypeError('`decodeDotInKeys` option can only be `true` or `false`, when provided');
}

if (opts.decoder !== null && typeof opts.decoder !== 'undefined' && typeof opts.decoder !== 'function') {
throw new TypeError('Decoder has to be a function.');
}
Expand All @@ -229,7 +235,7 @@ var normalizeParseOptions = function normalizeParseOptions(opts) {
throw new TypeError('The duplicates option must be either combine, first, or last');
}

var allowDots = typeof opts.allowDots === 'undefined' ? defaults.allowDots : !!opts.allowDots;
var allowDots = typeof opts.allowDots === 'undefined' ? opts.decodeDotInKeys === true ? true : defaults.allowDots : !!opts.allowDots;

return {
allowDots: allowDots,
Expand All @@ -240,6 +246,7 @@ var normalizeParseOptions = function normalizeParseOptions(opts) {
charset: charset,
charsetSentinel: typeof opts.charsetSentinel === 'boolean' ? opts.charsetSentinel : defaults.charsetSentinel,
comma: typeof opts.comma === 'boolean' ? opts.comma : defaults.comma,
decodeDotInKeys: typeof opts.decodeDotInKeys === 'boolean' ? opts.decodeDotInKeys : defaults.decodeDotInKeys,
decoder: typeof opts.decoder === 'function' ? opts.decoder : defaults.decoder,
delimiter: typeof opts.delimiter === 'string' || utils.isRegExp(opts.delimiter) ? opts.delimiter : defaults.delimiter,
// eslint-disable-next-line no-implicit-coercion, no-extra-parens
Expand Down
20 changes: 16 additions & 4 deletions lib/stringify.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ var defaults = {
charsetSentinel: false,
delimiter: '&',
encode: true,
encodeDotInKeys: false,
encoder: utils.encode,
encodeValuesOnly: false,
format: defaultFormat,
Expand Down Expand Up @@ -67,6 +68,7 @@ var stringify = function stringify(
allowEmptyArrays,
strictNullHandling,
skipNulls,
encodeDotInKeys,
encoder,
filter,
sort,
Expand Down Expand Up @@ -148,7 +150,9 @@ var stringify = function stringify(
objKeys = sort ? keys.sort(sort) : keys;
}

var adjustedPrefix = commaRoundTrip && isArray(obj) && obj.length === 1 ? prefix + '[]' : prefix;
var encodedPrefix = encodeDotInKeys ? prefix.replace(/\./g, '%2E') : prefix;

var adjustedPrefix = commaRoundTrip && isArray(obj) && obj.length === 1 ? encodedPrefix + '[]' : encodedPrefix;

if (allowEmptyArrays && isArray(obj) && obj.length === 0) {
return adjustedPrefix + '[]';
Expand All @@ -162,9 +166,10 @@ var stringify = function stringify(
continue;
}

var encodedKey = allowDots && encodeDotInKeys ? key.replace(/\./g, '%2E') : key;
var keyPrefix = isArray(obj)
? typeof generateArrayPrefix === 'function' ? generateArrayPrefix(adjustedPrefix, key) : adjustedPrefix
: adjustedPrefix + (allowDots ? '.' + key : '[' + key + ']');
? typeof generateArrayPrefix === 'function' ? generateArrayPrefix(adjustedPrefix, encodedKey) : adjustedPrefix
: adjustedPrefix + (allowDots ? '.' + encodedKey : '[' + encodedKey + ']');

sideChannel.set(object, step);
var valueSideChannel = getSideChannel();
Expand All @@ -177,6 +182,7 @@ var stringify = function stringify(
allowEmptyArrays,
strictNullHandling,
skipNulls,
encodeDotInKeys,
generateArrayPrefix === 'comma' && encodeValuesOnly && isArray(obj) ? null : encoder,
filter,
sort,
Expand All @@ -202,6 +208,10 @@ var normalizeStringifyOptions = function normalizeStringifyOptions(opts) {
throw new TypeError('`allowEmptyArrays` option can only be `true` or `false`, when provided');
}

if (typeof opts.encodeDotInKeys !== 'undefined' && typeof opts.encodeDotInKeys !== 'boolean') {
throw new TypeError('`encodeDotInKeys` option can only be `true` or `false`, when provided');
}

if (opts.encoder !== null && typeof opts.encoder !== 'undefined' && typeof opts.encoder !== 'function') {
throw new TypeError('Encoder has to be a function.');
}
Expand Down Expand Up @@ -238,7 +248,7 @@ var normalizeStringifyOptions = function normalizeStringifyOptions(opts) {
throw new TypeError('`commaRoundTrip` must be a boolean, or absent');
}

var allowDots = typeof opts.allowDots === 'undefined' ? defaults.allowDots : !!opts.allowDots;
var allowDots = typeof opts.allowDots === 'undefined' ? opts.encodeDotInKeys === true ? true : defaults.allowDots : !!opts.allowDots;

return {
addQueryPrefix: typeof opts.addQueryPrefix === 'boolean' ? opts.addQueryPrefix : defaults.addQueryPrefix,
Expand All @@ -250,6 +260,7 @@ var normalizeStringifyOptions = function normalizeStringifyOptions(opts) {
commaRoundTrip: opts.commaRoundTrip,
delimiter: typeof opts.delimiter === 'undefined' ? defaults.delimiter : opts.delimiter,
encode: typeof opts.encode === 'boolean' ? opts.encode : defaults.encode,
encodeDotInKeys: typeof opts.encodeDotInKeys === 'boolean' ? opts.encodeDotInKeys : defaults.encodeDotInKeys,
encoder: typeof opts.encoder === 'function' ? opts.encoder : defaults.encoder,
encodeValuesOnly: typeof opts.encodeValuesOnly === 'boolean' ? opts.encodeValuesOnly : defaults.encodeValuesOnly,
filter: filter,
Expand Down Expand Up @@ -309,6 +320,7 @@ module.exports = function (object, opts) {
options.allowEmptyArrays,
options.strictNullHandling,
options.skipNulls,
options.encodeDotInKeys,
options.encode ? options.encoder : null,
options.filter,
options.sort,
Expand Down
86 changes: 86 additions & 0 deletions test/parse.js
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,92 @@ test('parse()', function (t) {
st.end();
});

t.test('decode dot keys correctly', function (st) {
st.deepEqual(
qs.parse('name%252Eobj.first=John&name%252Eobj.last=Doe', { allowDots: false, decodeDotInKeys: false }),
{ 'name%2Eobj.first': 'John', 'name%2Eobj.last': 'Doe' },
'with allowDots false and decodeDotInKeys false'
);
st.deepEqual(
qs.parse('name.obj.first=John&name.obj.last=Doe', { allowDots: true, decodeDotInKeys: false }),
{ name: { obj: { first: 'John', last: 'Doe' } } },
'with allowDots false and decodeDotInKeys false'
);
st.deepEqual(
qs.parse('name%252Eobj.first=John&name%252Eobj.last=Doe', { allowDots: true, decodeDotInKeys: false }),
{ 'name%2Eobj': { first: 'John', last: 'Doe' } },
'with allowDots true and decodeDotInKeys false'
);
st.deepEqual(
qs.parse('name%252Eobj.first=John&name%252Eobj.last=Doe', { allowDots: true, decodeDotInKeys: true }),
{ 'name.obj': { first: 'John', last: 'Doe' } },
'with allowDots true and decodeDotInKeys true'
);

st.deepEqual(
qs.parse(
'name%252Eobj%252Esubobject.first%252Egodly%252Ename=John&name%252Eobj%252Esubobject.last=Doe',
{ allowDots: false, decodeDotInKeys: false }
),
{ 'name%2Eobj%2Esubobject.first%2Egodly%2Ename': 'John', 'name%2Eobj%2Esubobject.last': 'Doe' },
'with allowDots false and decodeDotInKeys false'
);
st.deepEqual(
qs.parse(
'name.obj.subobject.first.godly.name=John&name.obj.subobject.last=Doe',
{ allowDots: true, decodeDotInKeys: false }
),
{ name: { obj: { subobject: { first: { godly: { name: 'John' } }, last: 'Doe' } } } },
'with allowDots true and decodeDotInKeys false'
);
st.deepEqual(
qs.parse(
'name%252Eobj%252Esubobject.first%252Egodly%252Ename=John&name%252Eobj%252Esubobject.last=Doe',
{ allowDots: true, decodeDotInKeys: true }
),
{ 'name.obj.subobject': { 'first.godly.name': 'John', last: 'Doe' } },
'with allowDots true and decodeDotInKeys true'
);

st.end();
});

t.test('should decode dot in key of object, and allow enabling dot notation when decodeDotInKeys is set to true and allowDots is undefined', function (st) {
st.deepEqual(
qs.parse(
'name%252Eobj%252Esubobject.first%252Egodly%252Ename=John&name%252Eobj%252Esubobject.last=Doe',
{ decodeDotInKeys: true }
),
{ 'name.obj.subobject': { 'first.godly.name': 'John', last: 'Doe' } },
'with allowDots undefined and decodeDotInKeys true'
);

st.end();
});

t.test('should throw when decodeDotInKeys is not of type boolean', function (st) {
st['throws'](
function () { qs.parse('foo[]&bar=baz', { decodeDotInKeys: 'foobar' }); },
TypeError
);

st['throws'](
function () { qs.parse('foo[]&bar=baz', { decodeDotInKeys: 0 }); },
TypeError
);
st['throws'](
function () { qs.parse('foo[]&bar=baz', { decodeDotInKeys: NaN }); },
TypeError
);

st['throws'](
function () { qs.parse('foo[]&bar=baz', { decodeDotInKeys: null }); },
TypeError
);

st.end();
});

t.test('allows empty arrays in obj values', function (st) {
st.deepEqual(qs.parse('foo[]&bar=baz', { allowEmptyArrays: true }), { foo: [], bar: 'baz' });
st.deepEqual(qs.parse('foo[]&bar=baz', { allowEmptyArrays: false }), { foo: [''], bar: 'baz' });
Expand Down

0 comments on commit 30004b2

Please sign in to comment.