Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add new js nesting syntax #40

Merged
merged 1 commit into from
Jun 24, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,8 @@ Sets which style of nesting syntax should be used. The choices are:

- `dot` (e.g. `foo.bar=baz`)
- `index` (e.g. `foo[bar]=baz`)
- `js` (e.g. `foo.bar[0]=baz`, i.e. arrays are indexed and properties are
dotted)

### `arrayRepeat`

Expand Down
33 changes: 21 additions & 12 deletions src/object-util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ function isPrototypeKey(value: unknown) {
export function getDeepObject(
obj: KeyableObject,
key: PropertyKey,
nextKey: PropertyKey
nextKey: PropertyKey,
forceObject?: boolean,
forceArray?: boolean
): KeyableObject {
if (isPrototypeKey(key)) return obj;

Expand All @@ -21,13 +23,17 @@ export function getDeepObject(
return currObj;
}
// Check if the key is not a number, if it is a number, an array must be used
else if (
typeof nextKey === 'string' &&
((nextKey as unknown as number) * 0 !== 0 || nextKey.indexOf('.') > -1)
if (
!forceObject &&
(forceArray ||
typeof nextKey === 'number' ||
(typeof nextKey === 'string' &&
(nextKey as unknown as number) * 0 === 0 &&
nextKey.indexOf('.') === -1))
) {
return (obj[key] = {});
return (obj[key] = []) as unknown as KeyableObject;
}
return (obj[key] = []) as unknown as KeyableObject;
return (obj[key] = {});
}

const MAX_DEPTH = 20;
Expand All @@ -41,7 +47,7 @@ export function stringifyObject(
options: Partial<Options>,
depth: number = 0,
parentKey?: string,
useArrayRepeatKey?: boolean
isProbableArray?: boolean
): string {
const {
nestingSyntax = defaultOptions.nestingSyntax,
Expand All @@ -53,14 +59,17 @@ export function stringifyObject(
} = options;
const strDelimiter =
typeof delimiter === 'number' ? String.fromCharCode(delimiter) : delimiter;
const useArrayRepeatKey = isProbableArray === true && arrayRepeat;
const shouldUseDot =
nestingSyntax === 'dot' || (nestingSyntax === 'js' && !isProbableArray);

if (depth > MAX_DEPTH) {
return '';
}

let result = '';
let firstKey = true;
let probableArray = false;
let valueIsProbableArray = false;

for (const key in obj) {
const value = obj[key];
Expand All @@ -71,7 +80,7 @@ export function stringifyObject(
if (arrayRepeatSyntax === 'bracket') {
path += strBracketPair;
}
} else if (nestingSyntax === 'dot') {
} else if (shouldUseDot) {
path += strDot;
path += key;
} else {
Expand All @@ -88,15 +97,15 @@ export function stringifyObject(
}

if (typeof value === 'object' && value !== null) {
probableArray = (value as unknown[]).pop !== undefined;
valueIsProbableArray = (value as unknown[]).pop !== undefined;

if (nesting || (arrayRepeat && probableArray)) {
if (nesting || (arrayRepeat && valueIsProbableArray)) {
result += stringifyObject(
value as Record<PropertyKey, unknown>,
options,
depth + 1,
path,
arrayRepeat && probableArray
valueIsProbableArray
);
}
} else {
Expand Down
75 changes: 57 additions & 18 deletions src/parse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ export function parse(input: string, options?: ParseOptions): ParsedQuery {
} = options ?? {};
const charDelimiter =
typeof delimiter === 'string' ? delimiter.charCodeAt(0) : delimiter;
const isJsNestingSyntax = nestingSyntax === 'js';

// Optimization: Use new Empty() instead of Object.create(null) for performance
// v8 has a better optimization for initializing functions compared to Object
Expand All @@ -89,6 +90,8 @@ export function parse(input: string, options?: ParseOptions): ParsedQuery {
let shouldDecodeValue = false;
let keyHasPlus = false;
let valueHasPlus = false;
let keyIsDot = false;
let keyIsIndex = false;
let hasBothKeyValuePair = false;
let c = 0;
let arrayRepeatBracketIndex = -1;
Expand Down Expand Up @@ -119,7 +122,13 @@ export function parse(input: string, options?: ParseOptions): ParsedQuery {

currentKey = keyDeserializer(keyChunk);
if (lastKey !== undefined) {
currentObj = getDeepObject(currentObj, lastKey, currentKey);
currentObj = getDeepObject(
currentObj,
lastKey,
currentKey,
isJsNestingSyntax && keyIsDot,
isJsNestingSyntax && keyIsIndex
);
}
}

Expand Down Expand Up @@ -160,6 +169,8 @@ export function parse(input: string, options?: ParseOptions): ParsedQuery {
shouldDecodeValue = false;
keyHasPlus = false;
valueHasPlus = false;
keyIsDot = false;
keyIsIndex = false;
arrayRepeatBracketIndex = -1;
keySeparatorIndex = i;
currentObj = result;
Expand All @@ -177,7 +188,7 @@ export function parse(input: string, options?: ParseOptions): ParsedQuery {

if (
nesting &&
nestingSyntax === 'index' &&
(nestingSyntax === 'index' || isJsNestingSyntax) &&
equalityIndex <= startingIndex
) {
keyChunk = computeKeySlice(
Expand All @@ -190,46 +201,64 @@ export function parse(input: string, options?: ParseOptions): ParsedQuery {

currentKey = keyDeserializer(keyChunk);
if (lastKey !== undefined) {
currentObj = getDeepObject(currentObj, lastKey, currentKey);
currentObj = getDeepObject(
currentObj,
lastKey,
currentKey,
undefined,
isJsNestingSyntax
);
}
lastKey = currentKey;

keySeparatorIndex = i;
keyHasPlus = false;
shouldDecodeKey = false;
keyIsIndex = true;
keyIsDot = false;
}
}
// Check '.'
else if (c === 46) {
if (
nesting &&
nestingSyntax === 'dot' &&
(nestingSyntax === 'dot' || isJsNestingSyntax) &&
equalityIndex <= startingIndex
) {
keyChunk = computeKeySlice(
input,
keySeparatorIndex + 1,
i,
keyHasPlus,
shouldDecodeKey
);
if (keySeparatorIndex !== i - 1) {
keyChunk = computeKeySlice(
input,
keySeparatorIndex + 1,
i,
keyHasPlus,
shouldDecodeKey
);

currentKey = keyDeserializer(keyChunk);
if (lastKey !== undefined) {
currentObj = getDeepObject(currentObj, lastKey, currentKey);
currentKey = keyDeserializer(keyChunk);
if (lastKey !== undefined) {
currentObj = getDeepObject(
currentObj,
lastKey,
currentKey,
isJsNestingSyntax
);
}
lastKey = currentKey;

keyHasPlus = false;
shouldDecodeKey = false;
}
lastKey = currentKey;

keyIsDot = true;
keyIsIndex = false;
keySeparatorIndex = i;
keyHasPlus = false;
shouldDecodeKey = false;
}
}
// Check '['
else if (c === 91) {
if (
nesting &&
nestingSyntax === 'index' &&
(nestingSyntax === 'index' || isJsNestingSyntax) &&
equalityIndex <= startingIndex
) {
if (keySeparatorIndex !== i - 1) {
Expand All @@ -242,10 +271,20 @@ export function parse(input: string, options?: ParseOptions): ParsedQuery {
);

currentKey = keyDeserializer(keyChunk);
if (isJsNestingSyntax && lastKey !== undefined) {
currentObj = getDeepObject(
currentObj,
lastKey,
currentKey,
isJsNestingSyntax
);
}
lastKey = currentKey;

keyHasPlus = false;
shouldDecodeKey = false;
keyIsDot = false;
keyIsIndex = true;
}

keySeparatorIndex = i;
Expand Down
4 changes: 3 additions & 1 deletion src/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ export type NestingSyntax =
// `foo.bar`
| 'dot'
// `foo[bar]`
| 'index';
| 'index'
// `foo.bar[0]`
| 'js';

export type DeserializeValueFunction = (
value: string,
Expand Down
25 changes: 25 additions & 0 deletions src/test/object-util_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,31 @@ test('getDeepObject', async (t) => {
getDeepObject(obj, 'foo', 'bar');
assert.deepEqual(obj, {foo: {}});
});

await t.test('creates new object if forceObject=true', () => {
const obj = {};
getDeepObject(obj, 'foo', '0', true);
assert.deepEqual(obj, {
foo: {}
});
});

await t.test('creates new array if forceArray=true', () => {
const obj = {};
getDeepObject(obj, 'foo', 'bar', undefined, true);
assert.deepEqual(obj, {
foo: []
});
});

await t.test('handles non-string/non-number keys', () => {
const obj = {};
const key = Symbol();
getDeepObject(obj, 'foo', key);
assert.deepEqual(obj, {
foo: {}
});
});
});

test('stringifyObject', async (t) => {
Expand Down
9 changes: 9 additions & 0 deletions src/test/parse_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,13 @@ test('parse', async (t) => {
const result = parse(808 as unknown as string);
assert.deepEqual({...result}, {});
});

await t.test('js nesting dot-syntax always uses a property', () => {
const result = parse('foo[bar]=x', {nesting: true, nestingSyntax: 'js'});
assert.ok(Array.isArray(result.foo));
assert.equal(
(result.foo as unknown as Record<PropertyKey, unknown>).bar,
'x'
);
});
});
35 changes: 35 additions & 0 deletions src/test/test-cases.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,41 @@ export const testCases: TestCase[] = [
options: {nesting: true, nestingSyntax: 'index'}
},

// Nesting syntax: js
{
input: 'foo[0]=x&foo[1]=y',
stringifyOutput: 'foo%5B0%5D=x&foo%5B1%5D=y',
output: {foo: ['x', 'y']},
options: {nesting: true, nestingSyntax: 'js'}
},
{
input: 'foo.bar[0]=x&foo.bar[1]=y',
stringifyOutput: 'foo.bar%5B0%5D=x&foo.bar%5B1%5D=y',
output: {foo: {bar: ['x', 'y']}},
options: {nesting: true, nestingSyntax: 'js'}
},
{
input: 'foo.bar[0].baz=x',
stringifyOutput: 'foo.bar%5B0%5D.baz=x',
output: {foo: {bar: [{baz: 'x'}]}},
options: {nesting: true, nestingSyntax: 'js'}
},
{
input: 'foo.bar=x&foo.baz=y',
output: {foo: {bar: 'x', baz: 'y'}},
options: {nesting: true, nestingSyntax: 'js'}
},
{
input: 'foo.0=x&foo.1=y',
output: {foo: {0: 'x', 1: 'y'}},
options: {nesting: true, nestingSyntax: 'js'}
},
{
input: 'foo.bar.x=x&foo.bar.y=y',
output: {foo: {bar: {x: 'x', y: 'y'}}},
options: {nesting: true, nestingSyntax: 'js'}
},

// Sparse array with nestinh
{
input: 'foo[0]=x&foo[2]=y',
Expand Down