diff --git a/packages/util-dynamodb/src/convertToAttr.spec.ts b/packages/util-dynamodb/src/convertToAttr.spec.ts index 1ef7bdf1b33b..9e9febdc5b52 100644 --- a/packages/util-dynamodb/src/convertToAttr.spec.ts +++ b/packages/util-dynamodb/src/convertToAttr.spec.ts @@ -6,123 +6,145 @@ import { NativeAttributeValue } from "./models"; describe("convertToAttr", () => { describe("null", () => { - it(`returns for null`, () => { - expect(convertToAttr(null)).toEqual({ NULL: true }); + [true, false].forEach((convertClassInstanceToMap) => { + it(`returns for null`, () => { + expect(convertToAttr(null, { convertClassInstanceToMap })).toEqual({ NULL: true }); + }); }); }); describe("boolean", () => { - [true, false].forEach((bool) => { - it(`returns for boolean: ${bool}`, () => { - expect(convertToAttr(bool)).toEqual({ BOOL: bool }); + [true, false].forEach((convertClassInstanceToMap) => { + [true, false].forEach((isClassInstance) => { + [true, false].forEach((boolValue) => { + const bool = isClassInstance ? new Boolean(boolValue) : boolValue; + it(`returns for boolean: ${bool}`, () => { + expect(convertToAttr(bool, { convertClassInstanceToMap })).toEqual({ BOOL: bool.valueOf() }); + }); + }); }); }); }); describe("number", () => { - [1, Number.MAX_SAFE_INTEGER, Number.MIN_SAFE_INTEGER].forEach((num) => { - it(`returns for number (integer): ${num}`, () => { - expect(convertToAttr(num)).toEqual({ N: num.toString() }); - }); - }); - - [1.01, Math.PI, Math.E, Number.MIN_VALUE, Number.EPSILON].forEach((num) => { - it(`returns for number (floating point): ${num}`, () => { - expect(convertToAttr(num)).toEqual({ N: num.toString() }); - }); - }); - - [Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY].forEach((num) => { - it(`throws for number (special numeric value): ${num}`, () => { - expect(() => { - convertToAttr(num); - }).toThrowError(`Special numeric value ${num} is not allowed`); - }); - }); - - [Number.MAX_SAFE_INTEGER + 1, Number.MAX_VALUE].forEach((num) => { - it(`throws for number greater than Number.MAX_SAFE_INTEGER: ${num}`, () => { - const errorPrefix = `Number ${num} is greater than Number.MAX_SAFE_INTEGER.`; - - expect(() => { - convertToAttr(num); - }).toThrowError(`${errorPrefix} Use BigInt.`); + [true, false].forEach((convertClassInstanceToMap) => { + [true, false].forEach((isClassInstance) => { + [1, Number.MAX_SAFE_INTEGER, Number.MIN_SAFE_INTEGER].forEach((numValue) => { + const num = isClassInstance ? new Number(numValue) : numValue; + it(`returns for number (integer): ${num}`, () => { + expect(convertToAttr(num, { convertClassInstanceToMap })).toEqual({ N: num.toString() }); + }); + }); - const BigIntConstructor = BigInt; - (BigInt as any) = undefined; - expect(() => { - convertToAttr(num); - }).toThrowError(`${errorPrefix} Pass string value instead.`); - BigInt = BigIntConstructor; - }); - }); + [1.01, Math.PI, Math.E, Number.MIN_VALUE, Number.EPSILON].forEach((numValue) => { + const num = isClassInstance ? new Number(numValue) : numValue; + it(`returns for number (floating point): ${num}`, () => { + expect(convertToAttr(num, { convertClassInstanceToMap })).toEqual({ N: num.toString() }); + }); + }); - [Number.MIN_SAFE_INTEGER - 1].forEach((num) => { - it(`throws for number lesser than Number.MIN_SAFE_INTEGER: ${num}`, () => { - const errorPrefix = `Number ${num} is lesser than Number.MIN_SAFE_INTEGER.`; + [Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY].forEach((numValue) => { + const num = isClassInstance ? new Number(numValue) : numValue; + it(`throws for number (special numeric value): ${num}`, () => { + expect(() => { + convertToAttr(num, { convertClassInstanceToMap }); + }).toThrowError(`Special numeric value ${num.toString()} is not allowed`); + }); + }); - expect(() => { - convertToAttr(num); - }).toThrowError(`${errorPrefix} Use BigInt.`); + [Number.MAX_SAFE_INTEGER + 1, Number.MAX_VALUE].forEach((numValue) => { + const num = isClassInstance ? new Number(numValue) : numValue; + it(`throws for number greater than Number.MAX_SAFE_INTEGER: ${num}`, () => { + const errorPrefix = `Number ${num.toString()} is greater than Number.MAX_SAFE_INTEGER.`; + + expect(() => { + convertToAttr(num, { convertClassInstanceToMap }); + }).toThrowError(`${errorPrefix} Use BigInt.`); + + const BigIntConstructor = BigInt; + (BigInt as any) = undefined; + expect(() => { + convertToAttr(num, { convertClassInstanceToMap }); + }).toThrowError(`${errorPrefix} Pass string value instead.`); + BigInt = BigIntConstructor; + }); + }); - const BigIntConstructor = BigInt; - (BigInt as any) = undefined; - expect(() => { - convertToAttr(num); - }).toThrowError(`${errorPrefix} Pass string value instead.`); - BigInt = BigIntConstructor; + [Number.MIN_SAFE_INTEGER - 1].forEach((numValue) => { + const num = isClassInstance ? new Number(numValue) : numValue; + it(`throws for number lesser than Number.MIN_SAFE_INTEGER: ${num}`, () => { + const errorPrefix = `Number ${num.toString()} is lesser than Number.MIN_SAFE_INTEGER.`; + + expect(() => { + convertToAttr(num, { convertClassInstanceToMap }); + }).toThrowError(`${errorPrefix} Use BigInt.`); + + const BigIntConstructor = BigInt; + (BigInt as any) = undefined; + expect(() => { + convertToAttr(num, { convertClassInstanceToMap }); + }).toThrowError(`${errorPrefix} Pass string value instead.`); + BigInt = BigIntConstructor; + }); + }); }); }); }); describe("bigint", () => { - const maxSafe = BigInt(Number.MAX_SAFE_INTEGER); - [ - // @ts-expect-error BigInt literals are not available when targeting lower than ES2020. - 1n, - // @ts-expect-error BigInt literals are not available when targeting lower than ES2020. - maxSafe * 2n, - // @ts-expect-error BigInt literals are not available when targeting lower than ES2020. - maxSafe * -2n, - BigInt(Number.MAX_VALUE), - BigInt("0x1fffffffffffff"), - BigInt("0b11111111111111111111111111111111111111111111111111111"), - ].forEach((num) => { - it(`returns for bigint: ${num}`, () => { - expect(convertToAttr(num)).toEqual({ N: num.toString() }); + [true, false].forEach((convertClassInstanceToMap) => { + const maxSafe = BigInt(Number.MAX_SAFE_INTEGER); + [ + // @ts-expect-error BigInt literals are not available when targeting lower than ES2020. + 1n, + // @ts-expect-error BigInt literals are not available when targeting lower than ES2020. + maxSafe * 2n, + // @ts-expect-error BigInt literals are not available when targeting lower than ES2020. + maxSafe * -2n, + BigInt(Number.MAX_VALUE), + BigInt("0x1fffffffffffff"), + BigInt("0b11111111111111111111111111111111111111111111111111111"), + ].forEach((num) => { + it(`returns for bigint: ${num}`, () => { + expect(convertToAttr(num, { convertClassInstanceToMap })).toEqual({ N: num.toString() }); + }); }); }); }); describe("binary", () => { - const buffer = new ArrayBuffer(64); - const arr = [...Array(64).keys()]; - const addPointOne = (num: number) => num + 0.1; - - [ - buffer, - new Blob([new Uint8Array(buffer)]), - Buffer.from(buffer), - new DataView(buffer), - new Int8Array(arr), - new Uint8Array(arr), - new Uint8ClampedArray(arr), - new Int16Array(arr), - new Uint16Array(arr), - new Int32Array(arr), - new Uint32Array(arr), - new Float32Array(arr.map(addPointOne)), - new Float64Array(arr.map(addPointOne)), - new BigInt64Array(arr.map(BigInt)), - new BigUint64Array(arr.map(BigInt)), - ].forEach((data) => { - it(`returns for binary: ${data.constructor.name}`, () => { - expect(convertToAttr(data)).toEqual({ B: data }); + [true, false].forEach((convertClassInstanceToMap) => { + const buffer = new ArrayBuffer(64); + const arr = [...Array(64).keys()]; + const addPointOne = (num: number) => num + 0.1; + + [ + buffer, + new Blob([new Uint8Array(buffer)]), + Buffer.from(buffer), + new DataView(buffer), + new Int8Array(arr), + new Uint8Array(arr), + new Uint8ClampedArray(arr), + new Int16Array(arr), + new Uint16Array(arr), + new Int32Array(arr), + new Uint32Array(arr), + new Float32Array(arr.map(addPointOne)), + new Float64Array(arr.map(addPointOne)), + new BigInt64Array(arr.map(BigInt)), + new BigUint64Array(arr.map(BigInt)), + ].forEach((data) => { + it(`returns for binary: ${data.constructor.name}`, () => { + expect(convertToAttr(data, { convertClassInstanceToMap })).toEqual({ B: data }); + }); }); - }); - it("returns null for Binary when options.convertEmptyValues=true", () => { - expect(convertToAttr(new Uint8Array(), { convertEmptyValues: true })).toEqual({ NULL: true }); + it("returns null for Binary when options.convertEmptyValues=true", () => { + expect(convertToAttr(new Uint8Array(), { convertClassInstanceToMap, convertEmptyValues: true })).toEqual({ + NULL: true, + }); + }); }); }); @@ -273,7 +295,6 @@ describe("convertToAttr", () => { describe("unallowed set", () => { it("throws error", () => { expect(() => { - // @ts-expect-error Type 'Set' is not assignable convertToAttr(new Set([true, false])); }).toThrowError(`Only Number Set (NS), Binary Set (BS) or String Set (SS) are allowed.`); }); @@ -362,19 +383,25 @@ describe("convertToAttr", () => { }); describe("string", () => { - ["", "string", "'single-quote'", '"double-quote"'].forEach((str) => { - it(`returns for string: ${str}`, () => { - expect(convertToAttr(str)).toEqual({ S: str }); - }); - }); + [true, false].forEach((convertClassInstanceToMap) => { + [true, false].forEach((isClassInstance) => { + ["", "string", "'single-quote'", '"double-quote"'].forEach((strValue) => { + const str = isClassInstance ? new String(strValue) : strValue; + it(`returns for string: ${str}`, () => { + expect(convertToAttr(str, { convertClassInstanceToMap })).toEqual({ S: str.toString() }); + }); + }); - it("returns null for string when options.convertEmptyValues=true", () => { - expect(convertToAttr("", { convertEmptyValues: true })).toEqual({ NULL: true }); + it("returns null for string when options.convertEmptyValues=true", () => { + const str = isClassInstance ? new String("") : ""; + expect(convertToAttr(str, { convertClassInstanceToMap, convertEmptyValues: true })).toEqual({ NULL: true }); + }); + }); }); }); describe(`unsupported type`, () => { - class FooObj { + class FooClass { constructor(private readonly foo: string) {} } @@ -385,13 +412,72 @@ describe("convertToAttr", () => { }); // ToDo: Serialize ES6 class objects as string https://github.com/aws/aws-sdk-js-v3/issues/1535 - [new Date(), new FooObj("foo")].forEach((data) => { + [new Date(), new FooClass("foo")].forEach((data) => { it(`throws for: ${String(data)}`, () => { expect(() => { - // @ts-expect-error Argument is not assignable to parameter of type 'NativeAttributeValue' convertToAttr(data); - }).toThrowError(`Unsupported type passed: ${String(data)}`); + }).toThrowError( + `Unsupported type passed: ${String( + data + )}. Pass options.convertClassInstanceToMap=true to marshall typeof object as map attribute.` + ); }); }); }); + + describe("typeof object with options.convertClassInstanceToMap=true", () => { + it("returns map for class", () => { + class FooClass { + constructor( + private readonly nullAttr: null, + private readonly boolAttr: boolean, + private readonly strAttr: string, + private readonly numAttr: number, + private readonly bigintAttr: bigint, + private readonly binaryAttr: Uint8Array, + private readonly listAttr: any[], + private readonly mapAttr: { [key: string]: any } + ) {} + } + expect( + convertToAttr( + new FooClass( + null, + true, + "string", + 1, + BigInt(Number.MAX_VALUE), + new Uint8Array([...Array(64).keys()]), + [null, 1, "two", true], + { + nullKey: null, + numKey: 1, + strKey: "string", + boolKey: true, + } + ), + { + convertClassInstanceToMap: true, + } + ) + ).toEqual({ + M: { + nullAttr: { NULL: true }, + boolAttr: { BOOL: true }, + strAttr: { S: "string" }, + numAttr: { N: "1" }, + bigintAttr: { N: BigInt(Number.MAX_VALUE).toString() }, + binaryAttr: { B: new Uint8Array([...Array(64).keys()]) }, + listAttr: { L: [{ NULL: true }, { N: "1" }, { S: "two" }, { BOOL: true }] }, + mapAttr: { + M: { nullKey: { NULL: true }, numKey: { N: "1" }, strKey: { S: "string" }, boolKey: { BOOL: true } }, + }, + }, + }); + }); + + it("returns empty for Date object", () => { + expect(convertToAttr(new Date(), { convertClassInstanceToMap: true })).toEqual({ M: {} }); + }); + }); }); diff --git a/packages/util-dynamodb/src/convertToAttr.ts b/packages/util-dynamodb/src/convertToAttr.ts index 31994d25c04f..11efd932b2ac 100644 --- a/packages/util-dynamodb/src/convertToAttr.ts +++ b/packages/util-dynamodb/src/convertToAttr.ts @@ -10,15 +10,40 @@ import { NativeAttributeBinary, NativeAttributeValue, NativeScalarAttributeValue * @param {marshallOptions} options - An optional configuration object for `convertToAttr` */ export const convertToAttr = (data: NativeAttributeValue, options?: marshallOptions): AttributeValue => { - if (Array.isArray(data)) { + if (data === undefined) { + throw new Error(`Pass options.removeUndefinedValues=true to remove undefined values from map/array/set.`); + } else if (data === null && typeof data === "object") { + return convertToNullAttr(); + } else if (Array.isArray(data)) { return convertToListAttr(data, options); } else if (data?.constructor?.name === "Set") { return convertToSetAttr(data as Set, options); } else if (data?.constructor?.name === "Object") { return convertToMapAttr(data as { [key: string]: NativeAttributeValue }, options); - } else { - return convertToScalarAttr(data as NativeScalarAttributeValue, options); + } else if (isBinary(data)) { + if (data.length === 0 && options?.convertEmptyValues) { + return convertToNullAttr(); + } + // Do not alter binary data passed https://github.com/aws/aws-sdk-js-v3/issues/1530 + // @ts-expect-error Type '{ B: NativeAttributeBinary; }' is not assignable to type 'AttributeValue' + return convertToBinaryAttr(data); + } 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); + } else if (typeof data === "bigint") { + return convertToBigIntAttr(data); + } else if (typeof data === "string" || data?.constructor?.name === "String") { + if (data.length === 0 && options?.convertEmptyValues) { + return convertToNullAttr(); + } + return convertToStringAttr(data); + } else if (options?.convertClassInstanceToMap && typeof data === "object") { + return convertToMapAttr(data as { [key: string]: NativeAttributeValue }, options); } + throw new Error( + `Unsupported type passed: ${data}. Pass options.convertClassInstanceToMap=true to marshall typeof object as map attribute.` + ); }; const convertToListAttr = (data: NativeAttributeValue[], options?: marshallOptions): { L: AttributeValue[] } => ({ @@ -94,51 +119,27 @@ const convertToMapAttr = ( ), }); -const convertToScalarAttr = (data: NativeScalarAttributeValue, options?: marshallOptions): AttributeValue => { - if (data === undefined) { - throw new Error(`Pass options.removeUndefinedValues=true to remove undefined values from map/array/set.`); - } else if (data === null && typeof data === "object") { - return convertToNullAttr(); - } else if (typeof data === "boolean") { - return { BOOL: data }; - } else if (typeof data === "number") { - return convertToNumberAttr(data); - } else if (typeof data === "bigint") { - return convertToBigIntAttr(data); - } else if (isBinary(data)) { - // @ts-expect-error Property 'length' does not exist on type 'ArrayBuffer'. - if (data.length === 0 && options?.convertEmptyValues) { - return convertToNullAttr(); - } - // Do not alter binary data passed https://github.com/aws/aws-sdk-js-v3/issues/1530 - // @ts-expect-error Type '{ B: NativeAttributeBinary; }' is not assignable to type 'AttributeValue' - return convertToBinaryAttr(data); - } else if (typeof data === "string") { - if (data.length === 0 && options?.convertEmptyValues) { - return convertToNullAttr(); - } - return convertToStringAttr(data); - } - throw new Error(`Unsupported type passed: ${data}`); -}; - // For future-proofing: this functions are called from multiple places const convertToNullAttr = (): { NULL: true } => ({ NULL: true }); const convertToBinaryAttr = (data: NativeAttributeBinary): { B: NativeAttributeBinary } => ({ B: data }); -const convertToStringAttr = (data: string): { S: string } => ({ S: data }); +const convertToStringAttr = (data: string | String): { S: string } => ({ S: data.toString() }); const convertToBigIntAttr = (data: bigint): { N: string } => ({ N: data.toString() }); const validateBigIntAndThrow = (errorPrefix: string) => { throw new Error(`${errorPrefix} ${typeof BigInt === "function" ? "Use BigInt." : "Pass string value instead."} `); }; -const convertToNumberAttr = (num: number): { N: string } => { - if ([Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY].includes(num)) { - throw new Error(`Special numeric value ${num} is not allowed`); +const convertToNumberAttr = (num: number | Number): { 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} is greater than 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} is lesser than 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/marshall.spec.ts b/packages/util-dynamodb/src/marshall.spec.ts index cebbb1348be8..ea16c3cb2d03 100644 --- a/packages/util-dynamodb/src/marshall.spec.ts +++ b/packages/util-dynamodb/src/marshall.spec.ts @@ -4,18 +4,16 @@ import { marshall } from "./marshall"; jest.mock("./convertToAttr"); describe("marshall", () => { - const input = { a: "A", b: "B" }; - - beforeEach(() => { - (convertToAttr as jest.Mock).mockReturnValue({ M: input }); - }); + const mockOutput = { S: "mockOutput" }; + (convertToAttr as jest.Mock).mockReturnValue({ M: mockOutput }); afterEach(() => { jest.clearAllMocks(); }); - it("calls convertToAttr", () => { - expect(marshall(input)).toEqual(input); + it("with object as an input", () => { + const input = { a: "A", b: "B" }; + expect(marshall(input)).toEqual(mockOutput); expect(convertToAttr).toHaveBeenCalledTimes(1); expect(convertToAttr).toHaveBeenCalledWith(input, undefined); }); @@ -24,11 +22,44 @@ describe("marshall", () => { describe(`options.${option}`, () => { [false, true].forEach((value) => { it(`passes ${value} to convertToAttr`, () => { - expect(marshall(input, { [option]: value })).toEqual(input); + const input = { a: "A", b: "B" }; + expect(marshall(input, { [option]: value })).toEqual(mockOutput); expect(convertToAttr).toHaveBeenCalledTimes(1); expect(convertToAttr).toHaveBeenCalledWith(input, { [option]: value }); }); }); }); }); + + it("with type as an input", () => { + type TestInputType = { a: string; b: string }; + const input: TestInputType = { a: "A", b: "B" }; + + expect(marshall(input)).toEqual(mockOutput); + expect(convertToAttr).toHaveBeenCalledTimes(1); + expect(convertToAttr).toHaveBeenCalledWith(input, undefined); + }); + + it("with Interface as an input", () => { + interface TestInputInterface { + a: string; + b: string; + } + const input: TestInputInterface = { a: "A", b: "B" }; + + expect(marshall(input)).toEqual(mockOutput); + expect(convertToAttr).toHaveBeenCalledTimes(1); + expect(convertToAttr).toHaveBeenCalledWith(input, undefined); + }); + + it("with class instance as an input", () => { + class TestInputClass { + constructor(private readonly a: string, private readonly b: string) {} + } + const input = new TestInputClass("A", "B"); + + expect(marshall(input)).toEqual(mockOutput); + expect(convertToAttr).toHaveBeenCalledTimes(1); + expect(convertToAttr).toHaveBeenCalledWith(input, undefined); + }); }); diff --git a/packages/util-dynamodb/src/marshall.ts b/packages/util-dynamodb/src/marshall.ts index a24ad52c070c..f9fe6676abb3 100644 --- a/packages/util-dynamodb/src/marshall.ts +++ b/packages/util-dynamodb/src/marshall.ts @@ -15,6 +15,10 @@ export interface marshallOptions { * Whether to remove undefined values while marshalling. */ removeUndefinedValues?: boolean; + /** + * Whether to convert typeof object to map attribute. + */ + convertClassInstanceToMap?: boolean; } /** @@ -23,7 +27,7 @@ export interface marshallOptions { * @param {any} data - The data to convert to a DynamoDB record * @param {marshallOptions} options - An optional configuration object for `marshall` */ -export const marshall = ( - data: { [key: string]: NativeAttributeValue }, +export const marshall = ( + data: T, options?: marshallOptions ): { [key: string]: AttributeValue } => convertToAttr(data, options).M as { [key: string]: AttributeValue }; diff --git a/packages/util-dynamodb/src/models.ts b/packages/util-dynamodb/src/models.ts index 39400155ec79..1feae89c0766 100644 --- a/packages/util-dynamodb/src/models.ts +++ b/packages/util-dynamodb/src/models.ts @@ -14,7 +14,8 @@ export type NativeAttributeValue = | NativeScalarAttributeValue | { [key: string]: NativeAttributeValue } | NativeAttributeValue[] - | Set; + | Set + | InstanceType<{ new (...args: any[]): any }>; // accepts any class instance with options.convertClassInstanceToMap export type NativeScalarAttributeValue = | null