Skip to content

Commit e1ba507

Browse files
authored
fix(lib-dynamodb): preserve collections when serializing class instances to map (#5826)
* fix(lib-dynamodb): preserve collections when serializing class instances to map * test(lib-dynamodb): new e2e scenario for class conversion
1 parent 10fe8be commit e1ba507

File tree

4 files changed

+207
-77
lines changed

4 files changed

+207
-77
lines changed

Diff for: lib/lib-dynamodb/src/commands/utils.spec.ts

+107-39
Original file line numberDiff line numberDiff line change
@@ -152,32 +152,31 @@ describe("object with function property", () => {
152152
const keyNodes = { Item: {} };
153153
const nativeAttrObj = { Item: { id: 1, func: () => {} }, ...notAttrValue };
154154
const attrObj = { Item: { id: { N: "1" } }, ...notAttrValue };
155+
155156
it("should remove functions", () => {
156157
expect(
157158
marshallInput(nativeAttrObj, keyNodes, { convertTopLevelContainer: true, convertClassInstanceToMap: true })
158159
).toEqual(attrObj);
159160
});
160161

161-
// List of functions
162-
const listOfFunctions = { Item: { id: 1, funcs: [() => {}, () => {}] }, ...notAttrValue };
163162
it("should remove functions from lists", () => {
163+
const listOfFunctions = { Item: { id: 1, funcs: [() => {}, () => {}] }, ...notAttrValue };
164164
expect(
165165
marshallInput(listOfFunctions, keyNodes, { convertTopLevelContainer: true, convertClassInstanceToMap: true })
166166
).toEqual({ Item: { id: { N: "1" }, funcs: { L: [] } }, ...notAttrValue });
167167
});
168168

169-
// Nested list of functions
170-
const nestedListOfFunctions = {
171-
Item: {
172-
id: 1,
173-
funcs: [
174-
[() => {}, () => {}],
175-
[() => {}, () => {}],
176-
],
177-
},
178-
...notAttrValue,
179-
};
180169
it("should remove functions from nested lists", () => {
170+
const nestedListOfFunctions = {
171+
Item: {
172+
id: 1,
173+
funcs: [
174+
[() => {}, () => {}],
175+
[() => {}, () => {}],
176+
],
177+
},
178+
...notAttrValue,
179+
};
181180
expect(
182181
marshallInput(nestedListOfFunctions, keyNodes, {
183182
convertTopLevelContainer: true,
@@ -186,25 +185,103 @@ describe("object with function property", () => {
186185
).toEqual({ Item: { id: { N: "1" }, funcs: { L: [{ L: [] }, { L: [] }] } }, ...notAttrValue });
187186
});
188187

189-
// Nested list of functions 3 levels down
190-
const nestedListOfFunctions3Levels = {
191-
Item: {
192-
id: 1,
193-
funcs: [
194-
[
195-
[() => {}, () => {}],
196-
[() => {}, () => {}],
197-
],
198-
[
199-
[() => {}, () => {}],
200-
[() => {}, () => {}],
201-
],
202-
],
203-
},
204-
...notAttrValue,
205-
};
188+
it("should convert data class objects without affecting known data collection objects", () => {
189+
const nestedListOfFunctions3Levels = {
190+
Item: {
191+
id: 1,
192+
x: {
193+
map: new Map([
194+
[1, 1],
195+
[2, 2],
196+
[3, 3],
197+
]),
198+
set: new Set([1, 2, 3]),
199+
binary: new Uint8Array([1, 2, 3]),
200+
myPojo: new (class {
201+
public a = 1;
202+
public b = 2;
203+
public c = 3;
204+
public method() {
205+
return "method";
206+
}
207+
public get getter() {
208+
return "getter";
209+
}
210+
public arrowFn = () => "arrowFn";
211+
public ownFunction = function () {
212+
return "ownFunction";
213+
};
214+
})(),
215+
},
216+
},
217+
...notAttrValue,
218+
};
219+
expect(
220+
marshallInput(nestedListOfFunctions3Levels, keyNodes, {
221+
convertTopLevelContainer: true,
222+
convertClassInstanceToMap: true,
223+
})
224+
).toEqual({
225+
Item: {
226+
id: { N: "1" },
227+
x: {
228+
M: {
229+
binary: {
230+
B: new Uint8Array([1, 2, 3]),
231+
},
232+
map: {
233+
M: {
234+
"1": {
235+
N: "1",
236+
},
237+
"2": {
238+
N: "2",
239+
},
240+
"3": {
241+
N: "3",
242+
},
243+
},
244+
},
245+
myPojo: {
246+
M: {
247+
a: {
248+
N: "1",
249+
},
250+
b: {
251+
N: "2",
252+
},
253+
c: {
254+
N: "3",
255+
},
256+
},
257+
},
258+
set: {
259+
NS: ["1", "2", "3"],
260+
},
261+
},
262+
},
263+
},
264+
...notAttrValue,
265+
});
266+
});
206267

207268
it("should remove functions from a nested list of depth 3", () => {
269+
const nestedListOfFunctions3Levels = {
270+
Item: {
271+
id: 1,
272+
funcs: [
273+
[
274+
[() => {}, () => {}],
275+
[() => {}, () => {}],
276+
],
277+
[
278+
[() => {}, () => {}],
279+
[() => {}, () => {}],
280+
],
281+
],
282+
},
283+
...notAttrValue,
284+
};
208285
expect(
209286
marshallInput(nestedListOfFunctions3Levels, keyNodes, {
210287
convertTopLevelContainer: true,
@@ -227,13 +304,4 @@ describe("object with function property", () => {
227304
...notAttrValue,
228305
});
229306
});
230-
it("should throw when recursion depth has exceeded", () => {
231-
const obj = {} as any;
232-
obj.SELF = obj;
233-
expect(() => marshallInput(obj, {}, { convertClassInstanceToMap: true })).toThrow(
234-
new Error(
235-
"Recursive copy depth exceeded 1000. Please set options.convertClassInstanceToMap to false and manually remove functions from your data object."
236-
)
237-
);
238-
});
239307
});

Diff for: lib/lib-dynamodb/src/commands/utils.ts

+20-36
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,9 @@ const processObj = (obj: any, processFunc: Function, keyNodes?: KeyNodes): any =
5050
return processAllKeysInObj(obj, processFunc, SELF);
5151
} else if (goToNextLevel) {
5252
return Object.entries(obj ?? {}).reduce((acc, [k, v]) => {
53-
acc[k] = processObj(v, processFunc, keyNodes[NEXT_LEVEL]);
53+
if (typeof v !== "function") {
54+
acc[k] = processObj(v, processFunc, keyNodes[NEXT_LEVEL]);
55+
}
5456
return acc;
5557
}, (Array.isArray(obj) ? [] : {}) as any);
5658
}
@@ -62,14 +64,22 @@ const processObj = (obj: any, processFunc: Function, keyNodes?: KeyNodes): any =
6264
const processKeysInObj = (obj: any, processFunc: Function, keyNodes: KeyNodeChildren) => {
6365
let accumulator: any;
6466
if (Array.isArray(obj)) {
65-
accumulator = [...obj];
67+
accumulator = [...obj].filter((item) => typeof item !== "function");
6668
} else {
67-
accumulator = { ...obj };
69+
accumulator = {};
70+
for (const [k, v] of Object.entries(obj)) {
71+
if (typeof v !== "function") {
72+
accumulator[k] = v;
73+
}
74+
}
6875
}
6976

7077
for (const [nodeKey, nodes] of Object.entries(keyNodes)) {
78+
if (typeof obj[nodeKey] === "function") {
79+
continue;
80+
}
7181
const processedValue = processObj(obj[nodeKey], processFunc, nodes);
72-
if (processedValue !== undefined) {
82+
if (processedValue !== undefined && typeof processedValue !== "function") {
7383
accumulator[nodeKey] = processedValue;
7484
}
7585
}
@@ -79,52 +89,26 @@ const processKeysInObj = (obj: any, processFunc: Function, keyNodes: KeyNodeChil
7989

8090
const processAllKeysInObj = (obj: any, processFunc: Function, keyNodes: KeyNodes): any => {
8191
if (Array.isArray(obj)) {
82-
return obj.map((item) => processObj(item, processFunc, keyNodes));
92+
return obj.filter((item) => typeof item !== "function").map((item) => processObj(item, processFunc, keyNodes));
8393
}
8494
return Object.entries(obj).reduce((acc, [key, value]) => {
95+
if (typeof value === "function") {
96+
return acc;
97+
}
8598
const processedValue = processObj(value, processFunc, keyNodes);
86-
if (processedValue !== undefined) {
99+
if (processedValue !== undefined && typeof processedValue !== "function") {
87100
acc[key] = processedValue;
88101
}
89102
return acc;
90103
}, {} as any);
91104
};
92105

93-
function copyWithoutFunctions(o: any, depth = 0): any {
94-
if (depth > 1000) {
95-
throw new Error(
96-
"Recursive copy depth exceeded 1000. Please set options.convertClassInstanceToMap to false and manually remove functions from your data object."
97-
);
98-
}
99-
if (typeof o === "object" || typeof o === "function") {
100-
if (Array.isArray(o)) {
101-
return o.filter((item) => typeof item !== "function").map((item) => copyWithoutFunctions(item, depth + 1));
102-
}
103-
if (o === null) {
104-
return null;
105-
}
106-
const copy = {} as any;
107-
for (const [key, value] of Object.entries(o)) {
108-
if (typeof value !== "function") {
109-
copy[key] = copyWithoutFunctions(value, depth + 1);
110-
}
111-
}
112-
return copy;
113-
} else {
114-
return o;
115-
}
116-
}
117-
118106
/**
119107
* @internal
120108
*/
121109
export const marshallInput = (obj: any, keyNodes: KeyNodeChildren, options?: marshallOptions) => {
122-
let _obj = obj;
123-
if (options?.convertClassInstanceToMap) {
124-
_obj = copyWithoutFunctions(obj);
125-
}
126110
const marshallFunc = (toMarshall: any) => marshall(toMarshall, options);
127-
return processKeysInObj(_obj, marshallFunc, keyNodes);
111+
return processKeysInObj(obj, marshallFunc, keyNodes);
128112
};
129113

130114
/**

Diff for: lib/lib-dynamodb/src/test/lib-dynamodb.e2e.spec.ts

+74
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ describe(DynamoDBDocument.name, () => {
3232
const doc = DynamoDBDocument.from(dynamodb, {
3333
marshallOptions: {
3434
convertTopLevelContainer: true,
35+
convertClassInstanceToMap: true,
3536
},
3637
unmarshallOptions: {
3738
wrapNumbers: true,
@@ -75,6 +76,10 @@ describe(DynamoDBDocument.name, () => {
7576
update: {} as Record<string, UpdateCommandOutput>,
7677
updateReadBack: {} as Record<string, GetCommandOutput>,
7778
delete: {} as Record<string, DeleteItemCommandOutput>,
79+
classInstanceConversion: {
80+
write: null as null | PutCommandOutput,
81+
read: null as null | GetCommandOutput,
82+
},
7883
};
7984

8085
const data = {
@@ -439,6 +444,57 @@ describe(DynamoDBDocument.name, () => {
439444
});
440445
})().catch(passError);
441446
}
447+
448+
log.classInstanceConversion.write = await doc
449+
.put({
450+
TableName,
451+
Item: {
452+
id: "classInstance",
453+
data: {
454+
a: new (class {
455+
public a = 1;
456+
public b = 2;
457+
public c = 3;
458+
public method() {
459+
return "method";
460+
}
461+
public get getter() {
462+
return "getter";
463+
}
464+
public arrowFn = () => "arrowFn";
465+
public ownFunction = function () {
466+
return "ownFunction";
467+
};
468+
})(),
469+
b: new (class {
470+
public a = 4;
471+
public b = 5;
472+
public c = 6;
473+
public method() {
474+
return "method";
475+
}
476+
public get getter() {
477+
return "getter";
478+
}
479+
public arrowFn = () => "arrowFn";
480+
public ownFunction = function () {
481+
return "ownFunction";
482+
};
483+
})(),
484+
},
485+
},
486+
})
487+
.catch(passError);
488+
489+
log.classInstanceConversion.read = await doc
490+
.get({
491+
ConsistentRead: true,
492+
TableName,
493+
Key: {
494+
id: "classInstance",
495+
},
496+
})
497+
.catch(passError);
442498
});
443499

444500
afterAll(async () => {
@@ -635,4 +691,22 @@ describe(DynamoDBDocument.name, () => {
635691
expect(log.delete[key].$metadata).toBeDefined();
636692
});
637693
}
694+
695+
it("can serialize class instances as maps", async () => {
696+
expect(log.classInstanceConversion.read?.Item).toEqual({
697+
id: "classInstance",
698+
data: {
699+
a: {
700+
a: NumberValue.from(1),
701+
b: NumberValue.from(2),
702+
c: NumberValue.from(3),
703+
},
704+
b: {
705+
a: NumberValue.from(4),
706+
b: NumberValue.from(5),
707+
c: NumberValue.from(6),
708+
},
709+
},
710+
});
711+
});
638712
});

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

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

33
import { marshallOptions } from "./marshall";
4-
import { NativeAttributeBinary, NativeAttributeValue, NativeScalarAttributeValue } from "./models";
4+
import { NativeAttributeBinary, NativeAttributeValue } from "./models";
55
import { NumberValue } from "./NumberValue";
66

77
/**
@@ -57,7 +57,11 @@ export const convertToAttr = (data: NativeAttributeValue, options?: marshallOpti
5757

5858
const convertToListAttr = (data: NativeAttributeValue[], options?: marshallOptions): { L: AttributeValue[] } => ({
5959
L: data
60-
.filter((item) => !options?.removeUndefinedValues || (options?.removeUndefinedValues && item !== undefined))
60+
.filter(
61+
(item) =>
62+
typeof item !== "function" &&
63+
(!options?.removeUndefinedValues || (options?.removeUndefinedValues && item !== undefined))
64+
)
6165
.map((item) => convertToAttr(item, options)),
6266
});
6367

0 commit comments

Comments
 (0)