Skip to content

Commit f09e0da

Browse files
authored
feat(util-dynamodb): unmarshall to convert DynamoDB record to JavaScript Object (#1537)
1 parent e48545c commit f09e0da

File tree

7 files changed

+450
-2
lines changed

7 files changed

+450
-2
lines changed

Diff for: packages/util-dynamodb/README.md

+18
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,21 @@ const params = {
2626

2727
await client.putItem(params);
2828
```
29+
30+
## Convert DynamoDB Record into JavaScript object
31+
32+
```js
33+
const { DynamoDB } = require("@aws-sdk/client-dynamodb");
34+
const { marshall, unmarshall } = require("@aws-sdk/util-dynamodb");
35+
36+
const client = new DynamoDB(clientParams);
37+
const params = {
38+
TableName: "Table",
39+
Key: marshall({
40+
HashKey: "hashKey",
41+
}),
42+
};
43+
44+
const { Item } = await client.getItem(params);
45+
unmarshall(Item);
46+
```

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

+268
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,268 @@
1+
import { AttributeValue } from "@aws-sdk/client-dynamodb";
2+
3+
import { convertToNative } from "./convertToNative";
4+
import { NativeAttributeValue } from "./models";
5+
6+
describe("convertToNative", () => {
7+
const emptyAttr = {
8+
B: undefined,
9+
BOOL: undefined,
10+
BS: undefined,
11+
L: undefined,
12+
M: undefined,
13+
N: undefined,
14+
NS: undefined,
15+
NULL: undefined,
16+
S: undefined,
17+
SS: undefined,
18+
};
19+
20+
describe("null", () => {
21+
it(`returns for null`, () => {
22+
expect(convertToNative({ ...emptyAttr, NULL: true })).toEqual(null);
23+
});
24+
});
25+
26+
describe("boolean", () => {
27+
[true, false].forEach((bool) => {
28+
it(`returns for boolean: ${bool}`, () => {
29+
expect(convertToNative({ ...emptyAttr, BOOL: bool })).toEqual(bool);
30+
});
31+
});
32+
});
33+
34+
describe("number", () => {
35+
const wrapNumbers = true;
36+
37+
[1, Number.MAX_SAFE_INTEGER, Number.MIN_SAFE_INTEGER]
38+
.map((num) => num.toString())
39+
.forEach((numString) => {
40+
it(`returns for number (integer): ${numString}`, () => {
41+
expect(convertToNative({ ...emptyAttr, N: numString })).toEqual(Number(numString));
42+
});
43+
it(`returns NumberValue for number (integer) with options.wrapNumbers set: ${numString}`, () => {
44+
expect(convertToNative({ ...emptyAttr, N: numString }, { wrapNumbers })).toEqual({ value: numString });
45+
});
46+
});
47+
48+
[1.01, Math.PI, Math.E, Number.MIN_VALUE, Number.EPSILON]
49+
.map((num) => num.toString())
50+
.forEach((numString) => {
51+
it(`returns for number (floating point): ${numString}`, () => {
52+
expect(convertToNative({ ...emptyAttr, N: numString })).toEqual(Number(numString));
53+
});
54+
it(`returns NumberValue for number (floating point) with options.wrapNumbers set: ${numString}`, () => {
55+
expect(convertToNative({ ...emptyAttr, N: numString }, { wrapNumbers })).toEqual({ value: numString });
56+
});
57+
});
58+
59+
[Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY]
60+
.map((num) => num.toString())
61+
.forEach((numString) => {
62+
it(`returns for number (special numeric value): ${numString}`, () => {
63+
expect(convertToNative({ ...emptyAttr, N: numString })).toEqual(Number(numString));
64+
});
65+
});
66+
67+
[Number.MAX_SAFE_INTEGER + 1, Number.MAX_VALUE, Number.MIN_SAFE_INTEGER - 1]
68+
.map((num) => num.toString())
69+
.forEach((numString) => {
70+
it(`returns bigint for numbers outside SAFE_INTEGER range: ${numString}`, () => {
71+
expect(convertToNative({ ...emptyAttr, N: numString })).toEqual(BigInt(Number(numString)));
72+
});
73+
74+
it(`throws error for numbers outside SAFE_INTEGER range when BigInt is not defined: ${numString}`, () => {
75+
const BigIntConstructor = BigInt;
76+
(BigInt as any) = undefined;
77+
expect(() => {
78+
convertToNative({ ...emptyAttr, N: numString });
79+
}).toThrowError(`${numString} is outside SAFE_INTEGER bounds. Set options.wrapNumbers to get string value.`);
80+
BigInt = BigIntConstructor;
81+
});
82+
83+
it(`returns NumberValue for numbers outside SAFE_INTEGER range with options.wrapNumbers set: ${numString}`, () => {
84+
expect(convertToNative({ ...emptyAttr, N: numString }, { wrapNumbers })).toEqual({ value: numString });
85+
});
86+
});
87+
88+
[
89+
`${Number.MAX_SAFE_INTEGER}.1`,
90+
`${Number.MIN_SAFE_INTEGER}.1`,
91+
`${Number.MIN_VALUE}1`,
92+
`-${Number.MIN_VALUE}1`,
93+
].forEach((numString) => {
94+
it(`throws if number is outside IEEE 754 Floating-Point Arithmetic: ${numString}`, () => {
95+
expect(() => {
96+
convertToNative({ ...emptyAttr, N: numString });
97+
}).toThrowError(
98+
`Value ${numString} is outside IEEE 754 Floating-Point Arithmetic. Set options.wrapNumbers to get string value.`
99+
);
100+
});
101+
});
102+
});
103+
104+
describe("binary", () => {
105+
it(`returns for Uint8Array`, () => {
106+
const data = new Uint8Array([...Array(64).keys()]);
107+
expect(convertToNative({ ...emptyAttr, B: data })).toEqual(data);
108+
});
109+
});
110+
111+
describe("string", () => {
112+
["", "string", "'single-quote'", '"double-quote"'].forEach((str) => {
113+
it(`returns for string: ${str}`, () => {
114+
expect(convertToNative({ ...emptyAttr, S: str })).toEqual(str);
115+
});
116+
});
117+
});
118+
119+
describe("list", () => {
120+
const uint8Arr1 = new Uint8Array([...Array(4).keys()]);
121+
const uint8Arr2 = new Uint8Array([...Array(2).keys()]);
122+
([
123+
{
124+
input: [{ NULL: true }, { BOOL: false }],
125+
output: [null, false],
126+
},
127+
{
128+
input: [{ S: "one" }, { N: "1.01" }, { N: "9007199254740996" }],
129+
output: ["one", 1.01, BigInt(9007199254740996)],
130+
},
131+
{
132+
input: [{ B: uint8Arr1 }, { B: uint8Arr2 }],
133+
output: [uint8Arr1, uint8Arr2],
134+
},
135+
{
136+
input: [
137+
{ M: { nullKey: { NULL: true }, boolKey: { BOOL: false } } },
138+
{ M: { stringKey: { S: "one" }, numberKey: { N: "1.01" }, bigintKey: { N: "9007199254740996" } } },
139+
],
140+
output: [
141+
{ nullKey: null, boolKey: false },
142+
{ stringKey: "one", numberKey: 1.01, bigintKey: BigInt(9007199254740996) },
143+
],
144+
},
145+
{
146+
input: [
147+
{ NS: ["1", "2", "3"] },
148+
{ NS: ["9007199254740996", "-9007199254740996"] },
149+
{ BS: [uint8Arr1, uint8Arr2] },
150+
{ SS: ["one", "two", "three"] },
151+
],
152+
output: [
153+
new Set([1, 2, 3]),
154+
new Set([BigInt(9007199254740996), BigInt(-9007199254740996)]),
155+
new Set([uint8Arr1, uint8Arr2]),
156+
new Set(["one", "two", "three"]),
157+
],
158+
},
159+
] as { input: AttributeValue[]; output: NativeAttributeValue[] }[]).forEach(({ input, output }) => {
160+
it(`testing list: ${JSON.stringify(input)}`, () => {
161+
expect(convertToNative({ ...emptyAttr, L: input })).toEqual(output);
162+
});
163+
});
164+
165+
it(`testing list with options.wrapNumbers`, () => {
166+
const input = [{ N: "1.01" }, { N: "9007199254740996" }];
167+
expect(convertToNative({ ...emptyAttr, L: input as AttributeValue[] }, { wrapNumbers: true })).toEqual(
168+
input.map((item) => ({ value: item.N }))
169+
);
170+
});
171+
});
172+
173+
describe("map", () => {
174+
const uint8Arr1 = new Uint8Array([...Array(4).keys()]);
175+
const uint8Arr2 = new Uint8Array([...Array(2).keys()]);
176+
([
177+
{
178+
input: { nullKey: { NULL: true }, boolKey: { BOOL: false } },
179+
output: { nullKey: null, boolKey: false },
180+
},
181+
{
182+
input: { stringKey: { S: "one" }, numberKey: { N: "1.01" }, bigintKey: { N: "9007199254740996" } },
183+
output: { stringKey: "one", numberKey: 1.01, bigintKey: BigInt(9007199254740996) },
184+
},
185+
{
186+
input: { uint8Arr1Key: { B: uint8Arr1 }, uint8Arr2Key: { B: uint8Arr2 } },
187+
output: { uint8Arr1Key: uint8Arr1, uint8Arr2Key: uint8Arr2 },
188+
},
189+
{
190+
input: {
191+
list1: { L: [{ NULL: true }, { BOOL: false }] },
192+
list2: { L: [{ S: "one" }, { N: "1.01" }, { N: "9007199254740996" }] },
193+
},
194+
output: { list1: [null, false], list2: ["one", 1.01, BigInt(9007199254740996)] },
195+
},
196+
{
197+
input: {
198+
numberSet: { NS: ["1", "2", "3"] },
199+
bigintSet: { NS: ["9007199254740996", "-9007199254740996"] },
200+
binarySet: { BS: [uint8Arr1, uint8Arr2] },
201+
stringSet: { SS: ["one", "two", "three"] },
202+
},
203+
output: {
204+
numberSet: new Set([1, 2, 3]),
205+
bigintSet: new Set([BigInt(9007199254740996), BigInt(-9007199254740996)]),
206+
binarySet: new Set([uint8Arr1, uint8Arr2]),
207+
stringSet: new Set(["one", "two", "three"]),
208+
},
209+
},
210+
] as { input: { [key: string]: AttributeValue }; output: { [key: string]: NativeAttributeValue } }[]).forEach(
211+
({ input, output }) => {
212+
it(`testing map: ${input}`, () => {
213+
expect(convertToNative({ ...emptyAttr, M: input })).toEqual(output);
214+
});
215+
}
216+
);
217+
218+
it(`testing map with options.wrapNumbers`, () => {
219+
const input = { numberKey: { N: "1.01" }, bigintKey: { N: "9007199254740996" } };
220+
const output = { numberKey: { value: "1.01" }, bigintKey: { value: "9007199254740996" } };
221+
expect(convertToNative({ ...emptyAttr, M: input }, { wrapNumbers: true })).toEqual(output);
222+
});
223+
});
224+
225+
describe("set", () => {
226+
describe("number set", () => {
227+
const input = ["1", "2", "9007199254740996"];
228+
229+
it("without options.wrapNumbers", () => {
230+
expect(convertToNative({ ...emptyAttr, NS: input })).toEqual(new Set([1, 2, BigInt(9007199254740996)]));
231+
});
232+
233+
it("with options.wrapNumbers", () => {
234+
expect(convertToNative({ ...emptyAttr, NS: input }, { wrapNumbers: true })).toEqual(
235+
new Set(input.map((numString) => ({ value: numString })))
236+
);
237+
});
238+
});
239+
240+
it("binary set", () => {
241+
const uint8Arr1 = new Uint8Array([...Array(4).keys()]);
242+
const uint8Arr2 = new Uint8Array([...Array(2).keys()]);
243+
const input = [uint8Arr1, uint8Arr2];
244+
expect(convertToNative({ ...emptyAttr, BS: input })).toEqual(new Set(input));
245+
});
246+
247+
it("string set", () => {
248+
const input = ["one", "two", "three"];
249+
expect(convertToNative({ ...emptyAttr, SS: input })).toEqual(new Set(input));
250+
});
251+
});
252+
253+
describe(`unsupported type`, () => {
254+
["A", "P", "LS"].forEach((type) => {
255+
it(`throws for unsupported type: ${type}`, () => {
256+
expect(() => {
257+
convertToNative({ ...emptyAttr, [type]: "data" });
258+
}).toThrowError(`Unsupported type passed: ${type}`);
259+
});
260+
});
261+
});
262+
263+
it(`no value defined`, () => {
264+
expect(() => {
265+
convertToNative(emptyAttr);
266+
}).toThrowError(`No value defined: ${emptyAttr}`);
267+
});
268+
});

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

+82
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { AttributeValue } from "@aws-sdk/client-dynamodb";
2+
3+
import { NativeAttributeValue, NumberValue } from "./models";
4+
import { unmarshallOptions } from "./unmarshall";
5+
6+
/**
7+
* Convert a DynamoDB AttributeValue object to its equivalent JavaScript type.
8+
*
9+
* @param {AttributeValue} data - The DynamoDB record to convert to JavaScript type.
10+
* @param {unmarshallOptions} options - An optional configuration object for `convertToNative`.
11+
*/
12+
export const convertToNative = (data: AttributeValue, options?: unmarshallOptions): NativeAttributeValue => {
13+
for (const [key, value] of Object.entries(data)) {
14+
if (value !== undefined) {
15+
switch (key) {
16+
case "NULL":
17+
return null;
18+
case "BOOL":
19+
return Boolean(value);
20+
case "N":
21+
return convertNumber(value as string, options);
22+
case "B":
23+
return convertBinary(value as Uint8Array);
24+
case "S":
25+
return convertString(value as string);
26+
case "L":
27+
return convertList(value as AttributeValue[], options);
28+
case "M":
29+
return convertMap(value as { [key: string]: AttributeValue }, options);
30+
case "NS":
31+
return new Set((value as string[]).map((item) => convertNumber(item, options)));
32+
case "BS":
33+
return new Set((value as Uint8Array[]).map(convertBinary));
34+
case "SS":
35+
return new Set((value as string[]).map(convertString));
36+
default:
37+
throw new Error(`Unsupported type passed: ${key}`);
38+
}
39+
}
40+
}
41+
throw new Error(`No value defined: ${data}`);
42+
};
43+
44+
const convertNumber = (numString: string, options?: unmarshallOptions): number | bigint | NumberValue => {
45+
if (options?.wrapNumbers) {
46+
return { value: numString };
47+
}
48+
49+
const num = Number(numString);
50+
const infinityValues = [Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY];
51+
if ((num > Number.MAX_SAFE_INTEGER || num < Number.MIN_SAFE_INTEGER) && !infinityValues.includes(num)) {
52+
if (typeof BigInt === "function") {
53+
return BigInt(num);
54+
} else {
55+
throw new Error(`${numString} is outside SAFE_INTEGER bounds. Set options.wrapNumbers to get string value.`);
56+
}
57+
} else if (num.toString() !== numString) {
58+
throw new Error(
59+
`Value ${numString} is outside IEEE 754 Floating-Point Arithmetic. Set options.wrapNumbers to get string value.`
60+
);
61+
}
62+
return num;
63+
};
64+
65+
// For future-proofing: Functions from scalar value as well as set value
66+
const convertString = (stringValue: string): string => stringValue;
67+
const convertBinary = (binaryValue: Uint8Array): Uint8Array => binaryValue;
68+
69+
const convertList = (list: AttributeValue[], options?: unmarshallOptions): NativeAttributeValue[] =>
70+
list.map((item) => convertToNative(item, options));
71+
72+
const convertMap = (
73+
map: { [key: string]: AttributeValue },
74+
options?: unmarshallOptions
75+
): { [key: string]: NativeAttributeValue } =>
76+
Object.entries(map).reduce(
77+
(acc: { [key: string]: NativeAttributeValue }, [key, value]: [string, AttributeValue]) => ({
78+
...acc,
79+
[key]: convertToNative(value, options),
80+
}),
81+
{}
82+
);

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

+2
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
11
export * from "./convertToAttr";
2+
export * from "./convertToNative";
23
export * from "./marshall";
34
export * from "./models";
5+
export * from "./unmarshall";

0 commit comments

Comments
 (0)