Skip to content

Commit 3858e7d

Browse files
committed
feat: Added ability to enforce numeric map keys are encoded as numbers rather than strings
1 parent 0d6e172 commit 3858e7d

File tree

3 files changed

+139
-4
lines changed

3 files changed

+139
-4
lines changed

README.md

+1
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,7 @@ console.log(buffer);
121121
| sortKeys | boolean | false |
122122
| forceFloat32 | boolean | false |
123123
| forceIntegerToFloat | boolean | false |
124+
| forceNumericMapKeys | boolean | false |
124125
| ignoreUndefined | boolean | false |
125126

126127
To skip UTF-8 decoding of strings, `useRawBinaryStrings` can be set to `true`. In this case, strings are decoded into `Uint8Array`.

src/Encoder.ts

+27-4
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,13 @@ export type EncoderOptions<ContextType = undefined> = Partial<
5353
*/
5454
forceFloat32: boolean;
5555

56+
/**
57+
* If `true`, numeric map keys will be encoded as ints/floats (as appropriate) rather than strings (the default).
58+
*
59+
* Defaults to `false`.
60+
*/
61+
forceNumericMapKeys: boolean;
62+
5663
/**
5764
* If `true`, an object property with `undefined` value are ignored.
5865
* e.g. `{ foo: undefined }` will be encoded as `{}`, as `JSON.stringify()` does.
@@ -82,6 +89,7 @@ export class Encoder<ContextType = undefined> {
8289
private readonly forceFloat32: boolean;
8390
private readonly ignoreUndefined: boolean;
8491
private readonly forceIntegerToFloat: boolean;
92+
private readonly forceNumericMapKeys: boolean;
8593

8694
private pos: number;
8795
private view: DataView;
@@ -98,6 +106,7 @@ export class Encoder<ContextType = undefined> {
98106
this.forceFloat32 = options?.forceFloat32 ?? false;
99107
this.ignoreUndefined = options?.ignoreUndefined ?? false;
100108
this.forceIntegerToFloat = options?.forceIntegerToFloat ?? false;
109+
this.forceNumericMapKeys = options?.forceNumericMapKeys ?? false;
101110

102111
this.pos = 0;
103112
this.view = new DataView(new ArrayBuffer(this.initialBufferSize));
@@ -383,13 +392,23 @@ export class Encoder<ContextType = undefined> {
383392
return count;
384393
}
385394

395+
private isNumber(value: string | number) {
396+
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
397+
return !isNaN(value as any) && !isNaN(parseFloat(value as any));
398+
}
399+
386400
private encodeMap(object: Record<string, unknown>, depth: number) {
387-
const keys = Object.keys(object);
401+
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
402+
const keys = Object.keys(object).map((key) => (this.forceNumericMapKeys && this.isNumber(key) ? Number(key) : key));
388403
if (this.sortKeys) {
389-
keys.sort();
404+
if (keys.filter((k) => typeof k === "number").length > 0) {
405+
keys.sort().sort((a, b) => +a - +b);
406+
} else {
407+
keys.sort();
408+
}
390409
}
391410

392-
const size = this.ignoreUndefined ? this.countWithoutUndefined(object, keys) : keys.length;
411+
const size = this.ignoreUndefined ? this.countWithoutUndefined(object, Object.keys(object)) : keys.length;
393412

394413
if (size < 16) {
395414
// fixmap
@@ -410,7 +429,11 @@ export class Encoder<ContextType = undefined> {
410429
const value = object[key];
411430

412431
if (!(this.ignoreUndefined && value === undefined)) {
413-
this.encodeString(key);
432+
if (typeof key === "string") {
433+
this.encodeString(key);
434+
} else {
435+
this.encodeNumber(key);
436+
}
414437
this.doEncode(value, depth + 1);
415438
}
416439
}
+111
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
/* eslint-disable @typescript-eslint/naming-convention */
2+
import { deepStrictEqual, equal } from "assert";
3+
import { encode } from "../src/encode";
4+
import { decode } from "../src/decode";
5+
6+
const exampleMap = {
7+
1: "1",
8+
"102": "102",
9+
a: "a",
10+
20: "20",
11+
} as Record<number | string, string>;
12+
13+
function getExpectedMsgPack(
14+
key1: Uint8Array,
15+
value1: Uint8Array,
16+
key2: Uint8Array,
17+
value2: Uint8Array,
18+
key3: Uint8Array,
19+
value3: Uint8Array,
20+
key4: Uint8Array,
21+
value4: Uint8Array,
22+
) {
23+
return new Uint8Array([
24+
// fixmap of length 4: https://github.com/msgpack/msgpack/blob/master/spec.md#map-format-family
25+
0b10000100,
26+
...key1,
27+
...value1,
28+
...key2,
29+
...value2,
30+
...key3,
31+
...value3,
32+
...key4,
33+
...value4,
34+
]);
35+
}
36+
37+
describe("map-with-number-keys", () => {
38+
it(`encodes numeric keys as numbers when forced`, () => {
39+
const expected = getExpectedMsgPack(
40+
// This is the order Object.keys returns
41+
encode(1),
42+
encode("1"),
43+
encode(20),
44+
encode("20"),
45+
encode(102),
46+
encode("102"),
47+
encode("a"),
48+
encode("a"),
49+
);
50+
51+
const encoded = encode(exampleMap, { forceNumericMapKeys: true });
52+
53+
equal(Buffer.from(encoded).toString("hex"), Buffer.from(expected).toString("hex"));
54+
deepStrictEqual(decode(encoded), exampleMap);
55+
});
56+
57+
it(`encodes numeric keys as strings when not forced`, () => {
58+
const expected = getExpectedMsgPack(
59+
// This is the order Object.keys returns
60+
encode("1"),
61+
encode("1"),
62+
encode("20"),
63+
encode("20"),
64+
encode("102"),
65+
encode("102"),
66+
encode("a"),
67+
encode("a"),
68+
);
69+
70+
const encoded = encode(exampleMap);
71+
72+
equal(Buffer.from(encoded).toString("hex"), Buffer.from(expected).toString("hex"));
73+
deepStrictEqual(decode(encoded), exampleMap);
74+
});
75+
76+
it(`encodes numeric keys as strings with sorting`, () => {
77+
const expected = getExpectedMsgPack(
78+
encode("1"),
79+
encode("1"),
80+
encode("102"),
81+
encode("102"),
82+
encode("20"),
83+
encode("20"),
84+
encode("a"),
85+
encode("a"),
86+
);
87+
88+
const encoded = encode(exampleMap, { sortKeys: true });
89+
90+
equal(Buffer.from(encoded).toString("hex"), Buffer.from(expected).toString("hex"));
91+
deepStrictEqual(decode(encoded), exampleMap);
92+
});
93+
94+
it(`encodes numeric keys as numbers with sorting`, () => {
95+
const expected = getExpectedMsgPack(
96+
encode(1),
97+
encode("1"),
98+
encode(20),
99+
encode("20"),
100+
encode(102),
101+
encode("102"),
102+
encode("a"),
103+
encode("a"),
104+
);
105+
106+
const encoded = encode(exampleMap, { sortKeys: true, forceNumericMapKeys: true });
107+
108+
equal(Buffer.from(encoded).toString("hex"), Buffer.from(expected).toString("hex"));
109+
deepStrictEqual(decode(encoded), exampleMap);
110+
});
111+
});

0 commit comments

Comments
 (0)