From 9f2841c139bd5bbba8e603ee5c273555ac40e946 Mon Sep 17 00:00:00 2001 From: ninevra Date: Tue, 2 Mar 2021 03:44:04 -0800 Subject: [PATCH] Add option to prefer decoding as Map (#95) * Test preferMap option * Add preferMap option to always decode as Map * Don't warn on string-keyed Maps when preferMap set * Document preferMap option * Polyfill Object.fromEntries() in tests for node 10 --- README.md | 1 + index.js | 5 ++-- lib/decoder.js | 27 ++++++++++-------- lib/encoder.js | 6 ++-- test/prefer-map.js | 71 ++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 94 insertions(+), 16 deletions(-) create mode 100644 test/prefer-map.js diff --git a/README.md b/README.md index 198ada0..e8d5a49 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,7 @@ options: - `sortKeys`, a boolean to force a determinate keys order - `compatibilityMode`, a boolean that enables "compatibility mode" which doesn't use str 8 format. Defaults to false. - `disableTimestampEncoding`, a boolean that when set disables the encoding of Dates into the [timestamp extension type](https://github.com/msgpack/msgpack/blob/master/spec.md#timestamp-extension-type). Defaults to false. +- `preferMap`, a boolean that forces all maps to be decoded to `Map`s rather than plain objects. This ensures that `decode(encode(new Map())) instanceof Map` and that iteration order is preserved. Defaults to false. ------------------------------------------------------- diff --git a/index.js b/index.js index f21c75f..952c866 100644 --- a/index.js +++ b/index.js @@ -18,7 +18,8 @@ function msgpack (options) { compatibilityMode: false, // if true, skips encoding Dates using the msgpack // timestamp ext format (-1) - disableTimestampEncoding: false + disableTimestampEncoding: false, + preferMap: false } decodingTypes.set(DateCodec.type, DateCodec.decode) @@ -72,7 +73,7 @@ function msgpack (options) { return { encode: buildEncode(encodingTypes, options), - decode: buildDecode(decodingTypes), + decode: buildDecode(decodingTypes, options), register, registerEncoder, registerDecoder, diff --git a/lib/decoder.js b/lib/decoder.js index 16fe215..3db580a 100644 --- a/lib/decoder.js +++ b/lib/decoder.js @@ -37,7 +37,7 @@ function isValidDataSize (dataLength, bufLength, headerLength) { return bufLength >= headerLength + dataLength } -module.exports = function buildDecode (decodingTypes) { +module.exports = function buildDecode (decodingTypes, options) { return decode function decode (buf) { @@ -72,13 +72,13 @@ module.exports = function buildDecode (decodingTypes) { if ((first & 0xf0) === 0x80) { const length = first & 0x0f const headerSize = offset - initialOffset - // we have an array with less than 15 elements - return decodeMap(buf, offset, length, headerSize) + // we have a map with less than 15 elements + return decodeMap(buf, offset, length, headerSize, options) } if ((first & 0xf0) === 0x90) { const length = first & 0x0f const headerSize = offset - initialOffset - // we have a map with less than 15 elements + // we have an array with less than 15 elements return decodeArray(buf, offset, length, headerSize) } @@ -138,12 +138,12 @@ module.exports = function buildDecode (decodingTypes) { length = buf.readUInt16BE(offset) offset += 2 // console.log(offset - initialOffset) - return decodeMap(buf, offset, length, 3) + return decodeMap(buf, offset, length, 3, options) case 0xdf: length = buf.readUInt32BE(offset) offset += 4 - return decodeMap(buf, offset, length, 5) + return decodeMap(buf, offset, length, 5, options) } } if (first >= 0xe0) return [first - 0x100, 1] // 5 bits negative ints @@ -166,16 +166,19 @@ module.exports = function buildDecode (decodingTypes) { return [result, headerLength + offset - initialOffset] } - function decodeMap (buf, offset, length, headerLength) { + function decodeMap (buf, offset, length, headerLength, options) { const _temp = decodeArray(buf, offset, 2 * length, headerLength) if (!_temp) return null const [ result, consumedBytes ] = _temp - var isPlainObject = true - for (let i = 0; i < 2 * length; i += 2) { - if (typeof result[i] !== 'string') { - isPlainObject = false - break + var isPlainObject = !options.preferMap + + if (isPlainObject) { + for (let i = 0; i < 2 * length; i += 2) { + if (typeof result[i] !== 'string') { + isPlainObject = false + break + } } } diff --git a/lib/encoder.js b/lib/encoder.js index ad23d36..c812e5c 100644 --- a/lib/encoder.js +++ b/lib/encoder.js @@ -62,8 +62,10 @@ function encodeMap (map, options, encode) { const acc = [ getHeader(map.size, 0x80, 0xde) ] const keys = [ ...map.keys() ] - if (keys.every(item => typeof item === 'string')) { - console.warn('Map with string only keys will be deserialized as an object!') + if (!options.preferMap) { + if (keys.every(item => typeof item === 'string')) { + console.warn('Map with string only keys will be deserialized as an object!') + } } keys.forEach(key => { diff --git a/test/prefer-map.js b/test/prefer-map.js new file mode 100644 index 0000000..0d56fa3 --- /dev/null +++ b/test/prefer-map.js @@ -0,0 +1,71 @@ +const test = require('tape').test +const msgpack = require('../') + +const map = new Map() + .set('a', 1) + .set('1', 'hello') + .set('world', 2) + .set('0', 'again') + .set('01', null) + +test('round-trip string-keyed Maps', function (t) { + const encoder = msgpack({preferMap: true}) + + for (const input of [new Map(), map]) { + const result = encoder.decode(encoder.encode(input)) + t.assert(result instanceof Map) + t.deepEqual(result, input) + } + + t.end() +}) + +test('preserve iteration order of string-keyed Maps', function (t) { + const encoder = msgpack({preferMap: true}) + const decoded = encoder.decode(encoder.encode(map)) + + t.deepEqual([...decoded.keys()], [...map.keys()]) + + t.end() +}) + +test('user can still encode objects as ext maps', function (t) { + const encoder = msgpack({preferMap: true}) + const tag = 0x42 + + // Polyfill Object.fromEntries for node 10 + const fromEntries = Object.fromEntries || (iterable => { + const object = {} + for (const [property, value] of iterable) { + object[property] = value + } + return object + }) + + encoder.register( + tag, + Object, + obj => encoder.encode(new Map(Object.entries(obj))), + data => fromEntries(encoder.decode(data)) + ) + + const inputs = [ + {}, + new Map(), + {foo: 'bar'}, + new Map().set('foo', 'bar'), + new Map().set(null, null), + {0: 'baz'}, + ['baz'] + ] + + for (const input of inputs) { + const buf = encoder.encode(input) + const result = encoder.decode(buf) + + t.deepEqual(result, input) + t.equal(Object.getPrototypeOf(result), Object.getPrototypeOf(input)) + } + + t.end() +})