Skip to content

Commit 24e5b28

Browse files
authored
fix(lib-dynamodb): support command reuse (#7216)
* fix(lib-dynamodb): support command reuse * fix(lib-dynamodb): remove unnecessary copying
1 parent 0082621 commit 24e5b28

File tree

5 files changed

+285
-4
lines changed

5 files changed

+285
-4
lines changed

lib/lib-dynamodb/package.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,11 @@
1515
"clean": "rimraf ./dist-* && rimraf *.tsbuildinfo",
1616
"extract:docs": "api-extractor run --local",
1717
"test": "yarn g:vitest run",
18-
"test:e2e": "yarn g:vitest run -c vitest.config.e2e.ts --mode development",
1918
"test:watch": "yarn g:vitest watch",
20-
"test:e2e:watch": "yarn g:vitest watch -c vitest.config.e2e.ts"
19+
"test:e2e": "yarn g:vitest run -c vitest.config.e2e.ts --mode development",
20+
"test:e2e:watch": "yarn g:vitest watch -c vitest.config.e2e.ts",
21+
"test:integration": "yarn g:vitest run -c vitest.config.integ.ts --mode development",
22+
"test:integration:watch": "yarn g:vitest watch -c vitest.config.integ.ts"
2123
},
2224
"engines": {
2325
"node": ">=18.0.0"

lib/lib-dynamodb/src/baseCommand/DynamoDBDocumentClientCommand.spec.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ class AnyCommand extends DynamoDBDocumentClientCommand<{}, {}, {}, {}, {}> {
2020
addRelativeTo(fn: any, config: any) {
2121
this.argCaptor.push([fn, config]);
2222
},
23+
add(fn: any, config: any) {},
2324
},
2425
} as any;
2526
protected readonly clientCommandName = "AnyCommand";

lib/lib-dynamodb/src/baseCommand/DynamoDBDocumentClientCommand.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,18 @@ export abstract class DynamoDBDocumentClientCommand<
4848
args: InitializeHandlerArguments<Input | BaseInput>
4949
): Promise<InitializeHandlerOutput<Output | BaseOutput>> => {
5050
setFeature(context, "DDB_MAPPER", "d");
51-
args.input = marshallInput(args.input, this.inputKeyNodes, marshallOptions);
52-
return next(args);
51+
return next({
52+
...args,
53+
/**
54+
* We overwrite `args.input` at this middleware, but do not
55+
* mutate the args object itself, which is initially the Command instance.
56+
*
57+
* The reason for this is to prevent mutations to the Command instance's inputs
58+
* from being carried over if the Command instance is reused in a new
59+
* request.
60+
*/
61+
input: marshallInput(args.input, this.inputKeyNodes, marshallOptions),
62+
});
5363
},
5464
{
5565
name: "DocumentMarshall",
Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,259 @@
1+
import { DynamoDB, ScanCommand } from "@aws-sdk/client-dynamodb";
2+
import { HeadBucketCommand, HeadBucketCommandInput, S3Client } from "@aws-sdk/client-s3";
3+
import { DynamoDBDocument, ScanCommand as DocumentScanCommand, ScanCommandInput } from "@aws-sdk/lib-dynamodb";
4+
import { describe, expect, test as it } from "vitest";
5+
6+
import { requireRequestsFrom } from "../../../../private/aws-util-test/src";
7+
8+
describe("DynamoDBDocument command mutability", () => {
9+
it("should allow sending the same command more than once without mutating the Command instance", async () => {
10+
const ddb = new DynamoDB({
11+
region: "us-west-2",
12+
});
13+
14+
const doc = DynamoDBDocument.from(ddb);
15+
16+
doc.middlewareStack.add(
17+
(next) => async (args) => {
18+
(args.input as any).TableName = "modified-by-middleware";
19+
return next(args);
20+
},
21+
{
22+
name: "input-modifying-custom-middleware",
23+
}
24+
);
25+
26+
let requestCount = 0;
27+
28+
requireRequestsFrom(doc).toMatch({
29+
hostname: /dynamodb/,
30+
body(json: string) {
31+
const requestBody = JSON.parse(json);
32+
if (requestCount === 0) {
33+
expect(requestBody).toEqual({
34+
ExpressionAttributeValues: { ":id": { S: "1" } },
35+
FilterExpression: "id = :id",
36+
TableName: "modified-by-middleware",
37+
});
38+
} else if (requestCount === 1) {
39+
expect(requestBody).toEqual({
40+
ExpressionAttributeValues: { ":id": { S: "1" } },
41+
FilterExpression: "id = :id",
42+
TableName: "modified-by-middleware",
43+
ExclusiveStartKey: {
44+
id: {
45+
S: "abc",
46+
},
47+
},
48+
});
49+
} else if (requestCount === 2) {
50+
expect(requestBody).toEqual({
51+
ExpressionAttributeValues: { ":id": { S: "1" } },
52+
FilterExpression: "id = :id",
53+
TableName: "modified-by-middleware",
54+
ExclusiveStartKey: {
55+
id: { S: "def" },
56+
},
57+
});
58+
} else if (requestCount === 3) {
59+
expect(requestBody).toEqual({
60+
ExpressionAttributeValues: { ":id": { S: "1" } },
61+
FilterExpression: "id = :id",
62+
TableName: "modified-by-middleware",
63+
ExclusiveStartKey: {
64+
id: { S: "ghi" },
65+
},
66+
});
67+
}
68+
requestCount += 1;
69+
},
70+
});
71+
72+
const params: ScanCommandInput = {
73+
TableName: "test",
74+
FilterExpression: "id = :id",
75+
ExpressionAttributeValues: {
76+
":id": "1",
77+
},
78+
};
79+
80+
const command = new DocumentScanCommand(params);
81+
82+
await doc.send(command);
83+
params.ExclusiveStartKey = { id: "abc" };
84+
await doc.send(command);
85+
params.ExclusiveStartKey = { id: "def" };
86+
await doc.send(command);
87+
params.ExclusiveStartKey = { id: "ghi" };
88+
await doc.send(command);
89+
90+
// params should remain what it was set to by the caller,
91+
// disregarding mutations applied by the AttributeValue marshaller.
92+
expect(params).toEqual({
93+
TableName: "modified-by-middleware",
94+
FilterExpression: "id = :id",
95+
ExpressionAttributeValues: {
96+
":id": "1",
97+
},
98+
ExclusiveStartKey: {
99+
id: "ghi",
100+
},
101+
});
102+
103+
expect.assertions(9);
104+
});
105+
106+
it("the base dynamodb client can also use Command instances repeatedly", async () => {
107+
const ddb = new DynamoDB({
108+
region: "us-west-2",
109+
});
110+
111+
ddb.middlewareStack.add(
112+
(next) => async (args) => {
113+
(args.input as any).TableName = "modified-by-middleware";
114+
return next(args);
115+
},
116+
{
117+
name: "input-modifying-custom-middleware",
118+
}
119+
);
120+
121+
let requestCount = 0;
122+
123+
requireRequestsFrom(ddb).toMatch({
124+
hostname: /dynamodb/,
125+
body(json: string) {
126+
const requestBody = JSON.parse(json);
127+
if (requestCount === 0) {
128+
expect(requestBody).toEqual({
129+
ExpressionAttributeValues: { ":id": { S: "1" } },
130+
FilterExpression: "id = :id",
131+
TableName: "modified-by-middleware",
132+
});
133+
} else if (requestCount === 1) {
134+
expect(requestBody).toEqual({
135+
ExpressionAttributeValues: { ":id": { S: "1" } },
136+
FilterExpression: "id = :id",
137+
TableName: "modified-by-middleware",
138+
ExclusiveStartKey: {
139+
id: {
140+
S: "abc",
141+
},
142+
},
143+
});
144+
} else if (requestCount === 2) {
145+
expect(requestBody).toEqual({
146+
ExpressionAttributeValues: { ":id": { S: "1" } },
147+
FilterExpression: "id = :id",
148+
TableName: "modified-by-middleware",
149+
ExclusiveStartKey: {
150+
id: { S: "def" },
151+
},
152+
});
153+
} else if (requestCount === 3) {
154+
expect(requestBody).toEqual({
155+
ExpressionAttributeValues: { ":id": { S: "1" } },
156+
FilterExpression: "id = :id",
157+
TableName: "modified-by-middleware",
158+
ExclusiveStartKey: {
159+
id: { S: "ghi" },
160+
},
161+
});
162+
}
163+
requestCount += 1;
164+
},
165+
});
166+
167+
const params: ScanCommandInput = {
168+
TableName: "test",
169+
FilterExpression: "id = :id",
170+
ExpressionAttributeValues: {
171+
":id": { S: "1" },
172+
},
173+
};
174+
175+
const command = new ScanCommand(params);
176+
177+
await ddb.send(command);
178+
params.ExclusiveStartKey = { id: { S: "abc" } };
179+
await ddb.send(command);
180+
params.ExclusiveStartKey = { id: { S: "def" } };
181+
await ddb.send(command);
182+
params.ExclusiveStartKey = { id: { S: "ghi" } };
183+
await ddb.send(command);
184+
185+
// for regular clients, middleware modifications to the
186+
// args.input object also persist beyond the request.
187+
expect(params).toEqual({
188+
TableName: "modified-by-middleware",
189+
FilterExpression: "id = :id",
190+
ExpressionAttributeValues: {
191+
":id": { S: "1" },
192+
},
193+
ExclusiveStartKey: {
194+
id: {
195+
S: "ghi",
196+
},
197+
},
198+
});
199+
200+
expect.assertions(9);
201+
});
202+
203+
it("other clients can also use Command instances repeatedly", async () => {
204+
const s3 = new S3Client({
205+
region: "us-west-2",
206+
});
207+
208+
s3.middlewareStack.add(
209+
(next) => async (args) => {
210+
(args.input as any).ExpectedBucketOwner = "me";
211+
return next(args);
212+
},
213+
{
214+
name: "input-modifying-custom-middleware",
215+
}
216+
);
217+
218+
let requestCount = 0;
219+
220+
requireRequestsFrom(s3).toMatch({
221+
headers: {
222+
"x-amz-expected-bucket-owner": /^me$/,
223+
},
224+
hostname: (h: string) => {
225+
if (requestCount === 0) {
226+
expect(h).toEqual(`bucket1.s3.us-west-2.amazonaws.com`);
227+
} else if (requestCount === 1) {
228+
expect(h).toEqual(`bucket2.s3.us-west-2.amazonaws.com`);
229+
} else if (requestCount === 2) {
230+
expect(h).toEqual(`bucket3.s3.us-west-2.amazonaws.com`);
231+
} else if (requestCount === 3) {
232+
expect(h).toEqual(`bucket4.s3.us-west-2.amazonaws.com`);
233+
}
234+
requestCount += 1;
235+
},
236+
});
237+
238+
const params: HeadBucketCommandInput = {
239+
Bucket: "bucket1",
240+
};
241+
242+
const command = new HeadBucketCommand(params);
243+
244+
await s3.send(command);
245+
params.Bucket = "bucket2";
246+
await s3.send(command);
247+
params.Bucket = `bucket3`;
248+
await s3.send(command);
249+
params.Bucket = `bucket4`;
250+
await s3.send(command);
251+
252+
expect(params).toEqual({
253+
Bucket: "bucket4",
254+
ExpectedBucketOwner: "me",
255+
});
256+
257+
expect.assertions(9);
258+
});
259+
});
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { defineConfig } from "vitest/config";
2+
3+
export default defineConfig({
4+
test: {
5+
exclude: ["**/*.{e2e,browser}.spec.ts"],
6+
include: ["**/*.integ.spec.ts"],
7+
environment: "node",
8+
},
9+
});

0 commit comments

Comments
 (0)