From 3858e7df414512edcfebb1434316c115cf1d2e33 Mon Sep 17 00:00:00 2001 From: "Rob Moore (MakerX)" Date: Tue, 30 Jan 2024 22:22:12 +0800 Subject: [PATCH] feat: Added ability to enforce numeric map keys are encoded as numbers rather than strings --- README.md | 1 + src/Encoder.ts | 31 +++++- test/encode-decode-map-number-keys.test.ts | 111 +++++++++++++++++++++ 3 files changed, 139 insertions(+), 4 deletions(-) create mode 100644 test/encode-decode-map-number-keys.test.ts diff --git a/README.md b/README.md index 5a213af..2fbd65c 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,7 @@ console.log(buffer); | sortKeys | boolean | false | | forceFloat32 | boolean | false | | forceIntegerToFloat | boolean | false | +| forceNumericMapKeys | boolean | false | | ignoreUndefined | boolean | false | To skip UTF-8 decoding of strings, `useRawBinaryStrings` can be set to `true`. In this case, strings are decoded into `Uint8Array`. diff --git a/src/Encoder.ts b/src/Encoder.ts index 8c774cd..ec89e72 100644 --- a/src/Encoder.ts +++ b/src/Encoder.ts @@ -53,6 +53,13 @@ export type EncoderOptions = Partial< */ forceFloat32: boolean; + /** + * If `true`, numeric map keys will be encoded as ints/floats (as appropriate) rather than strings (the default). + * + * Defaults to `false`. + */ + forceNumericMapKeys: boolean; + /** * If `true`, an object property with `undefined` value are ignored. * e.g. `{ foo: undefined }` will be encoded as `{}`, as `JSON.stringify()` does. @@ -82,6 +89,7 @@ export class Encoder { private readonly forceFloat32: boolean; private readonly ignoreUndefined: boolean; private readonly forceIntegerToFloat: boolean; + private readonly forceNumericMapKeys: boolean; private pos: number; private view: DataView; @@ -98,6 +106,7 @@ export class Encoder { this.forceFloat32 = options?.forceFloat32 ?? false; this.ignoreUndefined = options?.ignoreUndefined ?? false; this.forceIntegerToFloat = options?.forceIntegerToFloat ?? false; + this.forceNumericMapKeys = options?.forceNumericMapKeys ?? false; this.pos = 0; this.view = new DataView(new ArrayBuffer(this.initialBufferSize)); @@ -383,13 +392,23 @@ export class Encoder { return count; } + private isNumber(value: string | number) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + return !isNaN(value as any) && !isNaN(parseFloat(value as any)); + } + private encodeMap(object: Record, depth: number) { - const keys = Object.keys(object); + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + const keys = Object.keys(object).map((key) => (this.forceNumericMapKeys && this.isNumber(key) ? Number(key) : key)); if (this.sortKeys) { - keys.sort(); + if (keys.filter((k) => typeof k === "number").length > 0) { + keys.sort().sort((a, b) => +a - +b); + } else { + keys.sort(); + } } - const size = this.ignoreUndefined ? this.countWithoutUndefined(object, keys) : keys.length; + const size = this.ignoreUndefined ? this.countWithoutUndefined(object, Object.keys(object)) : keys.length; if (size < 16) { // fixmap @@ -410,7 +429,11 @@ export class Encoder { const value = object[key]; if (!(this.ignoreUndefined && value === undefined)) { - this.encodeString(key); + if (typeof key === "string") { + this.encodeString(key); + } else { + this.encodeNumber(key); + } this.doEncode(value, depth + 1); } } diff --git a/test/encode-decode-map-number-keys.test.ts b/test/encode-decode-map-number-keys.test.ts new file mode 100644 index 0000000..befb9cc --- /dev/null +++ b/test/encode-decode-map-number-keys.test.ts @@ -0,0 +1,111 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import { deepStrictEqual, equal } from "assert"; +import { encode } from "../src/encode"; +import { decode } from "../src/decode"; + +const exampleMap = { + 1: "1", + "102": "102", + a: "a", + 20: "20", +} as Record; + +function getExpectedMsgPack( + key1: Uint8Array, + value1: Uint8Array, + key2: Uint8Array, + value2: Uint8Array, + key3: Uint8Array, + value3: Uint8Array, + key4: Uint8Array, + value4: Uint8Array, +) { + return new Uint8Array([ + // fixmap of length 4: https://github.com/msgpack/msgpack/blob/master/spec.md#map-format-family + 0b10000100, + ...key1, + ...value1, + ...key2, + ...value2, + ...key3, + ...value3, + ...key4, + ...value4, + ]); +} + +describe("map-with-number-keys", () => { + it(`encodes numeric keys as numbers when forced`, () => { + const expected = getExpectedMsgPack( + // This is the order Object.keys returns + encode(1), + encode("1"), + encode(20), + encode("20"), + encode(102), + encode("102"), + encode("a"), + encode("a"), + ); + + const encoded = encode(exampleMap, { forceNumericMapKeys: true }); + + equal(Buffer.from(encoded).toString("hex"), Buffer.from(expected).toString("hex")); + deepStrictEqual(decode(encoded), exampleMap); + }); + + it(`encodes numeric keys as strings when not forced`, () => { + const expected = getExpectedMsgPack( + // This is the order Object.keys returns + encode("1"), + encode("1"), + encode("20"), + encode("20"), + encode("102"), + encode("102"), + encode("a"), + encode("a"), + ); + + const encoded = encode(exampleMap); + + equal(Buffer.from(encoded).toString("hex"), Buffer.from(expected).toString("hex")); + deepStrictEqual(decode(encoded), exampleMap); + }); + + it(`encodes numeric keys as strings with sorting`, () => { + const expected = getExpectedMsgPack( + encode("1"), + encode("1"), + encode("102"), + encode("102"), + encode("20"), + encode("20"), + encode("a"), + encode("a"), + ); + + const encoded = encode(exampleMap, { sortKeys: true }); + + equal(Buffer.from(encoded).toString("hex"), Buffer.from(expected).toString("hex")); + deepStrictEqual(decode(encoded), exampleMap); + }); + + it(`encodes numeric keys as numbers with sorting`, () => { + const expected = getExpectedMsgPack( + encode(1), + encode("1"), + encode(20), + encode("20"), + encode(102), + encode("102"), + encode("a"), + encode("a"), + ); + + const encoded = encode(exampleMap, { sortKeys: true, forceNumericMapKeys: true }); + + equal(Buffer.from(encoded).toString("hex"), Buffer.from(expected).toString("hex")); + deepStrictEqual(decode(encoded), exampleMap); + }); +});