diff --git a/README.md b/README.md index 1403600..02b8e31 100644 --- a/README.md +++ b/README.md @@ -33,9 +33,9 @@ Tsynamo simplifies the DynamoDB API so that you don't have to write commands wit - [Put item](#put-item) - [Delete item](#delete-item) - [Update item](#update-item) + - [Transactions](#transactions) - [Contributors](#contributors) - ## Requirements - [@aws-sdk/client-dynamodb](https://www.npmjs.com/package/@aws-sdk/client-dynamodb) @@ -72,6 +72,7 @@ export interface DDB { }; } ``` + > [!TIP] > Notice that you can have multiple tables in the DDB schema. Nested attributes are supported too. @@ -196,6 +197,7 @@ await tsynamoClient .execute(); ``` +> [!NOTE] > This would compile as the following FilterExpression: > `NOT eventType = "LOG_IN"`, i.e. return all events whose types is not "LOG_IN" @@ -282,6 +284,85 @@ await tsynamoClient .execute(); ``` +### Transactions + +One can also utilise [DynamoDB Transaction](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/transaction-apis.html) features using Tsynamo. You can perform operations to multiple tables in a single transaction command. + +#### Write transaction + +DynamoDB enables you to do multiple `Put`, `Update` and `Delete` in a single `WriteTransaction` command. One can also provide an optional `ClientRequestToken` to the transaction to ensure idempotency. + +```ts +const trx = tsynamoClient.createWriteTransaction(); + +trx.addItem({ + Put: tsynamoClient + .putItem("myTable") + .item({ userId: "313", dataTimestamp: 1 }), +}); + +trx.addItem({ + Update: tsynamoClient + .updateItem("myTable") + .keys({ userId: "313", dataTimestamp: 2 }) + .set("tags", "=", ["a", "b", "c"]), +}); + +trx.addItem({ + Delete: tsynamoClient.deleteItem("myTable").keys({ + userId: "313", + dataTimestamp: 3, + }), +}); + +await trx.execute(); +``` + +> [!IMPORTANT] +> When passing the items into the transaction using the tsynamoClient, do not execute the individual calls! Instead just pass in the query builder as the item. + +> [!WARNING] +> DynamoDB also supports doing `ConditionCheck` operations in the transaction, but Tsynamo does not yet support those. + +#### Read transaction + +Since the read transaction output can affect multiple tables, the resulting output is an array of tuples where the first item is the name of the table and the second item is the item itself (or `undefined` if the item was not found). This can be used as a discriminated union to determine the resulting item's type. + +```ts +const trx = tsynamoClient.createReadTransaction(); + +trx.addItem({ + Get: tsynamoClient.getItem("myTable").keys({ + userId: "123", + dataTimestamp: 222, + }), +}); + +trx.addItem({ + Get: tsynamoClient.getItem("myOtherTable").keys({ + userId: "321", + stringTimestamp: "222", + }), +}); + +const result = await trx.execute(); +``` + +Then, one can loop through the result items as so: + +```ts +// note that the items can be undefined if they were not found from DynamoDB +result.forEach(([table, item]) => { + if (table === "myTable") { + // item's type is DDB["myTable"] + // ... + } else if (table === "myOtherTable") { + // item's type is DDB["myOtherTable"] + // ... + } +}); +``` + ## Contributors

diff --git a/package.json b/package.json index 815009d..45f3530 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "tsynamo", "author": "woltsu", - "version": "0.0.9", + "version": "0.0.10", "description": "Typed query builder for DynamoDB", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/src/nodes/readTransactionNode.ts b/src/nodes/readTransactionNode.ts new file mode 100644 index 0000000..9ed1dd2 --- /dev/null +++ b/src/nodes/readTransactionNode.ts @@ -0,0 +1,6 @@ +import { TransactGetItemNode } from "./transactGetItemNode"; + +export type ReadTransactionNode = { + readonly kind: "ReadTransactionNode"; + readonly transactGetItems: TransactGetItemNode[]; +}; diff --git a/src/nodes/transactGetItemNode.ts b/src/nodes/transactGetItemNode.ts new file mode 100644 index 0000000..d128b15 --- /dev/null +++ b/src/nodes/transactGetItemNode.ts @@ -0,0 +1,6 @@ +import { GetNode } from "./getNode"; + +export type TransactGetItemNode = { + readonly kind: "TransactGetItemNode"; + readonly Get: GetNode; +}; diff --git a/src/nodes/transactWriteItemNode.ts b/src/nodes/transactWriteItemNode.ts new file mode 100644 index 0000000..c58f4db --- /dev/null +++ b/src/nodes/transactWriteItemNode.ts @@ -0,0 +1,10 @@ +import { DeleteNode } from "./deleteNode"; +import { PutNode } from "./putNode"; +import { UpdateNode } from "./updateNode"; + +export type TransactWriteItemNode = { + readonly kind: "TransactWriteItemNode"; + readonly Put?: PutNode; + readonly Delete?: DeleteNode; + readonly Update?: UpdateNode; +}; diff --git a/src/nodes/writeTransactionNode.ts b/src/nodes/writeTransactionNode.ts new file mode 100644 index 0000000..b33074d --- /dev/null +++ b/src/nodes/writeTransactionNode.ts @@ -0,0 +1,7 @@ +import { TransactWriteItemNode } from "./transactWriteItemNode"; + +export type WriteTransactionNode = { + readonly kind: "WriteTransactionNode"; + readonly transactWriteItems: TransactWriteItemNode[]; + readonly clientRequestToken?: string; +}; diff --git a/src/queryBuilders/__snapshots__/readTransactionBuilder.integration.test.ts.snap b/src/queryBuilders/__snapshots__/readTransactionBuilder.integration.test.ts.snap new file mode 100644 index 0000000..4977c04 --- /dev/null +++ b/src/queryBuilders/__snapshots__/readTransactionBuilder.integration.test.ts.snap @@ -0,0 +1,25 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`ReadTransactionBuilder > handles transaction with gets 1`] = ` +[ + [ + "myTable", + { + "dataTimestamp": 222, + "someBoolean": true, + "somethingElse": 2, + "userId": "123", + }, + ], + [ + "myOtherTable", + { + "userId": "123", + }, + ], + [ + "myTable", + undefined, + ], +] +`; diff --git a/src/queryBuilders/__snapshots__/writeTransactionBuilder.integration.test.ts.snap b/src/queryBuilders/__snapshots__/writeTransactionBuilder.integration.test.ts.snap new file mode 100644 index 0000000..2f841ea --- /dev/null +++ b/src/queryBuilders/__snapshots__/writeTransactionBuilder.integration.test.ts.snap @@ -0,0 +1,37 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`WriteTransactionBuilder > handles a transaction with a client request token 1`] = `[IdempotentParameterMismatchException: UnknownError]`; + +exports[`WriteTransactionBuilder > handles a transaction with failing conditions 1`] = `[TransactionCanceledException: Transaction cancelled, please refer cancellation reasons for specific reasons [ConditionalCheckFailed]]`; + +exports[`WriteTransactionBuilder > handles a transaction with puts 1`] = ` +[ + { + "dataTimestamp": 1, + "userId": "9999", + }, + { + "dataTimestamp": 2, + "userId": "9999", + }, +] +`; + +exports[`WriteTransactionBuilder > handles a transaction with updates 1`] = ` +[ + { + "dataTimestamp": 1, + "someBoolean": true, + "userId": "9999", + }, + { + "dataTimestamp": 2, + "tags": [ + "a", + "b", + "c", + ], + "userId": "9999", + }, +] +`; diff --git a/src/queryBuilders/deleteItemQueryBuilder.ts b/src/queryBuilders/deleteItemQueryBuilder.ts index 50dd957..e74aa81 100644 --- a/src/queryBuilders/deleteItemQueryBuilder.ts +++ b/src/queryBuilders/deleteItemQueryBuilder.ts @@ -24,77 +24,77 @@ import { export interface DeleteItemQueryBuilderInterface< DDB, Table extends keyof DDB, - O + O extends DDB[Table] > { // conditionExpression conditionExpression>( ...args: ComparatorExprArg - ): DeleteItemQueryBuilderInterface; + ): DeleteItemQueryBuilder; conditionExpression>( ...args: AttributeFuncExprArg - ): DeleteItemQueryBuilderInterface; + ): DeleteItemQueryBuilder; conditionExpression>( ...args: AttributeBeginsWithExprArg - ): DeleteItemQueryBuilderInterface; + ): DeleteItemQueryBuilder; conditionExpression>( ...args: AttributeContainsExprArg - ): DeleteItemQueryBuilderInterface; + ): DeleteItemQueryBuilder; conditionExpression>( ...args: AttributeBetweenExprArg - ): DeleteItemQueryBuilderInterface; + ): DeleteItemQueryBuilder; conditionExpression>( ...args: NotExprArg - ): DeleteItemQueryBuilderInterface; + ): DeleteItemQueryBuilder; conditionExpression>( ...args: BuilderExprArg - ): DeleteItemQueryBuilderInterface; + ): DeleteItemQueryBuilder; // orConditionExpression orConditionExpression>( ...args: ComparatorExprArg - ): DeleteItemQueryBuilderInterface; + ): DeleteItemQueryBuilder; orConditionExpression>( ...args: AttributeFuncExprArg - ): DeleteItemQueryBuilderInterface; + ): DeleteItemQueryBuilder; orConditionExpression>( ...args: AttributeBeginsWithExprArg - ): DeleteItemQueryBuilderInterface; + ): DeleteItemQueryBuilder; orConditionExpression>( ...args: AttributeContainsExprArg - ): DeleteItemQueryBuilderInterface; + ): DeleteItemQueryBuilder; orConditionExpression>( ...args: AttributeBetweenExprArg - ): DeleteItemQueryBuilderInterface; + ): DeleteItemQueryBuilder; orConditionExpression>( ...args: NotExprArg - ): DeleteItemQueryBuilderInterface; + ): DeleteItemQueryBuilder; orConditionExpression>( ...args: BuilderExprArg - ): DeleteItemQueryBuilderInterface; + ): DeleteItemQueryBuilder; returnValues( option: Extract - ): DeleteItemQueryBuilderInterface; + ): DeleteItemQueryBuilder; returnValuesOnConditionCheckFailure( option: Extract - ): DeleteItemQueryBuilderInterface; + ): DeleteItemQueryBuilder; keys & PickSkRequired>( pk: Keys - ): DeleteItemQueryBuilderInterface; + ): DeleteItemQueryBuilder; compile(): DeleteCommand; execute(): Promise[] | undefined>; @@ -117,7 +117,7 @@ export class DeleteItemQueryBuilder< conditionExpression>( ...args: ExprArgs - ): DeleteItemQueryBuilderInterface { + ): DeleteItemQueryBuilder { const eB = new ExpressionBuilder({ node: { ...this.#props.node.conditionExpression }, }); @@ -135,7 +135,7 @@ export class DeleteItemQueryBuilder< orConditionExpression>( ...args: ExprArgs - ): DeleteItemQueryBuilderInterface { + ): DeleteItemQueryBuilder { const eB = new ExpressionBuilder({ node: { ...this.#props.node.conditionExpression }, }); @@ -153,7 +153,7 @@ export class DeleteItemQueryBuilder< returnValues( option: Extract - ): DeleteItemQueryBuilderInterface { + ): DeleteItemQueryBuilder { return new DeleteItemQueryBuilder({ ...this.#props, node: { @@ -168,7 +168,7 @@ export class DeleteItemQueryBuilder< returnValuesOnConditionCheckFailure( option: Extract - ): DeleteItemQueryBuilderInterface { + ): DeleteItemQueryBuilder { return new DeleteItemQueryBuilder({ ...this.#props, node: { @@ -199,11 +199,16 @@ export class DeleteItemQueryBuilder< compile = (): DeleteCommand => { return this.#props.queryCompiler.compile(this.#props.node); }; + execute = async (): Promise[] | undefined> => { const deleteCommand = this.compile(); const data = await this.#props.ddbClient.send(deleteCommand); return data.Attributes as any; }; + + public get node() { + return this.#props.node; + } } preventAwait( diff --git a/src/queryBuilders/getItemQueryBuilder.ts b/src/queryBuilders/getItemQueryBuilder.ts index 2935428..a2eceae 100644 --- a/src/queryBuilders/getItemQueryBuilder.ts +++ b/src/queryBuilders/getItemQueryBuilder.ts @@ -13,19 +13,19 @@ import { preventAwait } from "../util/preventAwait"; export interface GetQueryBuilderInterface { keys & PickSkRequired>( pk: Keys - ): GetQueryBuilderInterface; + ): GetQueryBuilder; - consistentRead(enabled: boolean): GetQueryBuilderInterface; + consistentRead(enabled: boolean): GetQueryBuilder; attributes[] & string[]>( attributes: A - ): GetQueryBuilderInterface>; + ): GetQueryBuilder>; compile(): GetCommand; execute(): Promise | undefined>; } -export class GetQueryBuilder +export class GetQueryBuilder implements GetQueryBuilderInterface { readonly #props: GetQueryBuilderProps; @@ -36,7 +36,7 @@ export class GetQueryBuilder keys & PickSkRequired>( keys: Keys - ) { + ): GetQueryBuilder { return new GetQueryBuilder({ ...this.#props, node: { @@ -49,7 +49,7 @@ export class GetQueryBuilder }); } - consistentRead(enabled: boolean): GetQueryBuilderInterface { + consistentRead(enabled: boolean): GetQueryBuilder { return new GetQueryBuilder({ ...this.#props, node: { @@ -64,7 +64,7 @@ export class GetQueryBuilder attributes[] & string[]>( attributes: A - ): GetQueryBuilderInterface> { + ): GetQueryBuilder> { return new GetQueryBuilder({ ...this.#props, node: { @@ -80,11 +80,16 @@ export class GetQueryBuilder compile(): GetCommand { return this.#props.queryCompiler.compile(this.#props.node); } + execute = async (): Promise | undefined> => { const command = this.compile(); const item = await this.#props.ddbClient.send(command); return (item.Item as ExecuteOutput) ?? undefined; }; + + public get node() { + return this.#props.node; + } } preventAwait( diff --git a/src/queryBuilders/putItemQueryBuilder.ts b/src/queryBuilders/putItemQueryBuilder.ts index 2ddbb42..06fa3c4 100644 --- a/src/queryBuilders/putItemQueryBuilder.ts +++ b/src/queryBuilders/putItemQueryBuilder.ts @@ -16,52 +16,56 @@ import { NotExprArg, } from "./expressionBuilder"; -export interface PutItemQueryBuilderInterface { +export interface PutItemQueryBuilderInterface< + DDB, + Table extends keyof DDB, + O extends DDB[Table] +> { // conditionExpression conditionExpression>( ...args: ComparatorExprArg - ): PutItemQueryBuilderInterface; + ): PutItemQueryBuilder; conditionExpression>( ...args: AttributeFuncExprArg - ): PutItemQueryBuilderInterface; + ): PutItemQueryBuilder; conditionExpression>( ...args: AttributeBeginsWithExprArg - ): PutItemQueryBuilderInterface; + ): PutItemQueryBuilder; conditionExpression>( ...args: AttributeContainsExprArg - ): PutItemQueryBuilderInterface; + ): PutItemQueryBuilder; conditionExpression>( ...args: AttributeBetweenExprArg - ): PutItemQueryBuilderInterface; + ): PutItemQueryBuilder; conditionExpression>( ...args: NotExprArg - ): PutItemQueryBuilderInterface; + ): PutItemQueryBuilder; conditionExpression>( ...args: BuilderExprArg - ): PutItemQueryBuilderInterface; + ): PutItemQueryBuilder; // orConditionExpression orConditionExpression>( ...args: ComparatorExprArg - ): PutItemQueryBuilderInterface; + ): PutItemQueryBuilder; orConditionExpression>( ...args: AttributeFuncExprArg - ): PutItemQueryBuilderInterface; + ): PutItemQueryBuilder; orConditionExpression>( ...args: AttributeBeginsWithExprArg - ): PutItemQueryBuilderInterface; + ): PutItemQueryBuilder; orConditionExpression>( ...args: AttributeContainsExprArg - ): PutItemQueryBuilderInterface; + ): PutItemQueryBuilder; orConditionExpression>( ...args: AttributeBetweenExprArg @@ -69,19 +73,19 @@ export interface PutItemQueryBuilderInterface { orConditionExpression>( ...args: NotExprArg - ): PutItemQueryBuilderInterface; + ): PutItemQueryBuilder; orConditionExpression>( ...args: BuilderExprArg - ): PutItemQueryBuilderInterface; + ): PutItemQueryBuilder; returnValues( option: Extract - ): PutItemQueryBuilderInterface; + ): PutItemQueryBuilder; item>( item: Item - ): PutItemQueryBuilderInterface; + ): PutItemQueryBuilder; compile(): PutCommand; execute(): Promise[] | undefined>; @@ -101,7 +105,7 @@ export class PutItemQueryBuilder< conditionExpression>( ...args: ExprArgs - ): PutItemQueryBuilderInterface { + ): PutItemQueryBuilder { const eB = new ExpressionBuilder({ node: { ...this.#props.node.conditionExpression }, }); @@ -119,7 +123,7 @@ export class PutItemQueryBuilder< orConditionExpression>( ...args: ExprArgs - ): PutItemQueryBuilderInterface { + ): PutItemQueryBuilder { const eB = new ExpressionBuilder({ node: { ...this.#props.node.conditionExpression }, }); @@ -137,7 +141,7 @@ export class PutItemQueryBuilder< item>( item: Item - ): PutItemQueryBuilderInterface { + ): PutItemQueryBuilder { return new PutItemQueryBuilder({ ...this.#props, node: { @@ -152,7 +156,7 @@ export class PutItemQueryBuilder< returnValues( option: Extract - ): PutItemQueryBuilderInterface { + ): PutItemQueryBuilder { return new PutItemQueryBuilder({ ...this.#props, node: { @@ -168,11 +172,16 @@ export class PutItemQueryBuilder< compile = (): PutCommand => { return this.#props.queryCompiler.compile(this.#props.node); }; + execute = async (): Promise[] | undefined> => { const putCommand = this.compile(); const data = await this.#props.ddbClient.send(putCommand); return data.Attributes as any; }; + + public get node() { + return this.#props.node; + } } preventAwait( diff --git a/src/queryBuilders/readTransactionBuilder.integration.test.ts b/src/queryBuilders/readTransactionBuilder.integration.test.ts new file mode 100644 index 0000000..7dc3081 --- /dev/null +++ b/src/queryBuilders/readTransactionBuilder.integration.test.ts @@ -0,0 +1,49 @@ +import { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb"; +import { DDB } from "../../test/testFixture"; +import { getDDBClientFor, startDDBTestContainer } from "../../test/testUtil"; +import { Tsynamo } from "../index"; + +describe("ReadTransactionBuilder", () => { + let tsynamoClient: Tsynamo; + let ddbClient: DynamoDBDocumentClient; + + beforeAll(async () => { + const testContainer = await startDDBTestContainer(); + ddbClient = await getDDBClientFor(testContainer); + + tsynamoClient = new Tsynamo({ + ddbClient, + }); + }); + + it("handles transaction with gets", async () => { + const trx = tsynamoClient.createReadTransaction(); + + trx.addItem({ + Get: tsynamoClient.getItem("myTable").keys({ + userId: "123", + dataTimestamp: 222, + }), + }); + + trx.addItem({ + Get: tsynamoClient + .getItem("myOtherTable") + .keys({ + userId: "123", + stringTimestamp: "111", + }) + .attributes(["userId"]), + }); + + trx.addItem({ + Get: tsynamoClient.getItem("myTable").keys({ + userId: "1111111", + dataTimestamp: 2222, + }), + }); + + const result = await trx.execute(); + expect(result).toMatchSnapshot(); + }); +}); diff --git a/src/queryBuilders/readTransactionBuilder.ts b/src/queryBuilders/readTransactionBuilder.ts new file mode 100644 index 0000000..0362ed0 --- /dev/null +++ b/src/queryBuilders/readTransactionBuilder.ts @@ -0,0 +1,59 @@ +import { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb"; +import { ReadTransactionNode } from "../nodes/readTransactionNode"; +import { QueryCompiler } from "../queryCompiler"; +import { AllTuples } from "../typeHelpers"; +import { GetQueryBuilder } from "./getItemQueryBuilder"; + +export interface ReadTransactionBuilderInterface { + addItem(item: { + Get: GetQueryBuilder; + }): void; + + /** + * The return value is an array of tuples, where the first item + * tells the name of the table, and the right item is the result + * item itself (or undefined if not found). The table can be used + * for discriminated union to determine the actual type of the result + * item. + */ + execute(): Promise[]>; +} + +export class ReadTransactionBuilder + implements ReadTransactionBuilderInterface +{ + readonly #props: ReadTransactionBuilderProps; + resultTables: string[] = []; + + constructor(props: ReadTransactionBuilderProps) { + this.#props = props; + } + + addItem(item: { Get: GetQueryBuilder }) { + this.resultTables.push(item.Get.node.table.table); + + this.#props.node.transactGetItems.push({ + kind: "TransactGetItemNode", + Get: item.Get.node, + }); + } + + async execute(): Promise[]> { + const transactionCommand = this.#props.queryCompiler.compile( + this.#props.node + ); + + return ( + await this.#props.ddbClient.send(transactionCommand) + ).Responses?.map((o, i) => [ + this.resultTables[i], + o.Item, + ]) as AllTuples[]; + } +} + +interface ReadTransactionBuilderProps { + readonly node: ReadTransactionNode; + readonly ddbClient: DynamoDBDocumentClient; + readonly queryCompiler: QueryCompiler; +} diff --git a/src/queryBuilders/updateItemQueryBuilder.ts b/src/queryBuilders/updateItemQueryBuilder.ts index 6579aa7..bd64e3a 100644 --- a/src/queryBuilders/updateItemQueryBuilder.ts +++ b/src/queryBuilders/updateItemQueryBuilder.ts @@ -31,71 +31,71 @@ import { SetUpdateExpressionFunctionQueryBuilder } from "./setUpdateExpressionFu export interface UpdateItemQueryBuilderInterface< DDB, Table extends keyof DDB, - O + O extends DDB[Table] > { // conditionExpression conditionExpression>( ...args: ComparatorExprArg - ): UpdateItemQueryBuilderInterface; + ): UpdateItemQueryBuilder; conditionExpression>( ...args: AttributeFuncExprArg - ): UpdateItemQueryBuilderInterface; + ): UpdateItemQueryBuilder; conditionExpression>( ...args: AttributeBeginsWithExprArg - ): UpdateItemQueryBuilderInterface; + ): UpdateItemQueryBuilder; conditionExpression>( ...args: AttributeContainsExprArg - ): UpdateItemQueryBuilderInterface; + ): UpdateItemQueryBuilder; conditionExpression>( ...args: AttributeBetweenExprArg - ): UpdateItemQueryBuilderInterface; + ): UpdateItemQueryBuilder; conditionExpression>( ...args: NotExprArg - ): UpdateItemQueryBuilderInterface; + ): UpdateItemQueryBuilder; conditionExpression>( ...args: BuilderExprArg - ): UpdateItemQueryBuilderInterface; + ): UpdateItemQueryBuilder; // orConditionExpression orConditionExpression>( ...args: ComparatorExprArg - ): UpdateItemQueryBuilderInterface; + ): UpdateItemQueryBuilder; orConditionExpression>( ...args: AttributeFuncExprArg - ): UpdateItemQueryBuilderInterface; + ): UpdateItemQueryBuilder; orConditionExpression>( ...args: AttributeBeginsWithExprArg - ): UpdateItemQueryBuilderInterface; + ): UpdateItemQueryBuilder; orConditionExpression>( ...args: AttributeContainsExprArg - ): UpdateItemQueryBuilderInterface; + ): UpdateItemQueryBuilder; orConditionExpression>( ...args: AttributeBetweenExprArg - ): UpdateItemQueryBuilderInterface; + ): UpdateItemQueryBuilder; orConditionExpression>( ...args: NotExprArg - ): UpdateItemQueryBuilderInterface; + ): UpdateItemQueryBuilder; orConditionExpression>( ...args: BuilderExprArg - ): UpdateItemQueryBuilderInterface; + ): UpdateItemQueryBuilder; set>>( key: Key, operand: UpdateExpressionOperands, value: StripKeys> - ): UpdateItemQueryBuilderInterface; + ): UpdateItemQueryBuilder; set>>( key: Key, @@ -103,7 +103,7 @@ export interface UpdateItemQueryBuilderInterface< value: ( builder: SetUpdateExpressionFunctionQueryBuilder ) => SetUpdateExpressionFunction - ): UpdateItemQueryBuilderInterface; + ): UpdateItemQueryBuilder; set>>( key: Key, @@ -111,16 +111,16 @@ export interface UpdateItemQueryBuilderInterface< value: ( builder: SetUpdateExpressionFunctionQueryBuilder ) => [SetUpdateExpressionFunction, number] - ): UpdateItemQueryBuilderInterface; + ): UpdateItemQueryBuilder; keys & PickSkRequired>( pk: Keys - ): UpdateItemQueryBuilderInterface; + ): UpdateItemQueryBuilder; // TODO: Make it possible to delete a whole object, and not just nested keys remove>>( attribute: Key - ): UpdateItemQueryBuilderInterface; + ): UpdateItemQueryBuilder; add< Key extends ObjectKeyPaths< @@ -129,7 +129,7 @@ export interface UpdateItemQueryBuilderInterface< >( attribute: Key, value: StripKeys> - ): UpdateItemQueryBuilderInterface; + ): UpdateItemQueryBuilder; delete< Key extends ObjectKeyPaths< @@ -138,11 +138,11 @@ export interface UpdateItemQueryBuilderInterface< >( attribute: Key, value: StripKeys> - ): UpdateItemQueryBuilderInterface; + ): UpdateItemQueryBuilder; returnValues( option: ReturnValuesOptions - ): UpdateItemQueryBuilderInterface; + ): UpdateItemQueryBuilder; compile(): UpdateCommand; execute(): Promise[] | undefined>; @@ -162,7 +162,7 @@ export class UpdateItemQueryBuilder< conditionExpression>( ...args: ExprArgs - ): UpdateItemQueryBuilderInterface { + ): UpdateItemQueryBuilder { const eB = new ExpressionBuilder({ node: { ...this.#props.node.conditionExpression }, }); @@ -180,7 +180,7 @@ export class UpdateItemQueryBuilder< orConditionExpression>( ...args: ExprArgs - ): UpdateItemQueryBuilderInterface { + ): UpdateItemQueryBuilder { const eB = new ExpressionBuilder({ node: { ...this.#props.node.conditionExpression }, }); @@ -225,7 +225,7 @@ export class UpdateItemQueryBuilder< > ) => [SetUpdateExpressionFunction, number] ] - ): UpdateItemQueryBuilderInterface { + ): UpdateItemQueryBuilder { const [key, operand, right] = args; if (typeof right === "function") { @@ -312,7 +312,7 @@ export class UpdateItemQueryBuilder< remove>>( attribute: Key - ): UpdateItemQueryBuilderInterface { + ): UpdateItemQueryBuilder { return new UpdateItemQueryBuilder({ ...this.#props, node: { @@ -332,7 +332,7 @@ export class UpdateItemQueryBuilder< add>>( attribute: Key, value: StripKeys> - ): UpdateItemQueryBuilderInterface { + ): UpdateItemQueryBuilder { return new UpdateItemQueryBuilder({ ...this.#props, node: { @@ -355,7 +355,7 @@ export class UpdateItemQueryBuilder< >( attribute: Key, value: StripKeys> - ): UpdateItemQueryBuilderInterface { + ): UpdateItemQueryBuilder { return new UpdateItemQueryBuilder({ ...this.#props, node: { @@ -375,7 +375,7 @@ export class UpdateItemQueryBuilder< returnValues( option: ReturnValuesOptions - ): UpdateItemQueryBuilderInterface { + ): UpdateItemQueryBuilder { return new UpdateItemQueryBuilder({ ...this.#props, node: { @@ -412,6 +412,10 @@ export class UpdateItemQueryBuilder< const data = await this.#props.ddbClient.send(putCommand); return data.Attributes as any; }; + + public get node() { + return this.#props.node; + } } preventAwait( diff --git a/src/queryBuilders/writeTransactionBuilder.integration.test.ts b/src/queryBuilders/writeTransactionBuilder.integration.test.ts new file mode 100644 index 0000000..c21c7f9 --- /dev/null +++ b/src/queryBuilders/writeTransactionBuilder.integration.test.ts @@ -0,0 +1,196 @@ +import { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb"; +import { DDB } from "../../test/testFixture"; +import { getDDBClientFor, startDDBTestContainer } from "../../test/testUtil"; +import { Tsynamo } from "../index"; + +describe("WriteTransactionBuilder", () => { + let tsynamoClient: Tsynamo; + let ddbClient: DynamoDBDocumentClient; + + beforeAll(async () => { + const testContainer = await startDDBTestContainer(); + ddbClient = await getDDBClientFor(testContainer); + + tsynamoClient = new Tsynamo({ + ddbClient, + }); + }); + + it("handles a transaction with puts", async () => { + const trx = tsynamoClient.createWriteTransaction(); + + trx.addItem({ + Put: tsynamoClient + .putItem("myTable") + .item({ userId: "9999", dataTimestamp: 1 }), + }); + + trx.addItem({ + Put: tsynamoClient + .putItem("myTable") + .item({ userId: "9999", dataTimestamp: 2 }), + }); + + await trx.execute(); + + const result = await tsynamoClient + .query("myTable") + .keyCondition("userId", "=", "9999") + .execute(); + + expect(result).toMatchSnapshot(); + }); + + it("handles a transaction with deletes", async () => { + await tsynamoClient + .putItem("myTable") + .item({ userId: "9999", dataTimestamp: 1 }) + .execute(); + + await tsynamoClient + .putItem("myOtherTable") + .item({ userId: "9999", stringTimestamp: "123" }) + .execute(); + + let foundItem: unknown = await tsynamoClient + .getItem("myTable") + .keys({ + userId: "9999", + dataTimestamp: 1, + }) + .execute(); + + expect(foundItem).toBeDefined(); + + foundItem = await tsynamoClient + .getItem("myOtherTable") + .keys({ + userId: "9999", + stringTimestamp: "123", + }) + .execute(); + + expect(foundItem).toBeDefined(); + + const trx = tsynamoClient.createWriteTransaction(); + + trx.addItem({ + Delete: tsynamoClient.deleteItem("myTable").keys({ + userId: "9999", + dataTimestamp: 1, + }), + }); + + trx.addItem({ + Delete: tsynamoClient.deleteItem("myOtherTable").keys({ + userId: "9999", + stringTimestamp: "123", + }), + }); + + await trx.execute(); + + foundItem = await tsynamoClient + .getItem("myTable") + .keys({ + userId: "9999", + dataTimestamp: 1, + }) + .execute(); + + expect(foundItem).toBeUndefined(); + + foundItem = await tsynamoClient + .getItem("myOtherTable") + .keys({ + userId: "9999", + stringTimestamp: "9999", + }) + .execute(); + + expect(foundItem).toBeUndefined(); + }); + + it("handles a transaction with updates", async () => { + await tsynamoClient + .putItem("myTable") + .item({ userId: "1", dataTimestamp: 1 }) + .execute(); + + await tsynamoClient + .putItem("myTable") + .item({ userId: "1", dataTimestamp: 2 }) + .execute(); + + const trx = tsynamoClient.createWriteTransaction(); + + trx.addItem({ + Update: tsynamoClient + .updateItem("myTable") + .keys({ userId: "9999", dataTimestamp: 1 }) + .set("someBoolean", "=", true), + }); + + trx.addItem({ + Update: tsynamoClient + .updateItem("myTable") + .keys({ userId: "9999", dataTimestamp: 2 }) + .set("tags", "=", ["a", "b", "c"]), + }); + + await trx.execute(); + + const result = await tsynamoClient + .query("myTable") + .keyCondition("userId", "=", "9999") + .execute(); + + expect(result).toMatchSnapshot(); + }); + + it("handles a transaction with failing conditions", async () => { + // Create a conflicting entry + await tsynamoClient + .putItem("myTable") + .item({ userId: "1", dataTimestamp: 2 }) + .execute(); + + const trx = tsynamoClient.createWriteTransaction(); + + trx.addItem({ + Put: tsynamoClient + .putItem("myTable") + .item({ + userId: "1", + dataTimestamp: 2, + someBoolean: true, + }) + .conditionExpression("userId", "attribute_not_exists"), + }); + + expect(trx.execute()).rejects.toMatchSnapshot(); + }); + + it("handles a transaction with a client request token", async () => { + const trx = tsynamoClient.createWriteTransaction("hello token"); + + trx.addItem({ + Put: tsynamoClient.putItem("myTable").item({ + userId: "1", + dataTimestamp: 2, + }), + }); + + await trx.execute(); + + trx.addItem({ + Put: tsynamoClient.putItem("myTable").item({ + userId: "1", + dataTimestamp: 2, + someBoolean: true, + }), + }); + + expect(trx.execute()).rejects.toMatchSnapshot(); + }); +}); diff --git a/src/queryBuilders/writeTransactionBuilder.ts b/src/queryBuilders/writeTransactionBuilder.ts new file mode 100644 index 0000000..573b199 --- /dev/null +++ b/src/queryBuilders/writeTransactionBuilder.ts @@ -0,0 +1,56 @@ +import { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb"; +import { WriteTransactionNode } from "../nodes/writeTransactionNode"; +import { QueryCompiler } from "../queryCompiler"; +import { DeleteItemQueryBuilder } from "./deleteItemQueryBuilder"; +import { PutItemQueryBuilder } from "./putItemQueryBuilder"; +import { UpdateItemQueryBuilder } from "./updateItemQueryBuilder"; + +export interface WriteTransactionBuilderInterface { + /** + * @todo add support for ConditionCheck items + */ + addItem(item: { + Put?: PutItemQueryBuilder; + Delete?: DeleteItemQueryBuilder; + Update?: UpdateItemQueryBuilder; + }): void; + + execute(): Promise; +} + +export class WriteTransactionBuilder + implements WriteTransactionBuilderInterface +{ + readonly #props: WriteTransactionBuilderProps; + + constructor(props: WriteTransactionBuilderProps) { + this.#props = props; + } + + addItem(item: { + Put?: PutItemQueryBuilder; + Delete?: DeleteItemQueryBuilder; + Update?: UpdateItemQueryBuilder; + }) { + this.#props.node.transactWriteItems.push({ + kind: "TransactWriteItemNode", + Put: item.Put?.node, + Delete: item.Delete?.node, + Update: item.Update?.node, + }); + } + + async execute() { + const transactionCommand = this.#props.queryCompiler.compile( + this.#props.node + ); + + await this.#props.ddbClient.send(transactionCommand); + } +} + +interface WriteTransactionBuilderProps { + readonly node: WriteTransactionNode; + readonly ddbClient: DynamoDBDocumentClient; + readonly queryCompiler: QueryCompiler; +} diff --git a/src/queryCompiler/queryCompiler.ts b/src/queryCompiler/queryCompiler.ts index 1f14087..9500536 100644 --- a/src/queryCompiler/queryCompiler.ts +++ b/src/queryCompiler/queryCompiler.ts @@ -1,30 +1,42 @@ +import { + TransactGetItem, + TransactWriteItem, + Update, +} from "@aws-sdk/client-dynamodb"; import { DeleteCommand, + DeleteCommandInput, GetCommand, + GetCommandInput, PutCommand, + PutCommandInput, QueryCommand, + TransactGetCommand, + TransactWriteCommand, UpdateCommand, } from "@aws-sdk/lib-dynamodb"; +import { AddUpdateExpression } from "../nodes/addUpdateExpression"; import { AttributesNode } from "../nodes/attributesNode"; import { DeleteNode } from "../nodes/deleteNode"; +import { DeleteUpdateExpression } from "../nodes/deleteUpdateExpression"; import { ExpressionJoinTypeNode } from "../nodes/expressionJoinTypeNode"; import { ExpressionNode } from "../nodes/expressionNode"; import { GetNode } from "../nodes/getNode"; import { KeyConditionNode } from "../nodes/keyConditionNode"; import { PutNode } from "../nodes/putNode"; import { QueryNode } from "../nodes/queryNode"; +import { ReadTransactionNode } from "../nodes/readTransactionNode"; +import { RemoveUpdateExpression } from "../nodes/removeUpdateExpression"; import { SetUpdateExpression } from "../nodes/setUpdateExpression"; import { SetUpdateExpressionFunction } from "../nodes/setUpdateExpressionFunction"; import { UpdateExpression } from "../nodes/updateExpression"; import { UpdateNode } from "../nodes/updateNode"; +import { WriteTransactionNode } from "../nodes/writeTransactionNode"; import { getAttributeNameFrom, getExpressionAttributeNameFrom, mergeObjectIntoMap, } from "./compilerUtil"; -import { RemoveUpdateExpression } from "../nodes/removeUpdateExpression"; -import { AddUpdateExpression } from "../nodes/addUpdateExpression"; -import { DeleteUpdateExpression } from "../nodes/deleteUpdateExpression"; export class QueryCompiler { compile(rootNode: QueryNode): QueryCommand; @@ -32,7 +44,18 @@ export class QueryCompiler { compile(rootNode: PutNode): PutCommand; compile(rootNode: DeleteNode): DeleteCommand; compile(rootNode: UpdateNode): UpdateCommand; - compile(rootNode: QueryNode | GetNode | PutNode | DeleteNode | UpdateNode) { + compile(rootNode: WriteTransactionNode): TransactWriteCommand; + compile(rootNode: ReadTransactionNode): TransactGetCommand; + compile( + rootNode: + | QueryNode + | GetNode + | PutNode + | DeleteNode + | UpdateNode + | WriteTransactionNode + | ReadTransactionNode + ) { switch (rootNode.kind) { case "GetNode": return this.compileGetNode(rootNode); @@ -44,10 +67,18 @@ export class QueryCompiler { return this.compileDeleteNode(rootNode); case "UpdateNode": return this.compileUpdateNode(rootNode); + case "WriteTransactionNode": + return this.compileWriteTransactionNode(rootNode); + case "ReadTransactionNode": + return this.compileReadTransactionNode(rootNode); } } compileGetNode(getNode: GetNode): GetCommand { + return new GetCommand(this.compileGetCmdInput(getNode)); + } + + compileGetCmdInput(getNode: GetNode): GetCommandInput { const { table: tableNode, keys: keysNode, @@ -58,13 +89,13 @@ export class QueryCompiler { const { ProjectionExpression, ExpressionAttributeNames } = this.compileAttributeNamesNode(attributesNode); - return new GetCommand({ + return { TableName: tableNode.table, Key: keysNode?.keys, ConsistentRead: consistentReadNode?.enabled, ProjectionExpression, ExpressionAttributeNames, - }); + }; } compileQueryNode(queryNode: QueryNode): QueryCommand { @@ -122,6 +153,10 @@ export class QueryCompiler { } compilePutNode(putNode: PutNode) { + return new PutCommand(this.compilePutCmdInput(putNode)); + } + + compilePutCmdInput(putNode: PutNode): PutCommandInput { const { table: tableNode, item: itemNode, @@ -138,7 +173,7 @@ export class QueryCompiler { attributeNames ); - return new PutCommand({ + return { TableName: tableNode.table, Item: itemNode?.item, ReturnValues: returnValuesNode?.option, @@ -157,10 +192,14 @@ export class QueryCompiler { ...Object.fromEntries(attributeNames), } : undefined, - }); + }; } compileDeleteNode(deleteNode: DeleteNode) { + return new DeleteCommand(this.compileDeleteCmdInput(deleteNode)); + } + + compileDeleteCmdInput(deleteNode: DeleteNode): DeleteCommandInput { const { table: tableNode, returnValues: returnValuesNode, @@ -179,7 +218,7 @@ export class QueryCompiler { attributeNames ); - return new DeleteCommand({ + return { TableName: tableNode.table, Key: keysNode?.keys, ReturnValues: returnValuesNode?.option, @@ -200,10 +239,14 @@ export class QueryCompiler { ...Object.fromEntries(attributeNames), } : undefined, - }); + }; } compileUpdateNode(updateNode: UpdateNode) { + return new UpdateCommand(this.compileUpdateCmdInput(updateNode)); + } + + compileUpdateCmdInput(updateNode: UpdateNode) { const { table: tableNode, conditionExpression: conditionExpressionNode, @@ -227,7 +270,7 @@ export class QueryCompiler { attributeNames ); - return new UpdateCommand({ + return { TableName: tableNode.table, Key: keysNode?.keys, ReturnValues: returnValuesNode?.option, @@ -249,6 +292,49 @@ export class QueryCompiler { ...Object.fromEntries(attributeNames), } : undefined, + }; + } + + compileWriteTransactionNode(transactionNode: WriteTransactionNode) { + const TransactItems = transactionNode.transactWriteItems.map((item) => { + const compiledTransactItem: TransactWriteItem = {}; + + if (item.Put) { + compiledTransactItem.Put = this.compilePutCmdInput(item.Put); + } + + if (item.Delete) { + compiledTransactItem.Delete = this.compileDeleteCmdInput(item.Delete); + } + + if (item.Update) { + compiledTransactItem.Update = this.compileUpdateCmdInput( + item.Update + ) as Update; + } + + return compiledTransactItem; + }); + + return new TransactWriteCommand({ + TransactItems: TransactItems, + ClientRequestToken: transactionNode.clientRequestToken, + }); + } + + compileReadTransactionNode(transactionNode: ReadTransactionNode) { + const TransactItems = transactionNode.transactGetItems.map((item) => { + const compiledGet = this.compileGetCmdInput(item.Get); + + const compiledTransactItem: TransactGetItem = { + Get: compiledGet, + }; + + return compiledTransactItem; + }); + + return new TransactGetCommand({ + TransactItems: TransactItems, }); } diff --git a/src/queryCreator.ts b/src/queryCreator.ts index ced2865..dfc8982 100644 --- a/src/queryCreator.ts +++ b/src/queryCreator.ts @@ -1,16 +1,12 @@ import { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb"; import { DeleteItemQueryBuilder } from "./queryBuilders/deleteItemQueryBuilder"; import { GetQueryBuilder } from "./queryBuilders/getItemQueryBuilder"; -import { - PutItemQueryBuilder, - PutItemQueryBuilderInterface, -} from "./queryBuilders/putItemQueryBuilder"; -import { - QueryQueryBuilder, - QueryQueryBuilderInterface, -} from "./queryBuilders/queryQueryBuilder"; -import { QueryCompiler } from "./queryCompiler"; +import { PutItemQueryBuilder } from "./queryBuilders/putItemQueryBuilder"; +import { QueryQueryBuilder } from "./queryBuilders/queryQueryBuilder"; +import { ReadTransactionBuilder } from "./queryBuilders/readTransactionBuilder"; import { UpdateItemQueryBuilder } from "./queryBuilders/updateItemQueryBuilder"; +import { WriteTransactionBuilder } from "./queryBuilders/writeTransactionBuilder"; +import { QueryCompiler } from "./queryCompiler"; export class QueryCreator { readonly #props: QueryCreatorProps; @@ -50,7 +46,7 @@ export class QueryCreator { */ query
( table: Table - ): QueryQueryBuilderInterface { + ): QueryQueryBuilder { return new QueryQueryBuilder({ node: { kind: "QueryNode", @@ -77,7 +73,7 @@ export class QueryCreator { */ putItem
( table: Table - ): PutItemQueryBuilderInterface { + ): PutItemQueryBuilder { return new PutItemQueryBuilder({ node: { kind: "PutNode", @@ -146,13 +142,54 @@ export class QueryCreator { setUpdateExpressions: [], removeUpdateExpressions: [], addUpdateExpressions: [], - deleteUpdateExpressions: [] + deleteUpdateExpressions: [], }, }, ddbClient: this.#props.ddbClient, queryCompiler: this.#props.queryCompiler, }); } + + /** + * Returns a builder that can be used to group many different write + * operations together and execute them in a transaction. + * + * @param clientRequestToken + * From AWS documentation: Providing a ClientRequestToken makes the call to TransactWriteItems idempotent, meaning that multiple identical calls have the same effect as one single call. + * Although multiple identical calls using the same client request token produce the same result on the server (no side effects), the responses to the calls might not be the same. If the ReturnConsumedCapacity parameter is set, then the initial TransactWriteItems call returns the amount of write capacity units consumed in making the changes. Subsequent TransactWriteItems calls with the same client token return the number of read capacity units consumed in reading the item. + * A client request token is valid for 10 minutes after the first request that uses it is completed. After 10 minutes, any request with the same client token is treated as a new request. Do not resubmit the same request with the same client token for more than 10 minutes, or the result might not be idempotent. + * If you submit a request with the same client token but a change in other parameters within the 10-minute idempotency window, DynamoDB returns an IdempotentParameterMismatch exception. + * + * @see https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/client/dynamodb/command/TransactWriteItemsCommand/ + */ + createWriteTransaction(clientRequestToken?: string) { + return new WriteTransactionBuilder({ + node: { + kind: "WriteTransactionNode", + transactWriteItems: [], + clientRequestToken, + }, + ddbClient: this.#props.ddbClient, + queryCompiler: this.#props.queryCompiler, + }); + } + + /** + * Returns a builder that can be used to group many different get + * operations together and execute them in a transaction. + * + * @see https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/client/dynamodb/command/TransactGetItemsCommand/ + */ + createReadTransaction() { + return new ReadTransactionBuilder({ + node: { + kind: "ReadTransactionNode", + transactGetItems: [], + }, + ddbClient: this.#props.ddbClient, + queryCompiler: this.#props.queryCompiler, + }); + } } export interface QueryCreatorProps { diff --git a/src/typeHelpers.ts b/src/typeHelpers.ts index 1edbb2e..b87775c 100644 --- a/src/typeHelpers.ts +++ b/src/typeHelpers.ts @@ -247,3 +247,20 @@ export type ObjectFullPaths = never : // Leaf value reached, don't return anything never; + +/** + * Creates a tuple where the first item is the name of the table + * and the second item is the schema of the table. + */ +type DependentTuple = [ + Table, + ExecuteOutput | undefined +]; + +/** + * Generates a type that can be used for discriminated union + * when returning data from multiple different tables. + */ +export type AllTuples = { + [Table in keyof DDB]: DependentTuple; +}[keyof DDB]; diff --git a/test/testUtil.ts b/test/testUtil.ts index 4242dea..c56d5af 100644 --- a/test/testUtil.ts +++ b/test/testUtil.ts @@ -5,7 +5,7 @@ import { GenericContainer, StartedTestContainer } from "testcontainers"; const DDB_PORT = 8000 as const; export const startDDBTestContainer = async () => { - return new GenericContainer("amazon/dynamodb-local") + return new GenericContainer("amazon/dynamodb-local:2.5.0") .withReuse() .withExposedPorts(DDB_PORT) .start();