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 f22b2bc commit 0b83865
Show file tree
Hide file tree
Showing 6 changed files with 237 additions and 10 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
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,14 @@ var withDots = qs.parse('a.b=c', { allowDots: true });
assert.deepEqual(withDots, { a: { b: 'c' } });
```

Option `decodeDotInKeys` can be used to decode dot notations in keys
Note: it only works with `allowDots` so you have to provide both options to be `true`:

```javascript
var withDots = qs.parse('name%252Eobj.first=John&name%252Eobj.last=Doe', { allowDots: true, 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,15 @@ 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`
Notes:
1. it only works with `allowDots` being set to `true`
2. when `encodeValuesOnly` is provided with `encodeDotInKeys`, it would only encode dots in keys and nothing else.
```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
19 changes: 13 additions & 6 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: false,
decoder: utils.decode,
delimiter: '&',
depth: 5,
Expand Down Expand Up @@ -126,20 +127,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.allowDots && 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 @@ -212,6 +214,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 @@ -230,6 +236,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
17 changes: 14 additions & 3 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,8 @@ var stringify = function stringify(
objKeys = sort ? keys.sort(sort) : keys;
}

var adjustedPrefix = commaRoundTrip && isArray(obj) && obj.length === 1 ? prefix + '[]' : prefix;
var encodedPrefix = allowDots && 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 +165,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 +181,7 @@ var stringify = function stringify(
allowEmptyArrays,
strictNullHandling,
skipNulls,
encodeDotInKeys,
generateArrayPrefix === 'comma' && encodeValuesOnly && isArray(obj) ? null : encoder,
filter,
sort,
Expand All @@ -202,6 +207,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 @@ -248,6 +257,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 @@ -307,6 +317,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
82 changes: 82 additions & 0 deletions test/parse.js
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,88 @@ 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%252Eobj.first=John&name%252Eobj.last=Doe', { allowDots: false, decodeDotInKeys: true }),
{ 'name%2Eobj.first': 'John', 'name%2Eobj.last': 'Doe' },
'with allowDots false and decodeDotInKeys true'
);

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%252Eobj%252Esubobject.first%252Egodly%252Ename=John&name%252Eobj%252Esubobject.last=Doe',
{ allowDots: false, decodeDotInKeys: true }
),
{ 'name%2Eobj%2Esubobject.first%2Egodly%2Ename': 'John', 'name%2Eobj%2Esubobject.last': 'Doe' },
'with allowDots false and decodeDotInKeys true'
);
st.deepEqual(
qs.parse(
'name%252Eobj%252Esubobject.first%252Egodly%252Ename=John&name%252Eobj%252Esubobject.last=Doe',
{ allowDots: true, decodeDotInKeys: false }
),
{ 'name%2Eobj%2Esubobject': { 'first%2Egodly%2Ename': '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 throw when decodeDotKeys 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
110 changes: 110 additions & 0 deletions test/stringify.js
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,116 @@ test('stringify()', function (t) {
st.end();
});

t.test('encodes dot in key of object when encodeDotInKeys and allowDots is provided', function (st) {
st.equal(
qs.stringify(
{ 'name.obj': { first: 'John', last: 'Doe' } },
{ allowDots: false, encodeDotInKeys: false }
),
'name.obj%5Bfirst%5D=John&name.obj%5Blast%5D=Doe',
'with allowDots false and encodeDotInKeys false'
);
st.equal(
qs.stringify(
{ 'name.obj': { first: 'John', last: 'Doe' } },
{ allowDots: true, encodeDotInKeys: false }
),
'name.obj.first=John&name.obj.last=Doe',
'with allowDots true and encodeDotInKeys false'
);
st.equal(
qs.stringify(
{ 'name.obj': { first: 'John', last: 'Doe' } },
{ allowDots: false, encodeDotInKeys: true }
),
'name.obj%5Bfirst%5D=John&name.obj%5Blast%5D=Doe',
'with allowDots false and encodeDotInKeys true'
);
st.equal(
qs.stringify(
{ 'name.obj': { first: 'John', last: 'Doe' } },
{ allowDots: true, encodeDotInKeys: true }
),
'name%252Eobj.first=John&name%252Eobj.last=Doe',
'with allowDots true and encodeDotInKeys true'
);

st.equal(
qs.stringify(
{ 'name.obj.subobject': { 'first.godly.name': 'John', last: 'Doe' } },
{ allowDots: false, encodeDotInKeys: false }
),
'name.obj.subobject%5Bfirst.godly.name%5D=John&name.obj.subobject%5Blast%5D=Doe',
'with allowDots false and encodeDotInKeys false'
);
st.equal(
qs.stringify(
{ 'name.obj.subobject': { 'first.godly.name': 'John', last: 'Doe' } },
{ allowDots: true, encodeDotInKeys: false }
),
'name.obj.subobject.first.godly.name=John&name.obj.subobject.last=Doe',
'with allowDots true and encodeDotInKeys false'
);
st.equal(
qs.stringify(
{ 'name.obj.subobject': { 'first.godly.name': 'John', last: 'Doe' } },
{ allowDots: false, encodeDotInKeys: true }
),
'name.obj.subobject%5Bfirst.godly.name%5D=John&name.obj.subobject%5Blast%5D=Doe',
'with allowDots false and encodeDotInKeys true'
);
st.equal(
qs.stringify(
{ 'name.obj.subobject': { 'first.godly.name': 'John', last: 'Doe' } },
{ allowDots: true, encodeDotInKeys: true }
),
'name%252Eobj%252Esubobject.first%252Egodly%252Ename=John&name%252Eobj%252Esubobject.last=Doe',
'with allowDots true and encodeDotInKeys true'
);

st.end();
});

t.test('should encode dot in key of object when encodeDotInKeys and allowDots is provided, and nothing else when encodeValuesOnly is provided', function (st) {
st.equal(
qs.stringify({ 'name.obj': { first: 'John', last: 'Doe' } }, {
encodeDotInKeys: true, allowDots: true, encodeValuesOnly: true
}),
'name%2Eobj.first=John&name%2Eobj.last=Doe'
);

st.equal(
qs.stringify({ 'name.obj.subobject': { 'first.godly.name': 'John', last: 'Doe' } }, { allowDots: true, encodeDotInKeys: true, encodeValuesOnly: true }),
'name%2Eobj%2Esubobject.first%2Egodly%2Ename=John&name%2Eobj%2Esubobject.last=Doe'
);

st.end();
});

t.test('should throw when encodeDotInKeys is not of type boolean', function (st) {
st['throws'](
function () { qs.stringify({ a: [], b: 'zz' }, { encodeDotInKeys: 'foobar' }); },
TypeError
);

st['throws'](
function () { qs.stringify({ a: [], b: 'zz' }, { encodeDotInKeys: 0 }); },
TypeError
);

st['throws'](
function () { qs.stringify({ a: [], b: 'zz' }, { encodeDotInKeys: NaN }); },
TypeError
);

st['throws'](
function () { qs.stringify({ a: [], b: 'zz' }, { encodeDotInKeys: null }); },
TypeError
);

st.end();
});

t.test('adds query prefix', function (st) {
st.equal(qs.stringify({ a: 'b' }, { addQueryPrefix: true }), '?a=b');
st.end();
Expand Down

0 comments on commit 0b83865

Please sign in to comment.