diff --git a/README.md b/README.md index 16e0bac..de30d5f 100644 --- a/README.md +++ b/README.md @@ -189,6 +189,29 @@ const decoded = deserialize(encoded, schema); // decoded deep-equals the input object ``` +`convertAttributeMapToObject` handles the other raw shape, the attribute map that contract action data carries. It turns one, in either entry shape, into a plain key/value object, and it settles the JSON type of every value it copies over: + +| Attribute type | Value in the returned object | +| --- | --- | +| `float32`, `float64` (schema `float`, `double`) | A number. A value that arrives as a string is read back into a number, and a `float32` rounds to the nearest float32: the chain value when the string kept full precision, and for a float32 below 1 handed over as a seven-decimal string the nearest float32 of that string, which can differ from the chain value by a few float32 steps. | +| `FLOAT_VEC`, `DOUBLE_VEC` (schema `float[]`, `double[]`) | An array of those numbers, element by element. | +| `uint64`, `int64`, `UINT64_VEC`, `INT64_VEC` (schema `uint64`, `int64`, `fixed64` and their vector forms) | A decimal string, so a value too large for a JavaScript number keeps every digit. | +| Every other type | What the codec decoded, unchanged. | + +A string that does not read as a finite number stays the string it is, under a float type too, and so does an empty or whitespace-only string, or a string outside the range its type represents, which is the range the Postgres `real` and `double precision` casts accept. + +## What's new in 2.2.0 + +Returns float attribute values as numbers when a decoder hands them over as strings. + +### Bug fixes + +- `convertAttributeMapToObject` returns a number for a `float32` or `float64` value that arrives as a string, and for the elements of a `FLOAT_VEC` or `DOUBLE_VEC`. A map objectified by `@wharfkit/antelope` carries those values as strings, so a consumer that passed one through published a string where the schema says a number; a `float32` rounds to the nearest float32 first, which is the chain value when the string kept full precision and the nearest float32 of a seven-decimal string otherwise. A string that does not read as a finite number, an empty string, and a string outside the range its type represents pass through unchanged. (#23) + +### Other changes + +- The README states the JSON type the helper returns for each attribute type: a number for `float` and `double` and their vectors, a decimal string for the 64-bit integers and their vectors, and the decoded value for everything else. (#23) + ## What's new in 2.1.1 Rejects a path segment that would send a read to the wrong route. diff --git a/src/Actions/Generator.ts b/src/Actions/Generator.ts index 675eda7..9350b0e 100644 --- a/src/Actions/Generator.ts +++ b/src/Actions/Generator.ts @@ -882,9 +882,39 @@ export function toAttributeMap(obj: any, schema: SchemaFormat): AttributeMap { return result; } +// Coerces one float32/float64 attribute value into a number. A value the +// caller already decoded to a number is returned as it is; a string is read as +// a number, and a float32 rounds to the nearest float32. That rounding gives +// the value the chain stored when the string kept full precision; a float32 +// below 1 handed over as a seven-decimal string rounds to the nearest float32 +// of that string, which can differ from the chain value by a few float32 +// steps. A string that does not read as a finite number, the empty string +// included, or whose float32 rounding overflows or underflows, is left alone, +// so a caller sees what it passed in rather than NaN, Infinity or a silent +// zero. +function coerceFloatValue(value: any, float32: boolean): any { + if (typeof value !== 'string' || value.trim() === '') { + return value; + } + + const parsed = Number(value); + const rounded = float32 ? Math.fround(parsed) : parsed; + + // A zero result is only right when the string itself is a zero. Number() + // already flattens a double underflow such as '1e-400' to 0, so the test + // reads the digits ahead of the exponent rather than the parsed value. + if (!Number.isFinite(rounded) || (rounded === 0 && /[1-9]/.test(value.split(/[eE]/)[0]))) { + return value; + } + + return rounded; +} + // Converts an on-chain AttributeMap (either entry shape) into a plain // key/value object. uint64/int64 values are stringified to avoid precision -// loss; uint64/int64 vector values are stringified element-wise. Every other +// loss; uint64/int64 vector values are stringified element-wise. float32 and +// float64 values, and their vector elements, are returned as numbers, because +// a map objectified by @wharfkit/antelope carries them as strings. Every other // variant passes through as decoded (numbers, number arrays, strings). export function convertAttributeMapToObject(data: DecodedAttributeMap): { [key: string]: any } { const result: { [key: string]: any } = {}; @@ -905,6 +935,20 @@ export function convertAttributeMapToObject(data: DecodedAttributeMap): { [key: writable: true, configurable: true, }); + } else if (['float32', 'float64'].indexOf(value[0]) >= 0) { + Object.defineProperty(result, key, { + value: coerceFloatValue(value[1], value[0] === 'float32'), + enumerable: true, + writable: true, + configurable: true, + }); + } else if (['FLOAT_VEC', 'DOUBLE_VEC'].indexOf(value[0]) >= 0) { + Object.defineProperty(result, key, { + value: (value[1] as any[]).map((entry) => coerceFloatValue(entry, value[0] === 'FLOAT_VEC')), + enumerable: true, + writable: true, + configurable: true, + }); } else { Object.defineProperty(result, key, { value: value[1], enumerable: true, writable: true, configurable: true }); } diff --git a/test/attribute_map.test.ts b/test/attribute_map.test.ts index eead73b..4d923f7 100644 --- a/test/attribute_map.test.ts +++ b/test/attribute_map.test.ts @@ -77,6 +77,96 @@ describe('convertAttributeMapToObject', () => { }); }); + // A map objectified by @wharfkit/antelope carries a float32 or float64 as a + // string, so the helper reads one back into the number the chain holds. + it('reads a float64 value that arrives as a string as a number', () => { + const data: DecodedAttributeMap = [ + {key: 'weight', value: ['float64', '92.13924923']} + ]; + + expect(convertAttributeMapToObject(data)).to.deep.equal({ + weight: 92.13924923 + }); + }); + + it('rounds a float32 value that arrives as a string to the nearest float32', () => { + const data: DecodedAttributeMap = [ + {key: 'ratio', value: ['float32', '1.0000001']} + ]; + + expect(convertAttributeMapToObject(data)).to.deep.equal({ + ratio: 1.0000001192092896 + }); + }); + + it('reads a zero string as the number zero', () => { + const data: DecodedAttributeMap = [ + {key: 'weight', value: ['float64', '0']}, + {key: 'ratio', value: ['float32', '0.0000']} + ]; + + expect(convertAttributeMapToObject(data)).to.deep.equal({ + weight: 0, + ratio: 0 + }); + }); + + it('leaves a float value that is already a number alone', () => { + const data: DecodedAttributeMap = [ + {first: 'ratio', second: ['float64', 0.75]}, + {key: 'tenth', value: ['float32', 0.1]} + ]; + + expect(convertAttributeMapToObject(data)).to.deep.equal({ + ratio: 0.75, + tenth: 0.1 + }); + }); + + it('reads float vector elements as numbers', () => { + const data: DecodedAttributeMap = [ + {key: 'weights', value: ['DOUBLE_VEC', ['1.5', 2]]}, + {first: 'ratios', second: ['FLOAT_VEC', ['1.0000001', 0.5]]} + ]; + + expect(convertAttributeMapToObject(data)).to.deep.equal({ + weights: [1.5, 2], + ratios: [1.0000001192092896, 0.5] + }); + }); + + it('leaves a float string that is not a finite number alone', () => { + const data: DecodedAttributeMap = [ + {key: 'weight', value: ['float64', 'abc']}, + {key: 'blank', value: ['float64', '']}, + {key: 'spaces', value: ['float32', ' ']}, + {key: 'overflow', value: ['float32', '1e39']}, + {key: 'underflow', value: ['float32', '1e-50']}, + {key: 'wideOverflow', value: ['float64', '1e400']}, + {key: 'wideUnderflow', value: ['float64', '1e-400']} + ]; + + expect(convertAttributeMapToObject(data)).to.deep.equal({ + weight: 'abc', + blank: '', + spaces: ' ', + overflow: '1e39', + underflow: '1e-50', + wideOverflow: '1e400', + wideUnderflow: '1e-400' + }); + }); + + it('keeps a uint64 value a decimal string', () => { + const data: DecodedAttributeMap = [ + {key: 'mint', value: ['uint64', 7]} + ]; + + expect(convertAttributeMapToObject(data)).to.deep.equal({ + mint: '7' + }); + }); + it('passes through other types unchanged', () => { const data: DecodedAttributeMap = [ {key: 'active', value: ['bool', true]},