Skip to content

Commit bf090b5

Browse files
committed
feat: add querystring stringify
1 parent 42fa59a commit bf090b5

8 files changed

Lines changed: 246 additions & 40 deletions

File tree

benchmark/stringify.mjs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import benchmark from "cronometro";
2+
import qs from "qs";
3+
import fastQueryString from "../lib/index.js";
4+
import native from "node:querystring";
5+
import queryString from "query-string";
6+
7+
const value = {
8+
frappucino: "muffin",
9+
goat: "scone",
10+
pond: "moose",
11+
foo: ["bar", "baz"],
12+
};
13+
14+
await benchmark(
15+
{
16+
qs() {
17+
return qs.stringify(value);
18+
},
19+
"fast-querystring"() {
20+
return fastQueryString.stringify(value);
21+
},
22+
"node:querystring"() {
23+
return native.stringify(value);
24+
},
25+
"query-string"() {
26+
return queryString.stringify(value);
27+
},
28+
URLSearchParams() {
29+
const urlParams = new URLSearchParams(value);
30+
return urlParams.toString();
31+
},
32+
},
33+
{ warmup: true, print: { compare: true, compareMode: "previous" } },
34+
);

lib/index.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
"use strict";
22

33
const parse = require("./parse");
4+
const stringify = require("./stringify");
45

56
module.exports = {
67
parse,
8+
stringify,
79
};

lib/internals/querystring.js

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
// This file is taken from Node.js project.
2+
// Full implementation can be found from https://github.com/nodejs/node/blob/main/lib/internal/querystring.js
3+
4+
const hexTable = Array.from(
5+
{ length: 256 },
6+
(_, i) => "%" + ((i < 16 ? "0" : "") + i.toString(16)).toUpperCase(),
7+
);
8+
9+
// These characters do not need escaping when generating query strings:
10+
// ! - . _ ~
11+
// ' ( ) *
12+
// digits
13+
// alpha (uppercase)
14+
// alpha (lowercase)
15+
// rome-ignore format: the array should not be formatted
16+
const noEscape = new Int8Array([
17+
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 0 - 15
18+
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 16 - 31
19+
0, 1, 0, 0, 0, 0, 0, 1, 1, 1, 1, 0, 0, 1, 1, 0, // 32 - 47
20+
1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, // 48 - 63
21+
0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, // 64 - 79
22+
1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 1, // 80 - 95
23+
0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, // 96 - 111
24+
1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 1, 0, // 112 - 127
25+
]);
26+
27+
/**
28+
* @param {string} str
29+
* @returns {string}
30+
*/
31+
function encodeString(str) {
32+
const len = str.length;
33+
if (len === 0) return "";
34+
35+
let out = "";
36+
let lastPos = 0;
37+
let i = 0;
38+
39+
outer: for (; i < len; i++) {
40+
let c = str.charCodeAt(i);
41+
42+
// ASCII
43+
while (c < 0x80) {
44+
if (noEscape[c] !== 1) {
45+
if (lastPos < i) out += str.slice(lastPos, i);
46+
lastPos = i + 1;
47+
out += hexTable[c];
48+
}
49+
50+
if (++i === len) break outer;
51+
52+
c = str.charCodeAt(i);
53+
}
54+
55+
if (lastPos < i) out += str.slice(lastPos, i);
56+
57+
// Multi-byte characters ...
58+
if (c < 0x800) {
59+
lastPos = i + 1;
60+
out += hexTable[0xC0 | (c >> 6)] + hexTable[0x80 | (c & 0x3F)];
61+
continue;
62+
}
63+
if (c < 0xD800 || c >= 0xE000) {
64+
lastPos = i + 1;
65+
out +=
66+
hexTable[0xE0 | (c >> 12)] +
67+
hexTable[0x80 | ((c >> 6) & 0x3F)] +
68+
hexTable[0x80 | (c & 0x3F)];
69+
continue;
70+
}
71+
// Surrogate pair
72+
++i;
73+
74+
// This branch should never happen because all URLSearchParams entries
75+
// should already be converted to USVString. But, included for
76+
// completion's sake anyway.
77+
if (i >= len) throw new Error("Invalid URI");
78+
79+
const c2 = str.charCodeAt(i) & 0x3FF;
80+
81+
lastPos = i + 1;
82+
c = 0x10000 + (((c & 0x3FF) << 10) | c2);
83+
out +=
84+
hexTable[0xF0 | (c >> 18)] +
85+
hexTable[0x80 | ((c >> 12) & 0x3F)] +
86+
hexTable[0x80 | ((c >> 6) & 0x3F)] +
87+
hexTable[0x80 | (c & 0x3F)];
88+
}
89+
if (lastPos === 0) return str;
90+
if (lastPos < len) return out + str.slice(lastPos);
91+
return out;
92+
}
93+
94+
module.exports = { encodeString };

lib/stringify.js

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
"use strict";
2+
3+
const { encodeString } = require("./internals/querystring");
4+
5+
function getAsPrimitive(value) {
6+
const type = typeof value;
7+
8+
if (type === "string") {
9+
// Length check is handled inside encodeString function
10+
return encodeString(value);
11+
} else if (type === "number" && Number.isFinite(value)) {
12+
if (Math.abs(value) < 1e21) return value + "";
13+
return encodeString(value);
14+
} else if (type === "bigint") {
15+
return value + "";
16+
} else if (type === "boolean") {
17+
return value ? "true" : "false";
18+
}
19+
20+
return "";
21+
}
22+
23+
/**
24+
* @callback
25+
* @param {any} input
26+
* @return {string}
27+
*/
28+
function stringify(input) {
29+
let result = "";
30+
31+
const keys = Object.keys(input);
32+
const keyLength = keys.length;
33+
34+
for (let i = 0; i < keyLength; i++) {
35+
const key = keys[i];
36+
const value = input[key];
37+
const encodedKey = encodeString(key);
38+
39+
if (i) {
40+
result += "&";
41+
}
42+
43+
if (value.pop) {
44+
const valueLength = value.length;
45+
for (let j = 0; j < valueLength; j++) {
46+
if (j) {
47+
result += "&";
48+
}
49+
50+
result += encodedKey + "=" + getAsPrimitive(value[j]);
51+
}
52+
} else {
53+
result += encodedKey + "=" + getAsPrimitive(value);
54+
}
55+
}
56+
57+
return result;
58+
}
59+
60+
module.exports = stringify;

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@
1313
"test": "vitest",
1414
"test:watch": "vitest --watch",
1515
"coverage": "vitest run --coverage",
16-
"benchmark": "node benchmark/parse.mjs"
16+
"benchmark:parse": "node benchmark/parse.mjs",
17+
"benchmark:stringify": "node benchmark/stringify.mjs"
1718
},
1819
"keywords": [
1920
"querystring",
Lines changed: 3 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
import qs from "../lib";
2-
import { test, assert } from "vitest";
31
import vm from "node:vm";
42

53
function createWithNoPrototype(properties) {
@@ -9,9 +7,8 @@ function createWithNoPrototype(properties) {
97
});
108
return noProto;
119
}
12-
13-
const foreignObject = vm.runInNewContext('({"foo": ["bar", "baz"]})');
14-
const qsNoMungeTestCases = [
10+
export const foreignObject = vm.runInNewContext('({"foo": ["bar", "baz"]})');
11+
export const qsNoMungeTestCases = [
1512
["", {}],
1613
["foo=bar&foo=baz", { foo: ["bar", "baz"] }],
1714
["foo=bar&foo=baz", foreignObject],
@@ -27,7 +24,7 @@ const qsNoMungeTestCases = [
2724
],
2825
["trololol=yes&lololo=no", { trololol: "yes", lololo: "no" }],
2926
];
30-
const qsTestCases = [
27+
export const qsTestCases = [
3128
[
3229
"__proto__=1",
3330
"__proto__=1",
@@ -115,36 +112,3 @@ const qsTestCases = [
115112
[null, "", {}],
116113
[undefined, "", {}],
117114
];
118-
119-
test("should parse the basics", () => {
120-
qsNoMungeTestCases.forEach((t) => {
121-
assert.deepEqual(qs.parse(t[0]), t[1]);
122-
});
123-
});
124-
125-
test("should succeed on node.js tests", () => {
126-
qsTestCases.forEach((t) => {
127-
assert.deepEqual(qs.parse(t[0]), t[2], t[0]);
128-
});
129-
});
130-
131-
test("handles & on first/last character", () => {
132-
assert.deepEqual(qs.parse("&hello=world"), { hello: "world" });
133-
assert.deepEqual(qs.parse("hello=world&"), { hello: "world" });
134-
});
135-
136-
test("handles ? on first character", () => {
137-
// This aligns with `node:querystring` functionality
138-
assert.deepEqual(qs.parse("?hello=world"), { "?hello": "world" });
139-
});
140-
141-
test("handles + character", () => {
142-
assert.deepEqual(qs.parse("author=Yagiz+Nizipli"), {
143-
author: "Yagiz Nizipli",
144-
});
145-
});
146-
147-
test("should accept pairs with missing values", () => {
148-
assert.deepEqual(qs.parse("foo=bar&hey"), { foo: "bar", hey: "" });
149-
assert.deepEqual(qs.parse("hey"), { hey: "" });
150-
});

test/parse.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import qs from "../lib";
2+
import { test, assert } from "vitest";
3+
import { qsNoMungeTestCases, qsTestCases } from "./node";
4+
5+
test("should parse the basics", () => {
6+
qsNoMungeTestCases.forEach((t) => {
7+
assert.deepEqual(qs.parse(t[0]), t[1]);
8+
});
9+
});
10+
11+
test("should succeed on node.js tests", () => {
12+
qsTestCases.forEach((t) => {
13+
assert.deepEqual(qs.parse(t[0]), t[2]);
14+
});
15+
});
16+
17+
test("handles & on first/last character", () => {
18+
assert.deepEqual(qs.parse("&hello=world"), { hello: "world" });
19+
assert.deepEqual(qs.parse("hello=world&"), { hello: "world" });
20+
});
21+
22+
test("handles ? on first character", () => {
23+
// This aligns with `node:querystring` functionality
24+
assert.deepEqual(qs.parse("?hello=world"), { "?hello": "world" });
25+
});
26+
27+
test("handles + character", () => {
28+
assert.deepEqual(qs.parse("author=Yagiz+Nizipli"), {
29+
author: "Yagiz Nizipli",
30+
});
31+
});
32+
33+
test("should accept pairs with missing values", () => {
34+
assert.deepEqual(qs.parse("foo=bar&hey"), { foo: "bar", hey: "" });
35+
assert.deepEqual(qs.parse("hey"), { hey: "" });
36+
});

test/stringify.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { qsNoMungeTestCases, qsTestCases } from "./node";
2+
import qs from "../lib";
3+
import { test, assert } from "vitest";
4+
5+
test("should stringify the basics", () => {
6+
qsNoMungeTestCases.forEach((t) => {
7+
assert.deepEqual(qs.stringify(t[1]), t[0]);
8+
});
9+
});
10+
11+
test("should succeed on node.js tests", () => {
12+
qsTestCases.forEach((t) => {
13+
assert.deepEqual(qs.stringify(t[2]), t[1]);
14+
});
15+
});

0 commit comments

Comments
 (0)