Skip to content

Commit 4e2f525

Browse files
authored
feat(lib-dynamodb): add support for imprecise numbers and custom number retrieval (#6644)
1 parent 17b37b7 commit 4e2f525

File tree

7 files changed

+130
-14
lines changed

7 files changed

+130
-14
lines changed

Diff for: lib/lib-dynamodb/README.md

+63-3
Original file line numberDiff line numberDiff line change
@@ -120,15 +120,26 @@ export interface marshallOptions {
120120
* but false if directly using the marshall function (backwards compatibility).
121121
*/
122122
convertTopLevelContainer?: boolean;
123+
/**
124+
* Whether to allow numbers beyond Number.MAX_SAFE_INTEGER during marshalling.
125+
* When set to true, allows numbers that may lose precision when converted to JavaScript numbers.
126+
* When false (default), throws an error if a number exceeds Number.MAX_SAFE_INTEGER to prevent
127+
* unintended loss of precision. Consider using the NumberValue type from @aws-sdk/lib-dynamodb
128+
* for precise handling of large numbers.
129+
*/
130+
allowImpreciseNumbers?: boolean;
123131
}
124132

125133
export interface unmarshallOptions {
126134
/**
127-
* Whether to return numbers as a string instead of converting them to native JavaScript numbers.
135+
* Whether to modify how numbers are unmarshalled from DynamoDB.
136+
* When set to true, returns numbers as NumberValue instances instead of native JavaScript numbers.
128137
* This allows for the safe round-trip transport of numbers of arbitrary size.
138+
*
139+
* If a function is provided, it will be called with the string representation of numbers to handle
140+
* custom conversions (e.g., using BigInt or decimal libraries).
129141
*/
130-
wrapNumbers?: boolean;
131-
142+
wrapNumbers?: boolean | ((value: string) => number | bigint | NumberValue | any);
132143
/**
133144
* When true, skip wrapping the data in `{ M: data }` before converting.
134145
*
@@ -235,10 +246,59 @@ const response = await client.get({
235246
const value = response.Item.bigNumber;
236247
```
237248

249+
You can also provide a custom function to handle number conversion during unmarshalling:
250+
251+
```typescript
252+
const client = DynamoDBDocument.from(new DynamoDB({}), {
253+
unmarshallOptions: {
254+
// Use BigInt for all numbers
255+
wrapNumbers: (str) => BigInt(str),
256+
},
257+
});
258+
259+
const response = await client.get({
260+
Key: { id: 1 },
261+
});
262+
263+
// Numbers in response will be BigInt instead of NumberValue or regular numbers
264+
```
265+
238266
`NumberValue` does not provide a way to do mathematical operations on itself.
239267
To do mathematical operations, take the string value of `NumberValue` by calling
240268
`.toString()` and supply it to your chosen big number implementation.
241269

270+
The client protects against precision loss by throwing an error on large numbers, but you can either
271+
allow imprecise values with `allowImpreciseNumbers` or maintain exact precision using `NumberValue`.
272+
273+
```typescript
274+
const preciseValue = "34567890123456789012345678901234567890";
275+
276+
// 1. Default behavior - will throw error
277+
await client.send(
278+
new PutCommand({
279+
TableName: "Table",
280+
Item: {
281+
id: "1",
282+
number: Number(preciseValue), // Throws error: Number is greater than Number.MAX_SAFE_INTEGER
283+
},
284+
})
285+
);
286+
287+
// 2. Using allowImpreciseNumbers - will store but loses precision (mimics the v2 implicit behavior)
288+
const impreciseClient = DynamoDBDocumentClient.from(new DynamoDBClient({}), {
289+
marshallOptions: { allowImpreciseNumbers: true },
290+
});
291+
await impreciseClient.send(
292+
new PutCommand({
293+
TableName: "Table",
294+
Item: {
295+
id: "2",
296+
number: Number(preciseValue), // Loses precision 34567890123456790000000000000000000000n
297+
},
298+
})
299+
);
300+
```
301+
242302
### Client and Command middleware stacks
243303

244304
As with other AWS SDK for JavaScript v3 clients, you can apply middleware functions

Diff for: packages/util-dynamodb/src/convertToAttr.spec.ts

+28
Original file line numberDiff line numberDiff line change
@@ -595,4 +595,32 @@ describe("convertToAttr", () => {
595595
expect(convertToAttr(new Date(), { convertClassInstanceToMap: true })).toEqual({ M: {} });
596596
});
597597
});
598+
599+
describe("imprecise numbers", () => {
600+
const impreciseNumbers = [
601+
{ val: 1.23e40, str: "1.23e+40" }, // https://github.com/aws/aws-sdk-js-v3/issues/6571
602+
{ val: Number.MAX_VALUE, str: Number.MAX_VALUE.toString() },
603+
{ val: Number.MAX_SAFE_INTEGER + 1, str: (Number.MAX_SAFE_INTEGER + 1).toString() },
604+
];
605+
606+
describe("without allowImpreciseNumbers", () => {
607+
impreciseNumbers.forEach(({ val }) => {
608+
it(`throws for imprecise number: ${val}`, () => {
609+
expect(() => {
610+
convertToAttr(val);
611+
}).toThrowError(
612+
`Number ${val.toString()} is greater than Number.MAX_SAFE_INTEGER. Use NumberValue from @aws-sdk/lib-dynamodb.`
613+
);
614+
});
615+
});
616+
});
617+
618+
describe("with allowImpreciseNumbers", () => {
619+
impreciseNumbers.forEach(({ val, str }) => {
620+
it(`allows imprecise number: ${val}`, () => {
621+
expect(convertToAttr(val, { allowImpreciseNumbers: true })).toEqual({ N: str });
622+
});
623+
});
624+
});
625+
});
598626
});

Diff for: packages/util-dynamodb/src/convertToAttr.ts

+10-7
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ export const convertToAttr = (data: NativeAttributeValue, options?: marshallOpti
3737
} else if (typeof data === "boolean" || data?.constructor?.name === "Boolean") {
3838
return { BOOL: data.valueOf() };
3939
} else if (typeof data === "number" || data?.constructor?.name === "Number") {
40-
return convertToNumberAttr(data);
40+
return convertToNumberAttr(data, options);
4141
} else if (data instanceof NumberValue) {
4242
return data.toAttributeValue();
4343
} else if (typeof data === "bigint") {
@@ -91,7 +91,7 @@ const convertToSetAttr = (
9191
} else if (typeof item === "number") {
9292
return {
9393
NS: Array.from(setToOperate)
94-
.map(convertToNumberAttr)
94+
.map((num) => convertToNumberAttr(num, options))
9595
.map((item) => item.N),
9696
};
9797
} else if (typeof item === "bigint") {
@@ -160,17 +160,20 @@ const validateBigIntAndThrow = (errorPrefix: string) => {
160160
throw new Error(`${errorPrefix} Use NumberValue from @aws-sdk/lib-dynamodb.`);
161161
};
162162

163-
const convertToNumberAttr = (num: number | Number): { N: string } => {
163+
const convertToNumberAttr = (num: number | Number, options?: marshallOptions): { N: string } => {
164164
if (
165165
[Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY]
166166
.map((val) => val.toString())
167167
.includes(num.toString())
168168
) {
169169
throw new Error(`Special numeric value ${num.toString()} is not allowed`);
170-
} else if (num > Number.MAX_SAFE_INTEGER) {
171-
validateBigIntAndThrow(`Number ${num.toString()} is greater than Number.MAX_SAFE_INTEGER.`);
172-
} else if (num < Number.MIN_SAFE_INTEGER) {
173-
validateBigIntAndThrow(`Number ${num.toString()} is lesser than Number.MIN_SAFE_INTEGER.`);
170+
} else if (!options?.allowImpreciseNumbers) {
171+
// Only perform these checks if allowImpreciseNumbers is false
172+
if (num > Number.MAX_SAFE_INTEGER) {
173+
validateBigIntAndThrow(`Number ${num.toString()} is greater than Number.MAX_SAFE_INTEGER.`);
174+
} else if (num < Number.MIN_SAFE_INTEGER) {
175+
validateBigIntAndThrow(`Number ${num.toString()} is lesser than Number.MIN_SAFE_INTEGER.`);
176+
}
174177
}
175178
return { N: num.toString() };
176179
};

Diff for: packages/util-dynamodb/src/convertToNative.spec.ts

+11
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,17 @@ describe("convertToNative", () => {
9292
}).toThrowError(`${numString} can't be converted to BigInt. Set options.wrapNumbers to get string value.`);
9393
});
9494
});
95+
96+
it("handles custom wrapNumbers function", () => {
97+
expect(
98+
convertToNative(
99+
{ N: "124" },
100+
{
101+
wrapNumbers: (str: string) => Number(str) / 2,
102+
}
103+
)
104+
).toEqual(62);
105+
});
95106
});
96107

97108
describe("binary", () => {

Diff for: packages/util-dynamodb/src/convertToNative.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -43,10 +43,12 @@ export const convertToNative = (data: AttributeValue, options?: unmarshallOption
4343
};
4444

4545
const convertNumber = (numString: string, options?: unmarshallOptions): number | bigint | NumberValue => {
46+
if (typeof options?.wrapNumbers === "function") {
47+
return options?.wrapNumbers(numString);
48+
}
4649
if (options?.wrapNumbers) {
4750
return NumberValue.from(numString);
4851
}
49-
5052
const num = Number(numString);
5153
const infinityValues = [Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY];
5254
const isLargeFiniteNumber =

Diff for: packages/util-dynamodb/src/marshall.ts

+8
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,14 @@ export interface marshallOptions {
2828
* but false if directly using the marshall function (backwards compatibility).
2929
*/
3030
convertTopLevelContainer?: boolean;
31+
/**
32+
* Whether to allow numbers beyond Number.MAX_SAFE_INTEGER during marshalling.
33+
* When set to true, allows numbers that may lose precision when converted to JavaScript numbers.
34+
* When false (default), throws an error if a number exceeds Number.MAX_SAFE_INTEGER to prevent
35+
* unintended loss of precision. Consider using the NumberValue type from @aws-sdk/lib-dynamodb
36+
* for precise handling of large numbers.
37+
*/
38+
allowImpreciseNumbers?: boolean;
3139
}
3240

3341
/**

Diff for: packages/util-dynamodb/src/unmarshall.ts

+7-3
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,21 @@ import { AttributeValue } from "@aws-sdk/client-dynamodb";
22

33
import { convertToNative } from "./convertToNative";
44
import { NativeAttributeValue } from "./models";
5+
import { NumberValue } from "./NumberValue";
56

67
/**
78
* An optional configuration object for `convertToNative`
89
*/
910
export interface unmarshallOptions {
1011
/**
11-
* Whether to return numbers as a string instead of converting them to native JavaScript numbers.
12+
* Whether to modify how numbers are unmarshalled from DynamoDB.
13+
* When set to true, returns numbers as NumberValue instances instead of native JavaScript numbers.
1214
* This allows for the safe round-trip transport of numbers of arbitrary size.
15+
*
16+
* If a function is provided, it will be called with the string representation of numbers to handle
17+
* custom conversions (e.g., using BigInt or decimal libraries).
1318
*/
14-
wrapNumbers?: boolean;
15-
19+
wrapNumbers?: boolean | ((value: string) => number | bigint | NumberValue | any);
1620
/**
1721
* When true, skip wrapping the data in `{ M: data }` before converting.
1822
*

0 commit comments

Comments
 (0)