diff --git a/lib/lib-dynamodb/README.md b/lib/lib-dynamodb/README.md index d97cecacf984..9fed58f274b8 100644 --- a/lib/lib-dynamodb/README.md +++ b/lib/lib-dynamodb/README.md @@ -120,15 +120,26 @@ export interface marshallOptions { * but false if directly using the marshall function (backwards compatibility). */ convertTopLevelContainer?: boolean; + /** + * Whether to allow numbers beyond Number.MAX_SAFE_INTEGER during marshalling. + * When set to true, allows numbers that may lose precision when converted to JavaScript numbers. + * When false (default), throws an error if a number exceeds Number.MAX_SAFE_INTEGER to prevent + * unintended loss of precision. Consider using the NumberValue type from @aws-sdk/lib-dynamodb + * for precise handling of large numbers. + */ + allowImpreciseNumbers?: boolean; } export interface unmarshallOptions { /** - * Whether to return numbers as a string instead of converting them to native JavaScript numbers. + * Whether to modify how numbers are unmarshalled from DynamoDB. + * When set to true, returns numbers as NumberValue instances instead of native JavaScript numbers. * This allows for the safe round-trip transport of numbers of arbitrary size. + * + * If a function is provided, it will be called with the string representation of numbers to handle + * custom conversions (e.g., using BigInt or decimal libraries). */ - wrapNumbers?: boolean; - + wrapNumbers?: boolean | ((value: string) => number | bigint | NumberValue | any); /** * When true, skip wrapping the data in `{ M: data }` before converting. * @@ -235,10 +246,59 @@ const response = await client.get({ const value = response.Item.bigNumber; ``` +You can also provide a custom function to handle number conversion during unmarshalling: + +```typescript +const client = DynamoDBDocument.from(new DynamoDB({}), { + unmarshallOptions: { + // Use BigInt for all numbers + wrapNumbers: (str) => BigInt(str), + }, +}); + +const response = await client.get({ + Key: { id: 1 }, +}); + +// Numbers in response will be BigInt instead of NumberValue or regular numbers +``` + `NumberValue` does not provide a way to do mathematical operations on itself. To do mathematical operations, take the string value of `NumberValue` by calling `.toString()` and supply it to your chosen big number implementation. +The client protects against precision loss by throwing an error on large numbers, but you can either +allow imprecise values with `allowImpreciseNumbers` or maintain exact precision using `NumberValue`. + +```typescript +const preciseValue = "34567890123456789012345678901234567890"; + +// 1. Default behavior - will throw error +await client.send( + new PutCommand({ + TableName: "Table", + Item: { + id: "1", + number: Number(preciseValue), // Throws error: Number is greater than Number.MAX_SAFE_INTEGER + }, + }) +); + +// 2. Using allowImpreciseNumbers - will store but loses precision (mimics the v2 implicit behavior) +const impreciseClient = DynamoDBDocumentClient.from(new DynamoDBClient({}), { + marshallOptions: { allowImpreciseNumbers: true }, +}); +await impreciseClient.send( + new PutCommand({ + TableName: "Table", + Item: { + id: "2", + number: Number(preciseValue), // Loses precision 34567890123456790000000000000000000000n + }, + }) +); +``` + ### Client and Command middleware stacks As with other AWS SDK for JavaScript v3 clients, you can apply middleware functions diff --git a/packages/util-dynamodb/src/convertToAttr.spec.ts b/packages/util-dynamodb/src/convertToAttr.spec.ts index 62e5b1df5061..c5fd2f0ca367 100644 --- a/packages/util-dynamodb/src/convertToAttr.spec.ts +++ b/packages/util-dynamodb/src/convertToAttr.spec.ts @@ -595,4 +595,32 @@ describe("convertToAttr", () => { expect(convertToAttr(new Date(), { convertClassInstanceToMap: true })).toEqual({ M: {} }); }); }); + + describe("imprecise numbers", () => { + const impreciseNumbers = [ + { val: 1.23e40, str: "1.23e+40" }, // https://github.com/aws/aws-sdk-js-v3/issues/6571 + { val: Number.MAX_VALUE, str: Number.MAX_VALUE.toString() }, + { val: Number.MAX_SAFE_INTEGER + 1, str: (Number.MAX_SAFE_INTEGER + 1).toString() }, + ]; + + describe("without allowImpreciseNumbers", () => { + impreciseNumbers.forEach(({ val }) => { + it(`throws for imprecise number: ${val}`, () => { + expect(() => { + convertToAttr(val); + }).toThrowError( + `Number ${val.toString()} is greater than Number.MAX_SAFE_INTEGER. Use NumberValue from @aws-sdk/lib-dynamodb.` + ); + }); + }); + }); + + describe("with allowImpreciseNumbers", () => { + impreciseNumbers.forEach(({ val, str }) => { + it(`allows imprecise number: ${val}`, () => { + expect(convertToAttr(val, { allowImpreciseNumbers: true })).toEqual({ N: str }); + }); + }); + }); + }); }); diff --git a/packages/util-dynamodb/src/convertToAttr.ts b/packages/util-dynamodb/src/convertToAttr.ts index 415b7574ac87..d26ee4ac677b 100644 --- a/packages/util-dynamodb/src/convertToAttr.ts +++ b/packages/util-dynamodb/src/convertToAttr.ts @@ -37,7 +37,7 @@ export const convertToAttr = (data: NativeAttributeValue, options?: marshallOpti } else if (typeof data === "boolean" || data?.constructor?.name === "Boolean") { return { BOOL: data.valueOf() }; } else if (typeof data === "number" || data?.constructor?.name === "Number") { - return convertToNumberAttr(data); + return convertToNumberAttr(data, options); } else if (data instanceof NumberValue) { return data.toAttributeValue(); } else if (typeof data === "bigint") { @@ -91,7 +91,7 @@ const convertToSetAttr = ( } else if (typeof item === "number") { return { NS: Array.from(setToOperate) - .map(convertToNumberAttr) + .map((num) => convertToNumberAttr(num, options)) .map((item) => item.N), }; } else if (typeof item === "bigint") { @@ -160,17 +160,20 @@ const validateBigIntAndThrow = (errorPrefix: string) => { throw new Error(`${errorPrefix} Use NumberValue from @aws-sdk/lib-dynamodb.`); }; -const convertToNumberAttr = (num: number | Number): { N: string } => { +const convertToNumberAttr = (num: number | Number, options?: marshallOptions): { N: string } => { if ( [Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY] .map((val) => val.toString()) .includes(num.toString()) ) { throw new Error(`Special numeric value ${num.toString()} is not allowed`); - } else if (num > Number.MAX_SAFE_INTEGER) { - validateBigIntAndThrow(`Number ${num.toString()} is greater than Number.MAX_SAFE_INTEGER.`); - } else if (num < Number.MIN_SAFE_INTEGER) { - validateBigIntAndThrow(`Number ${num.toString()} is lesser than Number.MIN_SAFE_INTEGER.`); + } else if (!options?.allowImpreciseNumbers) { + // Only perform these checks if allowImpreciseNumbers is false + if (num > Number.MAX_SAFE_INTEGER) { + validateBigIntAndThrow(`Number ${num.toString()} is greater than Number.MAX_SAFE_INTEGER.`); + } else if (num < Number.MIN_SAFE_INTEGER) { + validateBigIntAndThrow(`Number ${num.toString()} is lesser than Number.MIN_SAFE_INTEGER.`); + } } return { N: num.toString() }; }; diff --git a/packages/util-dynamodb/src/convertToNative.spec.ts b/packages/util-dynamodb/src/convertToNative.spec.ts index c235feae2eb0..35cb2eff38bc 100644 --- a/packages/util-dynamodb/src/convertToNative.spec.ts +++ b/packages/util-dynamodb/src/convertToNative.spec.ts @@ -92,6 +92,17 @@ describe("convertToNative", () => { }).toThrowError(`${numString} can't be converted to BigInt. Set options.wrapNumbers to get string value.`); }); }); + + it("handles custom wrapNumbers function", () => { + expect( + convertToNative( + { N: "124" }, + { + wrapNumbers: (str: string) => Number(str) / 2, + } + ) + ).toEqual(62); + }); }); describe("binary", () => { diff --git a/packages/util-dynamodb/src/convertToNative.ts b/packages/util-dynamodb/src/convertToNative.ts index 3b140d5c8dc5..405771bbcdaf 100644 --- a/packages/util-dynamodb/src/convertToNative.ts +++ b/packages/util-dynamodb/src/convertToNative.ts @@ -43,10 +43,12 @@ export const convertToNative = (data: AttributeValue, options?: unmarshallOption }; const convertNumber = (numString: string, options?: unmarshallOptions): number | bigint | NumberValue => { + if (typeof options?.wrapNumbers === "function") { + return options?.wrapNumbers(numString); + } if (options?.wrapNumbers) { return NumberValue.from(numString); } - const num = Number(numString); const infinityValues = [Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY]; const isLargeFiniteNumber = diff --git a/packages/util-dynamodb/src/marshall.ts b/packages/util-dynamodb/src/marshall.ts index 2fefc0f679d9..10cc7c4970e2 100644 --- a/packages/util-dynamodb/src/marshall.ts +++ b/packages/util-dynamodb/src/marshall.ts @@ -28,6 +28,14 @@ export interface marshallOptions { * but false if directly using the marshall function (backwards compatibility). */ convertTopLevelContainer?: boolean; + /** + * Whether to allow numbers beyond Number.MAX_SAFE_INTEGER during marshalling. + * When set to true, allows numbers that may lose precision when converted to JavaScript numbers. + * When false (default), throws an error if a number exceeds Number.MAX_SAFE_INTEGER to prevent + * unintended loss of precision. Consider using the NumberValue type from @aws-sdk/lib-dynamodb + * for precise handling of large numbers. + */ + allowImpreciseNumbers?: boolean; } /** diff --git a/packages/util-dynamodb/src/unmarshall.ts b/packages/util-dynamodb/src/unmarshall.ts index d6256fdc81ff..369896057cf3 100644 --- a/packages/util-dynamodb/src/unmarshall.ts +++ b/packages/util-dynamodb/src/unmarshall.ts @@ -2,17 +2,21 @@ import { AttributeValue } from "@aws-sdk/client-dynamodb"; import { convertToNative } from "./convertToNative"; import { NativeAttributeValue } from "./models"; +import { NumberValue } from "./NumberValue"; /** * An optional configuration object for `convertToNative` */ export interface unmarshallOptions { /** - * Whether to return numbers as a string instead of converting them to native JavaScript numbers. + * Whether to modify how numbers are unmarshalled from DynamoDB. + * When set to true, returns numbers as NumberValue instances instead of native JavaScript numbers. * This allows for the safe round-trip transport of numbers of arbitrary size. + * + * If a function is provided, it will be called with the string representation of numbers to handle + * custom conversions (e.g., using BigInt or decimal libraries). */ - wrapNumbers?: boolean; - + wrapNumbers?: boolean | ((value: string) => number | bigint | NumberValue | any); /** * When true, skip wrapping the data in `{ M: data }` before converting. *