diff --git a/README.md b/README.md index 34faeec..d7e0c73 100644 --- a/README.md +++ b/README.md @@ -266,7 +266,18 @@ await tsynamoClient ### Update item -WIP +```ts +await tsynamoClient + .updateItem("myTable") + .keys({ userId: "1", dataTimestamp: 2 }) + .set("nested.nestedBoolean", "=", true) + .remove("nested.nestedString") + .add("somethingElse", 10) + .add("someSet", new Set(["4", "5"])) + .delete("nested.nestedSet", new Set(["4", "5"])) + .conditionExpression("somethingElse", ">", 0) + .execute(); +``` ## Contributors diff --git a/src/nodes/addUpdateExpression.ts b/src/nodes/addUpdateExpression.ts new file mode 100644 index 0000000..7f90f96 --- /dev/null +++ b/src/nodes/addUpdateExpression.ts @@ -0,0 +1,5 @@ +export type AddUpdateExpression = { + readonly kind: "AddUpdateExpression"; + readonly key: string; + readonly value: unknown; +}; diff --git a/src/nodes/deleteUpdateExpression.ts b/src/nodes/deleteUpdateExpression.ts new file mode 100644 index 0000000..24ce881 --- /dev/null +++ b/src/nodes/deleteUpdateExpression.ts @@ -0,0 +1,5 @@ +export type DeleteUpdateExpression = { + readonly kind: "DeleteUpdateExpression"; + readonly key: string; + readonly value: unknown; +}; diff --git a/src/nodes/operands.ts b/src/nodes/operands.ts index 922a287..18f5950 100644 --- a/src/nodes/operands.ts +++ b/src/nodes/operands.ts @@ -11,3 +11,5 @@ export type NotExpression = "NOT"; export type KeyConditionComparators = "=" | "<" | "<=" | ">" | ">="; export type ExpressionConditionComparators = KeyConditionComparators | "<>"; + +export type UpdateExpressionOperands = "=" | "+=" | "-="; diff --git a/src/nodes/removeUpdateExpression.ts b/src/nodes/removeUpdateExpression.ts new file mode 100644 index 0000000..1fb9f13 --- /dev/null +++ b/src/nodes/removeUpdateExpression.ts @@ -0,0 +1,4 @@ +export type RemoveUpdateExpression = { + readonly kind: "RemoveUpdateExpression"; + readonly attribute: string; +}; diff --git a/src/nodes/setUpdateExpression.ts b/src/nodes/setUpdateExpression.ts new file mode 100644 index 0000000..67a259c --- /dev/null +++ b/src/nodes/setUpdateExpression.ts @@ -0,0 +1,11 @@ +import { UpdateExpressionOperands } from "./operands"; +import { SetUpdateExpressionFunction } from "./setUpdateExpressionFunction"; +import { SetUpdateExpressionValue } from "./setUpdateExpressionValue"; + +export type SetUpdateExpression = { + readonly kind: "SetUpdateExpression"; + readonly key: string; + readonly operation: UpdateExpressionOperands; + readonly right: SetUpdateExpressionFunction | SetUpdateExpressionValue; + readonly value?: number; +}; diff --git a/src/nodes/setUpdateExpressionFunction.ts b/src/nodes/setUpdateExpressionFunction.ts new file mode 100644 index 0000000..def1955 --- /dev/null +++ b/src/nodes/setUpdateExpressionFunction.ts @@ -0,0 +1,20 @@ +import { SetUpdateExpressionValue } from "./setUpdateExpressionValue"; + +export type SetUpdateExpressionFunction = { + readonly kind: "SetUpdateExpressionFunction"; + readonly function: + | SetUpdateExpressionIfNotExistsFunction + | SetUpdateExpressionListAppendFunction; +}; + +export type SetUpdateExpressionIfNotExistsFunction = { + readonly kind: "SetUpdateExpressionIfNotExistsFunction"; + readonly path: string; + readonly right: SetUpdateExpressionFunction | SetUpdateExpressionValue; +}; + +export type SetUpdateExpressionListAppendFunction = { + readonly kind: "SetUpdateExpressionListAppendFunction"; + readonly left: SetUpdateExpressionFunction | string; + readonly right: SetUpdateExpressionFunction | SetUpdateExpressionValue; +}; diff --git a/src/nodes/setUpdateExpressionValue.ts b/src/nodes/setUpdateExpressionValue.ts new file mode 100644 index 0000000..355bcb3 --- /dev/null +++ b/src/nodes/setUpdateExpressionValue.ts @@ -0,0 +1,4 @@ +export type SetUpdateExpressionValue = { + readonly kind: "SetUpdateExpressionValue"; + value: O; +}; diff --git a/src/nodes/updateExpression.ts b/src/nodes/updateExpression.ts new file mode 100644 index 0000000..b5ba2b6 --- /dev/null +++ b/src/nodes/updateExpression.ts @@ -0,0 +1,12 @@ +import { AddUpdateExpression } from "./addUpdateExpression"; +import { DeleteUpdateExpression } from "./deleteUpdateExpression"; +import { RemoveUpdateExpression } from "./removeUpdateExpression"; +import { SetUpdateExpression } from "./setUpdateExpression"; + +export type UpdateExpression = { + readonly kind: "UpdateExpression"; + readonly setUpdateExpressions: SetUpdateExpression[]; + readonly removeUpdateExpressions: RemoveUpdateExpression[]; + readonly addUpdateExpressions: AddUpdateExpression[]; + readonly deleteUpdateExpressions: DeleteUpdateExpression[]; +}; diff --git a/src/nodes/updateNode.ts b/src/nodes/updateNode.ts new file mode 100644 index 0000000..d40bf70 --- /dev/null +++ b/src/nodes/updateNode.ts @@ -0,0 +1,15 @@ +import { ExpressionNode } from "./expressionNode"; +import { ItemNode } from "./itemNode"; +import { KeysNode } from "./keysNode"; +import { ReturnValuesNode } from "./returnValuesNode"; +import { TableNode } from "./tableNode"; +import { UpdateExpression } from "./updateExpression"; + +export type UpdateNode = { + readonly kind: "UpdateNode"; + readonly table: TableNode; + readonly conditionExpression: ExpressionNode; + readonly updateExpression: UpdateExpression; + readonly keys?: KeysNode; + readonly returnValues?: ReturnValuesNode; +}; diff --git a/src/queryBuilders/__snapshots__/updateItemQueryBuilder.integration.test.ts.snap b/src/queryBuilders/__snapshots__/updateItemQueryBuilder.integration.test.ts.snap new file mode 100644 index 0000000..845a6b3 --- /dev/null +++ b/src/queryBuilders/__snapshots__/updateItemQueryBuilder.integration.test.ts.snap @@ -0,0 +1,80 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`UpdateItemQueryBuilder > handles update item query with ADD statements 1`] = ` +{ + "dataTimestamp": 200, + "someBoolean": true, + "someSet": Set { + "item1", + "item2", + }, + "somethingElse": 7, + "userId": "1010", +} +`; + +exports[`UpdateItemQueryBuilder > handles update item query with DELETE statements 1`] = ` +{ + "dataTimestamp": 2, + "nested": { + "nestedSet": Set { + "5", + }, + }, + "someSet": Set { + "1", + }, + "userId": "1", +} +`; + +exports[`UpdateItemQueryBuilder > handles update item query with REMOVE statements 1`] = ` +{ + "dataTimestamp": 200, + "someBoolean": true, + "userId": "1010", +} +`; + +exports[`UpdateItemQueryBuilder > handles update item query with SET statements 1`] = ` +{ + "dataTimestamp": 2, + "someBoolean": true, + "somethingElse": 3, + "tags": [ + "test_tag", + ], + "userId": "1", +} +`; + +exports[`UpdateItemQueryBuilder > handles update item query with condition expressions 2`] = ` +{ + "dataTimestamp": 2, + "someSet": Set { + "1", + "2", + "3", + }, + "somethingElse": 0, + "userId": "1", +} +`; + +exports[`UpdateItemQueryBuilder > handles update item query with multiple different operations 1`] = ` +{ + "dataTimestamp": 2, + "nested": { + "nestedBoolean": true, + }, + "someSet": Set { + "1", + "2", + "3", + "4", + "5", + }, + "somethingElse": 10, + "userId": "1", +} +`; diff --git a/src/queryBuilders/setUpdateExpressionFunctionQueryBuilder.ts b/src/queryBuilders/setUpdateExpressionFunctionQueryBuilder.ts new file mode 100644 index 0000000..6b2643f --- /dev/null +++ b/src/queryBuilders/setUpdateExpressionFunctionQueryBuilder.ts @@ -0,0 +1,233 @@ +import exp from "constants"; +import { SetUpdateExpressionFunction } from "../nodes/setUpdateExpressionFunction"; +import { GetFromPath, ObjectKeyPaths, StripKeys } from "../typeHelpers"; + +export interface SetUpdateExpressionFunctionQueryBuilderInterface< + DDB, + Table extends keyof DDB, + O +> { + ifNotExists>( + key: Key, + value: StripKeys> + ): SetUpdateExpressionFunction; + + ifNotExists>( + key: Key, + value: ( + builder: SetUpdateExpressionFunctionQueryBuilder + ) => SetUpdateExpressionFunction + ): SetUpdateExpressionFunction; + + listAppend>( + key: Key, + value: StripKeys> + ): SetUpdateExpressionFunction; + + listAppend>( + key: Key, + value: ( + builder: SetUpdateExpressionFunctionQueryBuilder + ) => SetUpdateExpressionFunction + ): SetUpdateExpressionFunction; + + listAppend>( + key: ( + builder: SetUpdateExpressionFunctionQueryBuilder + ) => SetUpdateExpressionFunction, + value: StripKeys> + ): SetUpdateExpressionFunction; + + listAppend( + key: ( + builder: SetUpdateExpressionFunctionQueryBuilder + ) => SetUpdateExpressionFunction, + value: ( + builder: SetUpdateExpressionFunctionQueryBuilder + ) => SetUpdateExpressionFunction + ): SetUpdateExpressionFunction; +} + +export class SetUpdateExpressionFunctionQueryBuilder< + DDB, + Table extends keyof DDB, + O extends DDB[Table] +> implements SetUpdateExpressionFunctionQueryBuilderInterface +{ + private node?: SetUpdateExpressionFunction; + + ifNotExists>( + ...args: + | [key: Key, value: StripKeys>] + | [ + key: Key, + value: ( + builder: SetUpdateExpressionFunctionQueryBuilder< + DDB, + Table, + DDB[Table] + > + ) => SetUpdateExpressionFunction + ] + ) { + const [key, right] = args; + + if (typeof right === "function") { + const setUpdateExpressionBuilder = + new SetUpdateExpressionFunctionQueryBuilder(); + + const builder = right as ( + builder: SetUpdateExpressionFunctionQueryBuilder + ) => SetUpdateExpressionFunction; + + const expression = builder(setUpdateExpressionBuilder); + + this.node = { + kind: "SetUpdateExpressionFunction", + function: { + kind: "SetUpdateExpressionIfNotExistsFunction", + path: key, + right: expression, + }, + }; + } else { + this.node = { + kind: "SetUpdateExpressionFunction", + function: { + kind: "SetUpdateExpressionIfNotExistsFunction", + path: key, + right: { + kind: "SetUpdateExpressionValue", + value: right, + }, + }, + }; + } + + return this.node; + } + + listAppend>( + ...args: + | [key: Key, value: StripKeys>] + | [ + key: Key, + value: ( + builder: SetUpdateExpressionFunctionQueryBuilder< + DDB, + Table, + DDB[Table] + > + ) => SetUpdateExpressionFunction + ] + | [ + key: ( + builder: SetUpdateExpressionFunctionQueryBuilder< + DDB, + Table, + DDB[Table] + > + ) => SetUpdateExpressionFunction, + value: StripKeys> + ] + | [ + key: ( + builder: SetUpdateExpressionFunctionQueryBuilder< + DDB, + Table, + DDB[Table] + > + ) => SetUpdateExpressionFunction, + value: ( + builder: SetUpdateExpressionFunctionQueryBuilder< + DDB, + Table, + DDB[Table] + > + ) => SetUpdateExpressionFunction + ] + ) { + const [left, right] = args; + + if (typeof left === "function" && typeof right === "function") { + const setUpdateExpressionBuilderA = + new SetUpdateExpressionFunctionQueryBuilder(); + + const setUpdateExpressionBuilderB = + new SetUpdateExpressionFunctionQueryBuilder(); + + const builderLeft = left as ( + builder: SetUpdateExpressionFunctionQueryBuilder + ) => SetUpdateExpressionFunction; + + const builderRight = right as ( + builder: SetUpdateExpressionFunctionQueryBuilder + ) => SetUpdateExpressionFunction; + + const exprLeft = builderLeft(setUpdateExpressionBuilderA); + const exprRight = builderRight(setUpdateExpressionBuilderB); + + this.node = { + kind: "SetUpdateExpressionFunction", + function: { + kind: "SetUpdateExpressionListAppendFunction", + left: exprLeft, + right: exprRight, + }, + }; + } else if (typeof left === "function") { + const setUpdateExpressionBuilder = + new SetUpdateExpressionFunctionQueryBuilder(); + + const builder = left as ( + builder: SetUpdateExpressionFunctionQueryBuilder + ) => SetUpdateExpressionFunction; + + const expr = builder(setUpdateExpressionBuilder); + + this.node = { + kind: "SetUpdateExpressionFunction", + function: { + kind: "SetUpdateExpressionListAppendFunction", + left: expr, + right: { + kind: "SetUpdateExpressionValue", + value: right, + }, + }, + }; + } else if (typeof right === "function") { + const setUpdateExpressionBuilder = + new SetUpdateExpressionFunctionQueryBuilder(); + + const builder = right as ( + builder: SetUpdateExpressionFunctionQueryBuilder + ) => SetUpdateExpressionFunction; + + const expr = builder(setUpdateExpressionBuilder); + + this.node = { + kind: "SetUpdateExpressionFunction", + function: { + kind: "SetUpdateExpressionListAppendFunction", + left, + right: expr, + }, + }; + } else { + this.node = { + kind: "SetUpdateExpressionFunction", + function: { + kind: "SetUpdateExpressionListAppendFunction", + left, + right: { + kind: "SetUpdateExpressionValue", + value: right, + }, + }, + }; + } + + return this.node; + } +} diff --git a/src/queryBuilders/updateItemQueryBuilder.integration.test.ts b/src/queryBuilders/updateItemQueryBuilder.integration.test.ts new file mode 100644 index 0000000..5c29a2f --- /dev/null +++ b/src/queryBuilders/updateItemQueryBuilder.integration.test.ts @@ -0,0 +1,189 @@ +import { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb"; +import { DDB } from "../../test/testFixture"; +import { getDDBClientFor, startDDBTestContainer } from "../../test/testUtil"; +import { Tsynamo } from "./../index"; + +describe("UpdateItemQueryBuilder", () => { + let tsynamoClient: Tsynamo; + let ddbClient: DynamoDBDocumentClient; + + beforeAll(async () => { + const testContainer = await startDDBTestContainer(); + ddbClient = await getDDBClientFor(testContainer); + + tsynamoClient = new Tsynamo({ + ddbClient, + }); + }); + + it("handles update item query with SET statements", async () => { + const res = await tsynamoClient + .updateItem("myTable") + .keys({ userId: "1", dataTimestamp: 2 }) + .set("someBoolean", "=", (qb) => { + return qb.ifNotExists("someBoolean", true); + }) + .set("tags", "=", (qb) => { + return qb.listAppend( + (qbb) => qbb.ifNotExists("tags", []), + ["test_tag"] + ); + }) + .set("somethingElse", "+=", (qb) => { + return [qb.ifNotExists("somethingElse", 1), 2]; + }) + .returnValues("ALL_NEW") + .execute(); + + expect(res).toMatchSnapshot(); + }); + + it("handles update item query with REMOVE statements", async () => { + await tsynamoClient + .putItem("myTable") + .item({ + userId: "1010", + dataTimestamp: 200, + somethingElse: 313, + someBoolean: true, + }) + .execute(); + + await tsynamoClient + .updateItem("myTable") + .keys({ userId: "1010", dataTimestamp: 200 }) + .remove("somethingElse") + .execute(); + + const foundItem = await tsynamoClient + .getItem("myTable") + .keys({ + userId: "1010", + dataTimestamp: 200, + }) + .execute(); + + expect(foundItem).toMatchSnapshot(); + }); + + it("handles update item query with ADD statements", async () => { + await tsynamoClient + .putItem("myTable") + .item({ + userId: "1010", + dataTimestamp: 200, + someBoolean: true, + }) + .execute(); + + await tsynamoClient + .updateItem("myTable") + .keys({ userId: "1010", dataTimestamp: 200 }) + .add("somethingElse", 7) + .add("someSet", new Set(["item1", "item2"])) + .execute(); + + const foundItem = await tsynamoClient + .getItem("myTable") + .keys({ + userId: "1010", + dataTimestamp: 200, + }) + .execute(); + + expect(foundItem).toMatchSnapshot(); + }); + + it("handles update item query with DELETE statements", async () => { + await tsynamoClient + .putItem("myTable") + .item({ + userId: "1", + dataTimestamp: 2, + someSet: new Set(["1", "2", "3"]), + nested: { + nestedSet: new Set(["4", "5"]), + }, + }) + .execute(); + + await tsynamoClient + .updateItem("myTable") + .keys({ userId: "1", dataTimestamp: 2 }) + .delete("someSet", new Set(["2", "3"])) + .delete("nested.nestedSet", new Set(["4"])) + .execute(); + + const foundItem = await tsynamoClient + .getItem("myTable") + .keys({ userId: "1", dataTimestamp: 2 }) + .execute(); + + expect(foundItem).toMatchSnapshot(); + }); + + it("handles update item query with multiple different operations", async () => { + await tsynamoClient + .putItem("myTable") + .item({ + userId: "1", + dataTimestamp: 2, + someSet: new Set(["1", "2", "3"]), + somethingElse: 0, + nested: { + nestedSet: new Set(["4", "5"]), + nestedBoolean: false, + nestedString: "hello i am nested", + }, + }) + .execute(); + + await tsynamoClient + .updateItem("myTable") + .keys({ userId: "1", dataTimestamp: 2 }) + .set("nested.nestedBoolean", "=", true) + .remove("nested.nestedString") + .add("somethingElse", 10) + .add("someSet", new Set(["4", "5"])) + .delete("nested.nestedSet", new Set(["4", "5"])) + .execute(); + + const foundItem = await tsynamoClient + .getItem("myTable") + .keys({ userId: "1", dataTimestamp: 2 }) + .execute(); + + expect(foundItem).toMatchSnapshot(); + }); + + it("handles update item query with condition expressions", async () => { + await tsynamoClient + .putItem("myTable") + .item({ + userId: "1", + dataTimestamp: 2, + someSet: new Set(["1", "2", "3"]), + somethingElse: 0, + }) + .execute(); + + expect( + tsynamoClient + .updateItem("myTable") + .keys({ userId: "1", dataTimestamp: 2 }) + .remove("someSet") + .remove("somethingElse") + .conditionExpression("somethingElse", ">", 0) + .execute() + ).rejects.toMatchInlineSnapshot( + `[ConditionalCheckFailedException: The conditional request failed]` + ); + + const foundItem = await tsynamoClient + .getItem("myTable") + .keys({ userId: "1", dataTimestamp: 2 }) + .execute(); + + expect(foundItem).toMatchSnapshot(); + }); +}); diff --git a/src/queryBuilders/updateItemQueryBuilder.ts b/src/queryBuilders/updateItemQueryBuilder.ts new file mode 100644 index 0000000..6579aa7 --- /dev/null +++ b/src/queryBuilders/updateItemQueryBuilder.ts @@ -0,0 +1,426 @@ +import { DynamoDBDocumentClient, UpdateCommand } from "@aws-sdk/lib-dynamodb"; +import { UpdateExpressionOperands } from "../nodes/operands"; +import { ReturnValuesOptions } from "../nodes/returnValuesNode"; +import { SetUpdateExpressionFunction } from "../nodes/setUpdateExpressionFunction"; +import { UpdateNode } from "../nodes/updateNode"; +import { QueryCompiler } from "../queryCompiler"; +import { + ExecuteOutput, + FilteredKeys, + GetFromPath, + ObjectKeyPaths, + PickNonKeys, + PickPk, + PickSkRequired, + StripKeys, +} from "../typeHelpers"; +import { preventAwait } from "../util/preventAwait"; +import { + AttributeBeginsWithExprArg, + AttributeBetweenExprArg, + AttributeContainsExprArg, + AttributeFuncExprArg, + BuilderExprArg, + ComparatorExprArg, + ExprArgs, + ExpressionBuilder, + NotExprArg, +} from "./expressionBuilder"; +import { SetUpdateExpressionFunctionQueryBuilder } from "./setUpdateExpressionFunctionQueryBuilder"; + +export interface UpdateItemQueryBuilderInterface< + DDB, + Table extends keyof DDB, + O +> { + // conditionExpression + conditionExpression>( + ...args: ComparatorExprArg + ): UpdateItemQueryBuilderInterface; + + conditionExpression>( + ...args: AttributeFuncExprArg + ): UpdateItemQueryBuilderInterface; + + conditionExpression>( + ...args: AttributeBeginsWithExprArg + ): UpdateItemQueryBuilderInterface; + + conditionExpression>( + ...args: AttributeContainsExprArg + ): UpdateItemQueryBuilderInterface; + + conditionExpression>( + ...args: AttributeBetweenExprArg + ): UpdateItemQueryBuilderInterface; + + conditionExpression>( + ...args: NotExprArg + ): UpdateItemQueryBuilderInterface; + + conditionExpression>( + ...args: BuilderExprArg + ): UpdateItemQueryBuilderInterface; + + // orConditionExpression + orConditionExpression>( + ...args: ComparatorExprArg + ): UpdateItemQueryBuilderInterface; + + orConditionExpression>( + ...args: AttributeFuncExprArg + ): UpdateItemQueryBuilderInterface; + + orConditionExpression>( + ...args: AttributeBeginsWithExprArg + ): UpdateItemQueryBuilderInterface; + + orConditionExpression>( + ...args: AttributeContainsExprArg + ): UpdateItemQueryBuilderInterface; + + orConditionExpression>( + ...args: AttributeBetweenExprArg + ): UpdateItemQueryBuilderInterface; + + orConditionExpression>( + ...args: NotExprArg + ): UpdateItemQueryBuilderInterface; + + orConditionExpression>( + ...args: BuilderExprArg + ): UpdateItemQueryBuilderInterface; + + set>>( + key: Key, + operand: UpdateExpressionOperands, + value: StripKeys> + ): UpdateItemQueryBuilderInterface; + + set>>( + key: Key, + operand: Extract, + value: ( + builder: SetUpdateExpressionFunctionQueryBuilder + ) => [SetUpdateExpressionFunction, number] + ): UpdateItemQueryBuilderInterface; + + keys & PickSkRequired>( + pk: Keys + ): UpdateItemQueryBuilderInterface; + + // TODO: Make it possible to delete a whole object, and not just nested keys + remove>>( + attribute: Key + ): UpdateItemQueryBuilderInterface; + + add< + Key extends ObjectKeyPaths< + FilteredKeys, Set | number> + > + >( + attribute: Key, + value: StripKeys> + ): UpdateItemQueryBuilderInterface; + + delete< + Key extends ObjectKeyPaths< + FilteredKeys, Set> + > + >( + attribute: Key, + value: StripKeys> + ): UpdateItemQueryBuilderInterface; + + returnValues( + option: ReturnValuesOptions + ): UpdateItemQueryBuilderInterface; + + compile(): UpdateCommand; + execute(): Promise[] | undefined>; +} + +export class UpdateItemQueryBuilder< + DDB, + Table extends keyof DDB, + O extends DDB[Table] +> implements UpdateItemQueryBuilderInterface +{ + readonly #props: UpdateItemQueryBuilderProps; + + constructor(props: UpdateItemQueryBuilderProps) { + this.#props = props; + } + + conditionExpression>( + ...args: ExprArgs + ): UpdateItemQueryBuilderInterface { + const eB = new ExpressionBuilder({ + node: { ...this.#props.node.conditionExpression }, + }); + + const expressionNode = eB.expression(...args)._getNode(); + + return new UpdateItemQueryBuilder({ + ...this.#props, + node: { + ...this.#props.node, + conditionExpression: expressionNode, + }, + }); + } + + orConditionExpression>( + ...args: ExprArgs + ): UpdateItemQueryBuilderInterface { + const eB = new ExpressionBuilder({ + node: { ...this.#props.node.conditionExpression }, + }); + + const expressionNode = eB.orExpression(...args)._getNode(); + + return new UpdateItemQueryBuilder({ + ...this.#props, + node: { + ...this.#props.node, + conditionExpression: expressionNode, + }, + }); + } + + set>>( + ...args: + | [ + key: Key, + operand: UpdateExpressionOperands, + value: StripKeys> + ] + | [ + key: Key, + operand: Extract, + value: ( + builder: SetUpdateExpressionFunctionQueryBuilder< + DDB, + Table, + DDB[Table] + > + ) => [SetUpdateExpressionFunction, number] + ] + ): UpdateItemQueryBuilderInterface { + const [key, operand, right] = args; + + if (typeof right === "function") { + const setUpdateExpressionBuilder = + new SetUpdateExpressionFunctionQueryBuilder(); + + if (operand === "=") { + // TODO: Get rid of casting? + const builder = right as ( + builder: SetUpdateExpressionFunctionQueryBuilder< + DDB, + Table, + DDB[Table] + > + ) => SetUpdateExpressionFunction; + + const expression = builder(setUpdateExpressionBuilder); + return new UpdateItemQueryBuilder({ + ...this.#props, + node: { + ...this.#props.node, + updateExpression: { + ...this.#props.node.updateExpression, + setUpdateExpressions: + this.#props.node.updateExpression.setUpdateExpressions.concat({ + kind: "SetUpdateExpression", + operation: operand, + key, + right: expression, + }), + }, + }, + }); + } else { + const builder = right as ( + builder: SetUpdateExpressionFunctionQueryBuilder< + DDB, + Table, + DDB[Table] + > + ) => [SetUpdateExpressionFunction, number]; + + const [expression, number] = builder(setUpdateExpressionBuilder); + return new UpdateItemQueryBuilder({ + ...this.#props, + node: { + ...this.#props.node, + updateExpression: { + ...this.#props.node.updateExpression, + setUpdateExpressions: + this.#props.node.updateExpression.setUpdateExpressions.concat({ + kind: "SetUpdateExpression", + operation: operand, + key, + right: expression, + value: number, + }), + }, + }, + }); + } + } else { + return new UpdateItemQueryBuilder({ + ...this.#props, + node: { + ...this.#props.node, + updateExpression: { + ...this.#props.node.updateExpression, + setUpdateExpressions: + this.#props.node.updateExpression.setUpdateExpressions.concat({ + kind: "SetUpdateExpression", + operation: operand, + key, + right: { + kind: "SetUpdateExpressionValue", + value: right, + }, + }), + }, + }, + }); + } + } + + remove>>( + attribute: Key + ): UpdateItemQueryBuilderInterface { + return new UpdateItemQueryBuilder({ + ...this.#props, + node: { + ...this.#props.node, + updateExpression: { + ...this.#props.node.updateExpression, + removeUpdateExpressions: + this.#props.node.updateExpression.removeUpdateExpressions.concat({ + kind: "RemoveUpdateExpression", + attribute, + }), + }, + }, + }); + } + + add>>( + attribute: Key, + value: StripKeys> + ): UpdateItemQueryBuilderInterface { + return new UpdateItemQueryBuilder({ + ...this.#props, + node: { + ...this.#props.node, + updateExpression: { + ...this.#props.node.updateExpression, + addUpdateExpressions: + this.#props.node.updateExpression.addUpdateExpressions.concat({ + kind: "AddUpdateExpression", + key: attribute, + value, + }), + }, + }, + }); + } + + delete< + Key extends ObjectKeyPaths, Set>> + >( + attribute: Key, + value: StripKeys> + ): UpdateItemQueryBuilderInterface { + return new UpdateItemQueryBuilder({ + ...this.#props, + node: { + ...this.#props.node, + updateExpression: { + ...this.#props.node.updateExpression, + deleteUpdateExpressions: + this.#props.node.updateExpression.deleteUpdateExpressions.concat({ + kind: "DeleteUpdateExpression", + key: attribute, + value, + }), + }, + }, + }); + } + + returnValues( + option: ReturnValuesOptions + ): UpdateItemQueryBuilderInterface { + return new UpdateItemQueryBuilder({ + ...this.#props, + node: { + ...this.#props.node, + returnValues: { + kind: "ReturnValuesNode", + option, + }, + }, + }); + } + + keys & PickSkRequired>( + keys: Keys + ) { + return new UpdateItemQueryBuilder({ + ...this.#props, + node: { + ...this.#props.node, + keys: { + kind: "KeysNode", + keys, + }, + }, + }); + } + + compile = (): UpdateCommand => { + 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; + }; +} + +preventAwait( + UpdateItemQueryBuilder, + "Don't await UpdateItemQueryBuilder instances directly. To execute the query you need to call the `execute` method" +); + +interface UpdateItemQueryBuilderProps { + readonly node: UpdateNode; + readonly ddbClient: DynamoDBDocumentClient; + readonly queryCompiler: QueryCompiler; +} diff --git a/src/queryCompiler/__snapshots__/queryCompiler.test.ts.snap b/src/queryCompiler/__snapshots__/queryCompiler.test.ts.snap index adbf467..0a40dd2 100644 --- a/src/queryCompiler/__snapshots__/queryCompiler.test.ts.snap +++ b/src/queryCompiler/__snapshots__/queryCompiler.test.ts.snap @@ -309,3 +309,113 @@ QueryCommand { }, } `; + +exports[`QueryQueryBuilder > updateItemQueryBuilder can be compiled 1`] = ` +UpdateCommand { + "clientCommand": UpdateItemCommand { + "deserialize": [Function], + "input": { + "ConditionExpression": undefined, + "ExpressionAttributeNames": { + "#nested": "nested", + "#nestedBoolean": "nestedBoolean", + "#nestedSet": "nestedSet", + "#someBoolean": "someBoolean", + "#someSet": "someSet", + }, + "ExpressionAttributeValues": { + ":addUpdateExpressionValue1": Set { + "1", + "2", + }, + ":deleteUpdateExpressionValue2": Set { + "a", + }, + ":setUpdateExpressionValue0": true, + }, + "Key": { + "dataTimestamp": 2, + "userId": "1", + }, + "ReturnValues": undefined, + "TableName": "myTable", + "UpdateExpression": "SET #nested.#nestedBoolean = :setUpdateExpressionValue0 REMOVE #someBoolean ADD #someSet :addUpdateExpressionValue1 DELETE #nested.#nestedSet :deleteUpdateExpressionValue2", + }, + "middlewareStack": { + "add": [Function], + "addRelativeTo": [Function], + "applyToStack": [Function], + "clone": [Function], + "concat": [Function], + "identify": [Function], + "identifyOnResolve": [Function], + "remove": [Function], + "removeByTag": [Function], + "resolve": [Function], + "use": [Function], + }, + "serialize": [Function], + }, + "input": { + "ConditionExpression": undefined, + "ExpressionAttributeNames": { + "#nested": "nested", + "#nestedBoolean": "nestedBoolean", + "#nestedSet": "nestedSet", + "#someBoolean": "someBoolean", + "#someSet": "someSet", + }, + "ExpressionAttributeValues": { + ":addUpdateExpressionValue1": Set { + "1", + "2", + }, + ":deleteUpdateExpressionValue2": Set { + "a", + }, + ":setUpdateExpressionValue0": true, + }, + "Key": { + "dataTimestamp": 2, + "userId": "1", + }, + "ReturnValues": undefined, + "TableName": "myTable", + "UpdateExpression": "SET #nested.#nestedBoolean = :setUpdateExpressionValue0 REMOVE #someBoolean ADD #someSet :addUpdateExpressionValue1 DELETE #nested.#nestedSet :deleteUpdateExpressionValue2", + }, + "inputKeyNodes": { + "AttributeUpdates": { + "*": { + "Value": null, + }, + }, + "Expected": { + "*": { + "AttributeValueList": [], + "Value": null, + }, + }, + "ExpressionAttributeValues": {}, + "Key": {}, + }, + "middlewareStack": { + "add": [Function], + "addRelativeTo": [Function], + "applyToStack": [Function], + "clone": [Function], + "concat": [Function], + "identify": [Function], + "identifyOnResolve": [Function], + "remove": [Function], + "removeByTag": [Function], + "resolve": [Function], + "use": [Function], + }, + "outputKeyNodes": { + "Attributes": {}, + "ItemCollectionMetrics": { + "ItemCollectionKey": {}, + }, + }, +} +`; diff --git a/src/queryCompiler/queryCompiler.test.ts b/src/queryCompiler/queryCompiler.test.ts index 2b5fb24..08fdb84 100644 --- a/src/queryCompiler/queryCompiler.test.ts +++ b/src/queryCompiler/queryCompiler.test.ts @@ -13,8 +13,8 @@ describe("QueryQueryBuilder", () => { }); }); - it("queryQueryBuilder can be compiled", async () => { - const data = await tsynamoClient + it("queryQueryBuilder can be compiled", () => { + const data = tsynamoClient .query("myTable") .keyCondition("userId", "=", "123") .filterExpression("someBoolean", "=", true) @@ -25,8 +25,8 @@ describe("QueryQueryBuilder", () => { expect(data).toMatchSnapshot(); }); - it("putItemQueryBuilder can be compiled", async () => { - const data = await tsynamoClient + it("putItemQueryBuilder can be compiled", () => { + const data = tsynamoClient .putItem("myTable") .item({ userId: "333", @@ -37,8 +37,9 @@ describe("QueryQueryBuilder", () => { expect(data).toMatchSnapshot(); }); - it("getItemQueryBuilder can be compiled", async () => { - const data = await tsynamoClient + + it("getItemQueryBuilder can be compiled", () => { + const data = tsynamoClient .getItem("myTable") .keys({ userId: TEST_DATA[1].userId, @@ -48,8 +49,9 @@ describe("QueryQueryBuilder", () => { expect(data).toMatchSnapshot(); }); - it("deleteItemQueryBuilder can be compiled", async () => { - const data = await tsynamoClient + + it("deleteItemQueryBuilder can be compiled", () => { + const data = tsynamoClient .deleteItem("myTable") .keys({ userId: "1", @@ -60,4 +62,20 @@ describe("QueryQueryBuilder", () => { expect(data).toMatchSnapshot(); }); + + it("updateItemQueryBuilder can be compiled", () => { + const data = tsynamoClient + .updateItem("myTable") + .keys({ + userId: "1", + dataTimestamp: 2, + }) + .add("someSet", new Set(["1", "2"])) + .set("nested.nestedBoolean", "=", true) + .remove("someBoolean") + .delete("nested.nestedSet", new Set(["a"])) + .compile(); + + expect(data).toMatchSnapshot(); + }); }); diff --git a/src/queryCompiler/queryCompiler.ts b/src/queryCompiler/queryCompiler.ts index 83c919c..1f14087 100644 --- a/src/queryCompiler/queryCompiler.ts +++ b/src/queryCompiler/queryCompiler.ts @@ -3,27 +3,36 @@ import { GetCommand, PutCommand, QueryCommand, + UpdateCommand, } from "@aws-sdk/lib-dynamodb"; +import { AttributesNode } from "../nodes/attributesNode"; +import { DeleteNode } from "../nodes/deleteNode"; 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 { SetUpdateExpression } from "../nodes/setUpdateExpression"; +import { SetUpdateExpressionFunction } from "../nodes/setUpdateExpressionFunction"; +import { UpdateExpression } from "../nodes/updateExpression"; +import { UpdateNode } from "../nodes/updateNode"; import { getAttributeNameFrom, getExpressionAttributeNameFrom, mergeObjectIntoMap, } from "./compilerUtil"; -import { AttributesNode } from "../nodes/attributesNode"; -import { PutNode } from "../nodes/putNode"; -import { DeleteNode } from "../nodes/deleteNode"; +import { RemoveUpdateExpression } from "../nodes/removeUpdateExpression"; +import { AddUpdateExpression } from "../nodes/addUpdateExpression"; +import { DeleteUpdateExpression } from "../nodes/deleteUpdateExpression"; export class QueryCompiler { compile(rootNode: QueryNode): QueryCommand; compile(rootNode: GetNode): GetCommand; compile(rootNode: PutNode): PutCommand; compile(rootNode: DeleteNode): DeleteCommand; - compile(rootNode: QueryNode | GetNode | PutNode | DeleteNode) { + compile(rootNode: UpdateNode): UpdateCommand; + compile(rootNode: QueryNode | GetNode | PutNode | DeleteNode | UpdateNode) { switch (rootNode.kind) { case "GetNode": return this.compileGetNode(rootNode); @@ -33,6 +42,8 @@ export class QueryCompiler { return this.compilePutNode(rootNode); case "DeleteNode": return this.compileDeleteNode(rootNode); + case "UpdateNode": + return this.compileUpdateNode(rootNode); } } @@ -192,6 +203,55 @@ export class QueryCompiler { }); } + compileUpdateNode(updateNode: UpdateNode) { + const { + table: tableNode, + conditionExpression: conditionExpressionNode, + updateExpression: updateExpressionNode, + keys: keysNode, + returnValues: returnValuesNode, + } = updateNode; + + const attributeNames = new Map(); + const filterExpressionAttributeValues = new Map(); + + const compiledConditionExpression = this.compileExpression( + conditionExpressionNode, + filterExpressionAttributeValues, + attributeNames + ); + + const compiledUpdateExpression = this.compileUpdateExpression( + updateExpressionNode, + filterExpressionAttributeValues, + attributeNames + ); + + return new UpdateCommand({ + TableName: tableNode.table, + Key: keysNode?.keys, + ReturnValues: returnValuesNode?.option, + ConditionExpression: compiledConditionExpression + ? compiledConditionExpression + : undefined, + UpdateExpression: compiledUpdateExpression + ? compiledUpdateExpression + : undefined, + ExpressionAttributeValues: + filterExpressionAttributeValues.size > 0 + ? { + ...Object.fromEntries(filterExpressionAttributeValues), + } + : undefined, + ExpressionAttributeNames: + attributeNames.size > 0 + ? { + ...Object.fromEntries(attributeNames), + } + : undefined, + }); + } + compileAttributeNamesNode(node?: AttributesNode) { const ProjectionExpression = node?.attributes .map((att) => getExpressionAttributeNameFrom(att)) @@ -380,4 +440,254 @@ export class QueryCompiler { return res; }; + + compileUpdateExpression( + node: UpdateExpression, + updateExpressionAttributeValues: Map, + attributeNames: Map + ) { + let res = ""; + + if (node.setUpdateExpressions.length > 0) { + res += "SET "; + res += node.setUpdateExpressions + .map((setUpdateExpression) => { + return this.compileSetUpdateExpression( + setUpdateExpression, + updateExpressionAttributeValues, + attributeNames + ); + }) + .join(", "); + } + + if (node.removeUpdateExpressions.length > 0) { + res += " REMOVE "; + res += node.removeUpdateExpressions + .map((removeUpdateExpression) => { + return this.compileRemoveUpdateExpression( + removeUpdateExpression, + attributeNames + ); + }) + .join(", "); + } + + if (node.addUpdateExpressions.length > 0) { + res += " ADD "; + res += node.addUpdateExpressions + .map((addUpdateExpression) => { + return this.compileAddUpdateExpression( + addUpdateExpression, + updateExpressionAttributeValues, + attributeNames + ); + }) + .join(", "); + } + + if (node.deleteUpdateExpressions.length > 0) { + res += " DELETE "; + res += node.deleteUpdateExpressions + .map((deleteUpdateExpression) => { + return this.compileDeleteUpdateExpression( + deleteUpdateExpression, + updateExpressionAttributeValues, + attributeNames + ); + }) + .join(", "); + } + + return res; + } + + compileSetUpdateExpression( + expression: SetUpdateExpression, + updateExpressionAttributeValues: Map, + attributeNames: Map + ) { + let res = ""; + const offset = updateExpressionAttributeValues.size; + const attributeValue = `:setUpdateExpressionValue${offset}`; + + const { expressionAttributeName, attributeNameMap } = + this.compileAttributeName(expression.key); + const attributeName = expressionAttributeName; + mergeObjectIntoMap(attributeNames, attributeNameMap); + + res += `${attributeName} = `; + + switch (expression.right.kind) { + case "SetUpdateExpressionValue": { + if (expression.operation !== "=") { + if (expression.operation === "-=") { + res += `${attributeName} - `; + } else { + res += `${attributeName} + `; + } + } + + res += attributeValue; + updateExpressionAttributeValues.set( + attributeValue, + expression.right.value + ); + return res; + } + + case "SetUpdateExpressionFunction": { + const compiledFunc = this.compileSetUpdateExpressionFunction( + expression.right, + updateExpressionAttributeValues, + attributeNames + ); + + if (expression.value !== undefined) { + const offset = updateExpressionAttributeValues.size; + const attributeValue = `:setUpdateExpressionValue${offset}Value`; + + if (expression.operation === "+=") { + // TODO: Put to value map + res += `${compiledFunc} + ${attributeValue}`; + } else if (expression.operation === "-=") { + res += `${compiledFunc} - ${attributeValue}`; + } + + updateExpressionAttributeValues.set(attributeValue, expression.value); + } else { + res += compiledFunc; + } + + return res; + } + } + } + + compileSetUpdateExpressionFunction( + functionExpression: SetUpdateExpressionFunction, + updateExpressionAttributeValues: Map, + attributeNames: Map + ) { + const { function: functionNode } = functionExpression; + let res = ""; + + switch (functionNode.kind) { + case "SetUpdateExpressionIfNotExistsFunction": { + let rightValue = ""; + const offset = updateExpressionAttributeValues.size; + const attributeValue = `:setUpdateExpressionValue${offset}`; + + if (functionNode.right.kind === "SetUpdateExpressionValue") { + rightValue = attributeValue; + + updateExpressionAttributeValues.set( + attributeValue, + functionNode.right.value + ); + } else { + rightValue = this.compileSetUpdateExpressionFunction( + functionNode.right, + updateExpressionAttributeValues, + attributeNames + ); + } + + const { expressionAttributeName, attributeNameMap } = + this.compileAttributeName(functionNode.path); + const attributeName = expressionAttributeName; + mergeObjectIntoMap(attributeNames, attributeNameMap); + + res += `if_not_exists(${attributeName}, ${rightValue})`; + return res; + } + + case "SetUpdateExpressionListAppendFunction": { + let leftValue = ""; + let rightValue = ""; + + if (typeof functionNode.left === "string") { + const { expressionAttributeName, attributeNameMap } = + this.compileAttributeName(functionNode.left); + + const attributeName = expressionAttributeName; + mergeObjectIntoMap(attributeNames, attributeNameMap); + leftValue = attributeName; + } else { + leftValue = this.compileSetUpdateExpressionFunction( + functionNode.left, + updateExpressionAttributeValues, + attributeNames + ); + } + + const offset = updateExpressionAttributeValues.size; + const attributeValue = `:setUpdateExpressionValue${offset}`; + + if (functionNode.right.kind === "SetUpdateExpressionValue") { + rightValue = attributeValue; + + updateExpressionAttributeValues.set( + attributeValue, + functionNode.right.value + ); + } else { + rightValue = this.compileSetUpdateExpressionFunction( + functionNode.right, + updateExpressionAttributeValues, + attributeNames + ); + } + + res += `list_append(${leftValue}, ${rightValue})`; + return res; + } + } + } + + compileRemoveUpdateExpression( + node: RemoveUpdateExpression, + attributeNames: Map + ) { + const { expressionAttributeName, attributeNameMap } = + this.compileAttributeName(node.attribute); + const attributeName = expressionAttributeName; + mergeObjectIntoMap(attributeNames, attributeNameMap); + return attributeName; + } + + compileAddUpdateExpression( + node: AddUpdateExpression, + updateExpressionAttributeValues: Map, + attributeNames: Map + ) { + const { expressionAttributeName, attributeNameMap } = + this.compileAttributeName(node.key); + const attributeName = expressionAttributeName; + mergeObjectIntoMap(attributeNames, attributeNameMap); + + const offset = updateExpressionAttributeValues.size; + const attributeValue = `:addUpdateExpressionValue${offset}`; + updateExpressionAttributeValues.set(attributeValue, node.value); + + return `${attributeName} ${attributeValue}`; + } + + compileDeleteUpdateExpression( + node: DeleteUpdateExpression, + updateExpressionAttributeValues: Map, + attributeNames: Map + ) { + const { expressionAttributeName, attributeNameMap } = + this.compileAttributeName(node.key); + + const attributeName = expressionAttributeName; + mergeObjectIntoMap(attributeNames, attributeNameMap); + + const offset = updateExpressionAttributeValues.size; + const attributeValue = `:deleteUpdateExpressionValue${offset}`; + updateExpressionAttributeValues.set(attributeValue, node.value); + + return `${attributeName} ${attributeValue}`; + } } diff --git a/src/queryCreator.ts b/src/queryCreator.ts index d3d191e..ced2865 100644 --- a/src/queryCreator.ts +++ b/src/queryCreator.ts @@ -10,6 +10,7 @@ import { QueryQueryBuilderInterface, } from "./queryBuilders/queryQueryBuilder"; import { QueryCompiler } from "./queryCompiler"; +import { UpdateItemQueryBuilder } from "./queryBuilders/updateItemQueryBuilder"; export class QueryCreator { readonly #props: QueryCreatorProps; @@ -119,6 +120,39 @@ export class QueryCreator { queryCompiler: this.#props.queryCompiler, }); } + + /** + * + * @param table Table to perform the update item command to + * + * @see https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/client/dynamodb/command/UpdateItemCommand/ + */ + updateItem( + table: Table + ): UpdateItemQueryBuilder { + return new UpdateItemQueryBuilder({ + node: { + kind: "UpdateNode", + table: { + kind: "TableNode", + table, + }, + conditionExpression: { + kind: "ExpressionNode", + expressions: [], + }, + updateExpression: { + kind: "UpdateExpression", + setUpdateExpressions: [], + removeUpdateExpressions: [], + addUpdateExpressions: [], + deleteUpdateExpressions: [] + }, + }, + ddbClient: this.#props.ddbClient, + queryCompiler: this.#props.queryCompiler, + }); + } } export interface QueryCreatorProps { diff --git a/src/typeHelpers.ts b/src/typeHelpers.ts index 65f86e1..1edbb2e 100644 --- a/src/typeHelpers.ts +++ b/src/typeHelpers.ts @@ -96,9 +96,24 @@ export type SelectAttributes< > >; +type IsArray = T extends unknown[] ? true : false; + +export type FilteredKeys = { + // TODO: Add support for recursively checking tuple values here + [K in keyof T]: IsArray extends false + ? T[K] extends U + ? T[K] + : T[K] extends object + ? FilteredKeys + : never + : never; +}; + export type DeepPartial = { [P in keyof T]?: T[P] extends Array ? Array> + : T[P] extends Set + ? T[P] : T[P] extends object ? DeepPartial : T[P]; @@ -121,10 +136,6 @@ export type GetFromPath = RecursiveGet>; // `"." | "[" | "]"` union type. If it does, we split // the string at this position. If it does not, we // keep going. -// -// This is similar to the `RemovePunctuation` generic we have -// seen in this chapter, except we create a tuple type instead -// of a string here. type ParsePath< // our unparsed path string Path, diff --git a/test/testFixture.ts b/test/testFixture.ts index 99838db..2996a79 100644 --- a/test/testFixture.ts +++ b/test/testFixture.ts @@ -16,13 +16,15 @@ export interface DDB { somethingElse: number; someBoolean: boolean; nested: { - nestedString: number; + nestedString: string; nestedBoolean: boolean; nestedNested: { nestedNestedBoolean: boolean; }; + nestedSet: Set }; tags: string[]; + someSet: Set; }; myOtherTable: { userId: PartitionKey;