Skip to content

fix(lib-dynamodb): support command reuse #7216

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Jul 21, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions lib/lib-dynamodb/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,11 @@
"clean": "rimraf ./dist-* && rimraf *.tsbuildinfo",
"extract:docs": "api-extractor run --local",
"test": "yarn g:vitest run",
"test:e2e": "yarn g:vitest run -c vitest.config.e2e.ts --mode development",
"test:watch": "yarn g:vitest watch",
"test:e2e:watch": "yarn g:vitest watch -c vitest.config.e2e.ts"
"test:e2e": "yarn g:vitest run -c vitest.config.e2e.ts --mode development",
"test:e2e:watch": "yarn g:vitest watch -c vitest.config.e2e.ts",
"test:integration": "yarn g:vitest run -c vitest.config.integ.ts --mode development",
"test:integration:watch": "yarn g:vitest watch -c vitest.config.integ.ts"
},
"engines": {
"node": ">=18.0.0"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ class AnyCommand extends DynamoDBDocumentClientCommand<{}, {}, {}, {}, {}> {
addRelativeTo(fn: any, config: any) {
this.argCaptor.push([fn, config]);
},
add(fn: any, config: any) {},
},
} as any;
protected readonly clientCommandName = "AnyCommand";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,18 @@ export abstract class DynamoDBDocumentClientCommand<
args: InitializeHandlerArguments<Input | BaseInput>
): Promise<InitializeHandlerOutput<Output | BaseOutput>> => {
setFeature(context, "DDB_MAPPER", "d");
args.input = marshallInput(args.input, this.inputKeyNodes, marshallOptions);
return next(args);
return next({
...args,
/**
* We overwrite `args.input` at this middleware, but do not
* mutate the args object itself, which is initially the Command instance.
*
* The reason for this is to prevent mutations to the Command instance's inputs
* from being carried over if the Command instance is reused in a new
* request.
*/
input: marshallInput(args.input, this.inputKeyNodes, marshallOptions),
});
},
{
name: "DocumentMarshall",
Expand Down
259 changes: 259 additions & 0 deletions lib/lib-dynamodb/src/test/mutability.integ.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,259 @@
import { DynamoDB, ScanCommand } from "@aws-sdk/client-dynamodb";
import { HeadBucketCommand, HeadBucketCommandInput, S3Client } from "@aws-sdk/client-s3";
import { DynamoDBDocument, ScanCommand as DocumentScanCommand, ScanCommandInput } from "@aws-sdk/lib-dynamodb";
import { describe, expect, test as it } from "vitest";

import { requireRequestsFrom } from "../../../../private/aws-util-test/src";

describe("DynamoDBDocument command mutability", () => {
it("should allow sending the same command more than once without mutating the Command instance", async () => {
const ddb = new DynamoDB({
region: "us-west-2",
});

const doc = DynamoDBDocument.from(ddb);

doc.middlewareStack.add(
(next) => async (args) => {
(args.input as any).TableName = "modified-by-middleware";
return next(args);
},
{
name: "input-modifying-custom-middleware",
}
);

let requestCount = 0;

requireRequestsFrom(doc).toMatch({
hostname: /dynamodb/,
body(json: string) {
const requestBody = JSON.parse(json);
if (requestCount === 0) {
expect(requestBody).toEqual({
ExpressionAttributeValues: { ":id": { S: "1" } },
FilterExpression: "id = :id",
TableName: "modified-by-middleware",
});
} else if (requestCount === 1) {
expect(requestBody).toEqual({
ExpressionAttributeValues: { ":id": { S: "1" } },
FilterExpression: "id = :id",
TableName: "modified-by-middleware",
ExclusiveStartKey: {
id: {
S: "abc",
},
},
});
} else if (requestCount === 2) {
expect(requestBody).toEqual({
ExpressionAttributeValues: { ":id": { S: "1" } },
FilterExpression: "id = :id",
TableName: "modified-by-middleware",
ExclusiveStartKey: {
id: { S: "def" },
},
});
} else if (requestCount === 3) {
expect(requestBody).toEqual({
ExpressionAttributeValues: { ":id": { S: "1" } },
FilterExpression: "id = :id",
TableName: "modified-by-middleware",
ExclusiveStartKey: {
id: { S: "ghi" },
},
});
}
requestCount += 1;
},
});

const params: ScanCommandInput = {
TableName: "test",
FilterExpression: "id = :id",
ExpressionAttributeValues: {
":id": "1",
},
};

const command = new DocumentScanCommand(params);

await doc.send(command);
params.ExclusiveStartKey = { id: "abc" };
await doc.send(command);
params.ExclusiveStartKey = { id: "def" };
await doc.send(command);
params.ExclusiveStartKey = { id: "ghi" };
await doc.send(command);

// params should remain what it was set to by the caller,
// disregarding mutations applied by the AttributeValue marshaller.
expect(params).toEqual({
TableName: "modified-by-middleware",
FilterExpression: "id = :id",
ExpressionAttributeValues: {
":id": "1",
},
ExclusiveStartKey: {
id: "ghi",
},
});

expect.assertions(9);
});

it("the base dynamodb client can also use Command instances repeatedly", async () => {
const ddb = new DynamoDB({
region: "us-west-2",
});

ddb.middlewareStack.add(
(next) => async (args) => {
(args.input as any).TableName = "modified-by-middleware";
return next(args);
},
{
name: "input-modifying-custom-middleware",
}
);

let requestCount = 0;

requireRequestsFrom(ddb).toMatch({
hostname: /dynamodb/,
body(json: string) {
const requestBody = JSON.parse(json);
if (requestCount === 0) {
expect(requestBody).toEqual({
ExpressionAttributeValues: { ":id": { S: "1" } },
FilterExpression: "id = :id",
TableName: "modified-by-middleware",
});
} else if (requestCount === 1) {
expect(requestBody).toEqual({
ExpressionAttributeValues: { ":id": { S: "1" } },
FilterExpression: "id = :id",
TableName: "modified-by-middleware",
ExclusiveStartKey: {
id: {
S: "abc",
},
},
});
} else if (requestCount === 2) {
expect(requestBody).toEqual({
ExpressionAttributeValues: { ":id": { S: "1" } },
FilterExpression: "id = :id",
TableName: "modified-by-middleware",
ExclusiveStartKey: {
id: { S: "def" },
},
});
} else if (requestCount === 3) {
expect(requestBody).toEqual({
ExpressionAttributeValues: { ":id": { S: "1" } },
FilterExpression: "id = :id",
TableName: "modified-by-middleware",
ExclusiveStartKey: {
id: { S: "ghi" },
},
});
}
requestCount += 1;
},
});

const params: ScanCommandInput = {
TableName: "test",
FilterExpression: "id = :id",
ExpressionAttributeValues: {
":id": { S: "1" },
},
};

const command = new ScanCommand(params);

await ddb.send(command);
params.ExclusiveStartKey = { id: { S: "abc" } };
await ddb.send(command);
params.ExclusiveStartKey = { id: { S: "def" } };
await ddb.send(command);
params.ExclusiveStartKey = { id: { S: "ghi" } };
await ddb.send(command);

// for regular clients, middleware modifications to the
// args.input object also persist beyond the request.
expect(params).toEqual({
TableName: "modified-by-middleware",
FilterExpression: "id = :id",
ExpressionAttributeValues: {
":id": { S: "1" },
},
ExclusiveStartKey: {
id: {
S: "ghi",
},
},
});

expect.assertions(9);
});

it("other clients can also use Command instances repeatedly", async () => {
const s3 = new S3Client({
region: "us-west-2",
});

s3.middlewareStack.add(
(next) => async (args) => {
(args.input as any).ExpectedBucketOwner = "me";
return next(args);
},
{
name: "input-modifying-custom-middleware",
}
);

let requestCount = 0;

requireRequestsFrom(s3).toMatch({
headers: {
"x-amz-expected-bucket-owner": /^me$/,
},
hostname: (h: string) => {
if (requestCount === 0) {
expect(h).toEqual(`bucket1.s3.us-west-2.amazonaws.com`);
} else if (requestCount === 1) {
expect(h).toEqual(`bucket2.s3.us-west-2.amazonaws.com`);
} else if (requestCount === 2) {
expect(h).toEqual(`bucket3.s3.us-west-2.amazonaws.com`);
} else if (requestCount === 3) {
expect(h).toEqual(`bucket4.s3.us-west-2.amazonaws.com`);
}
requestCount += 1;
},
});

const params: HeadBucketCommandInput = {
Bucket: "bucket1",
};

const command = new HeadBucketCommand(params);

await s3.send(command);
params.Bucket = "bucket2";
await s3.send(command);
params.Bucket = `bucket3`;
await s3.send(command);
params.Bucket = `bucket4`;
await s3.send(command);

expect(params).toEqual({
Bucket: "bucket4",
ExpectedBucketOwner: "me",
});

expect.assertions(9);
});
});
9 changes: 9 additions & 0 deletions lib/lib-dynamodb/vitest.config.integ.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { defineConfig } from "vitest/config";

export default defineConfig({
test: {
exclude: ["**/*.{e2e,browser}.spec.ts"],
include: ["**/*.integ.spec.ts"],
environment: "node",
},
});
Loading