Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
46 changes: 45 additions & 1 deletion src/Actions/Generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } = {};
Expand All @@ -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 });
}
Expand Down
90 changes: 90 additions & 0 deletions test/attribute_map.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]},
Expand Down
Loading