From bf1b087d5b407052b072685e85a8cb5d10f46197 Mon Sep 17 00:00:00 2001 From: Drew Date: Wed, 9 Oct 2024 01:53:22 -0600 Subject: [PATCH 01/11] adds instance method for update. needs unit tests and cleanup --- src/DynaRecord.ts | 29 ++++++++++++++++++++++++++++- src/metadata/EntityMetadata.ts | 3 ++- src/metadata/types.ts | 12 ++++++++++-- src/operations/Create/Create.ts | 10 ++++++---- src/operations/FindById/types.ts | 9 +++++++-- src/operations/types.ts | 15 ++++++++++++--- src/relationships/BelongsToLink.ts | 3 ++- src/utils.ts | 21 ++++++++++++++++++++- 8 files changed, 87 insertions(+), 15 deletions(-) diff --git a/src/DynaRecord.ts b/src/DynaRecord.ts index 446dcb58..1d395fa8 100644 --- a/src/DynaRecord.ts +++ b/src/DynaRecord.ts @@ -13,9 +13,11 @@ import { type CreateOptions, Update, type UpdateOptions, - Delete + Delete, + EntityAttributes } from "./operations"; import type { EntityClass, Optional } from "./types"; +import { createInstance } from "./utils"; interface DynaRecordBase { id: string; @@ -258,6 +260,31 @@ abstract class DynaRecord implements DynaRecordBase { await op.run(id, attributes); } + // // // TODO typedoc + // TODO add to readme + // TODO test that this returns instance of class + // TDO + public async update( + attributes: UpdateOptions + ): Promise { + const InstanceClass = this.constructor as EntityClass; + const op = new Update(InstanceClass); + await op.run(this.id, attributes); + + // TODO test that calling instance wasnt mutated + const updatedInstanceAttrs = structuredClone( + this + ) as unknown as EntityAttributes; + + // Update the current instance with new attributes + Object.assign(updatedInstanceAttrs, attributes); + + // TODO test that calling instance wasnt mutated + + // Return the updated instance, which is of type `this` + return createInstance(InstanceClass, updatedInstanceAttrs); + } + /** * Delete an entity by ID * - Delete all BelongsToLinks diff --git a/src/metadata/EntityMetadata.ts b/src/metadata/EntityMetadata.ts index a5baad7f..cc8eb4e2 100644 --- a/src/metadata/EntityMetadata.ts +++ b/src/metadata/EntityMetadata.ts @@ -6,6 +6,7 @@ import { } from "."; import type DynaRecord from "../DynaRecord"; import { ValidationError } from "../errors"; +import { type EntityAttributes } from "../operations"; type EntityClass = new (...args: any) => DynaRecord; @@ -81,7 +82,7 @@ class EntityMetadata { * Validate all an entities attributes (used on create) * @param attributes */ - public validateFull(attributes: DynaRecord): void { + public validateFull(attributes: EntityAttributes): void { if (this.#schema === undefined) { this.#schema = z.object(this.#zodAttributes); } diff --git a/src/metadata/types.ts b/src/metadata/types.ts index 4d0cacaf..ebc59a5e 100644 --- a/src/metadata/types.ts +++ b/src/metadata/types.ts @@ -49,10 +49,18 @@ export type JoinTableMetadataStorage = Record; */ export type DefaultDateFields = "createdAt" | "updatedAt"; +// TODO add unit test that instance method keys are not allowed whereever this isused +// TODO use the exclude on Functionfields /** - * Specifies the default fields used in entities, including fields from `DynaRecord` or `BelongsToLink`. + * Specifies the default fields used in entities, including fields from `DynaRecord` or `BelongsToLink`. Instance methods are excluded */ -export type DefaultFields = keyof DynaRecord | keyof BelongsToLink; +export type DefaultFields = + | { + [K in keyof DynaRecord]: DynaRecord[K] extends (...args: any[]) => any + ? never + : K; + }[keyof DynaRecord] + | keyof BelongsToLink; /** * Defines the structure for default fields within a table, mapping field names to their `AttributeMetadata` aliases. diff --git a/src/operations/Create/Create.ts b/src/operations/Create/Create.ts index c6a29624..4ff2a8f3 100644 --- a/src/operations/Create/Create.ts +++ b/src/operations/Create/Create.ts @@ -6,7 +6,7 @@ import { entityToTableItem, tableItemToEntity } from "../../utils"; import OperationBase from "../OperationBase"; import { RelationshipTransactions } from "../utils"; import type { CreateOptions } from "./types"; -import { type EntityAttributes } from "../types"; +import { type EntityAttributeDefaultFields, type EntityAttributes } from "../types"; import Metadata from "../../metadata"; /** @@ -50,7 +50,9 @@ class Create extends OperationBase { * @param attributes * @returns */ - private buildEntityData(attributes: CreateOptions): DynaRecord { + private buildEntityData( + attributes: CreateOptions + ): EntityAttributes { const id = uuidv4(); const createdAt = new Date(); @@ -62,7 +64,7 @@ class Create extends OperationBase { [sk]: this.EntityClass.name }; - const defaultAttrs: DynaRecord = { + const defaultAttrs: EntityAttributeDefaultFields = { id, type: this.EntityClass.name, createdAt, @@ -92,7 +94,7 @@ class Create extends OperationBase { * @param entityData */ private async buildRelationshipTransactions( - entityData: DynaRecord + entityData: EntityAttributes ): Promise { const relationshipTransactions = new RelationshipTransactions({ Entity: this.EntityClass, diff --git a/src/operations/FindById/types.ts b/src/operations/FindById/types.ts index 7e78f631..0a9ed8a2 100644 --- a/src/operations/FindById/types.ts +++ b/src/operations/FindById/types.ts @@ -1,6 +1,10 @@ import type { QueryItems } from "../../dynamo-utils"; import type DynaRecord from "../../DynaRecord"; -import type { EntityAttributes, RelationshipAttributeNames } from "../types"; +import type { + EntityAttributes, + FunctionFields, + RelationshipAttributeNames +} from "../types"; import type { BelongsToLinkDynamoItem } from "../../types"; /** @@ -62,6 +66,7 @@ type EntityKeysWithIncludedAssociations< : T[K]; }; +// TODO add test that instance method "update" is included /** * Represents the result of a `FindById` operation, including the main entity and any specified associated entities. * @@ -73,5 +78,5 @@ export type FindByIdIncludesRes< Opts extends FindByIdOptions > = EntityKeysWithIncludedAssociations< T, - keyof EntityAttributes | IncludedKeys + keyof EntityAttributes | IncludedKeys | FunctionFields >; diff --git a/src/operations/types.ts b/src/operations/types.ts index 4dac6cfe..505f47e6 100644 --- a/src/operations/types.ts +++ b/src/operations/types.ts @@ -1,4 +1,5 @@ import type DynaRecord from "../DynaRecord"; +import { type DefaultFields } from "../metadata"; import type { ForeignKey, NullableForeignKey, @@ -34,8 +35,7 @@ export type SortKeyAttribute = { * @returns The names of the function properties as strings if any exist; otherwise, the result is `never`. */ export type FunctionFields = { - // eslint-disable-next-line @typescript-eslint/ban-types - [K in keyof T]: T[K] extends Function ? K : never; + [K in keyof T]: T[K] extends (...args: any[]) => any ? K : never; }[keyof T]; /** @@ -67,7 +67,16 @@ export type RelationshipAttributeNames = { */ export type EntityAttributes = Omit< T, - RelationshipAttributeNames + RelationshipAttributeNames | FunctionFields +>; + +// // TODO add unit test that methods are not used where every this is used +/** + * Entity attributes for default fields + */ +export type EntityAttributeDefaultFields = Pick< + DynaRecord, + Extract >; /** diff --git a/src/relationships/BelongsToLink.ts b/src/relationships/BelongsToLink.ts index 19e93490..5cc3be15 100644 --- a/src/relationships/BelongsToLink.ts +++ b/src/relationships/BelongsToLink.ts @@ -1,11 +1,12 @@ import { v4 as uuidv4 } from "uuid"; import type DynaRecord from "../DynaRecord"; import type { ForeignKey } from "../types"; +import type { EntityAttributes } from "../operations"; /** * Extends `DynaRecord` with properties specific to "BelongsTo" relationships, such as `foreignEntityType` and `foreignKey`. */ -interface BelongsToLinkProps extends DynaRecord { +interface BelongsToLinkProps extends EntityAttributes { foreignEntityType: string; foreignKey: string; } diff --git a/src/utils.ts b/src/utils.ts index f42e6029..2412a2f0 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -6,6 +6,7 @@ import Metadata, { } from "./metadata"; import { BelongsToLink } from "./relationships"; import type { NativeScalarAttributeValue } from "@aws-sdk/util-dynamodb"; +import { type EntityAttributes } from "./operations"; /** * Convert an entity to its aliased table item fields to for dynamo interactions @@ -71,6 +72,24 @@ export const tableItemToEntity = ( return entity; }; +/* The line `const updatedInstanceAttrs = structuredClone(this);` is creating a deep copy of the +current instance object `this` using the `structuredClone` function. This is typically done to +ensure that any modifications made to the copied object do not affect the original object. */ +export const createInstance = ( + EntityClass: new () => T, + attributes: EntityAttributes +): T => { + const entity = new EntityClass(); + + Object.entries(attributes).forEach(([attrName, val]) => { + if (isKeyOfEntity(entity, attrName)) { + safeAssign(entity, attrName, val); + } + }); + + return entity; +}; + /** * Serialize a dynamo table item response to a BelongsToLink * @param tableMeta - Table metadata @@ -205,7 +224,7 @@ export const isString = (value: any): value is string => { * safeAssign(entity, "name", "Jane Doe"); */ export const safeAssign = < - TObject extends DynaRecord, + TObject extends EntityAttributes, TKey extends keyof TObject, TValue >( From 1cba61c81a310fcd37954d3be26969c23e1e9958 Mon Sep 17 00:00:00 2001 From: Drew Date: Wed, 9 Oct 2024 01:58:42 -0600 Subject: [PATCH 02/11] prettier --- src/operations/Create/Create.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/operations/Create/Create.ts b/src/operations/Create/Create.ts index 4ff2a8f3..6566f824 100644 --- a/src/operations/Create/Create.ts +++ b/src/operations/Create/Create.ts @@ -6,7 +6,10 @@ import { entityToTableItem, tableItemToEntity } from "../../utils"; import OperationBase from "../OperationBase"; import { RelationshipTransactions } from "../utils"; import type { CreateOptions } from "./types"; -import { type EntityAttributeDefaultFields, type EntityAttributes } from "../types"; +import { + type EntityAttributeDefaultFields, + type EntityAttributes +} from "../types"; import Metadata from "../../metadata"; /** From d03355f84ea7a9cf96617bf9edefa9e5b7edbcca Mon Sep 17 00:00:00 2001 From: Drew Date: Thu, 10 Oct 2024 00:44:48 -0600 Subject: [PATCH 03/11] added update instance method tests --- src/DynaRecord.ts | 25 +- src/metadata/EntityMetadata.ts | 7 + src/operations/Update/Update.ts | 18 +- src/operations/Update/types.ts | 8 + tests/integration/Update.test.ts | 6259 ++++++++++++++++++++++-------- 5 files changed, 4717 insertions(+), 1600 deletions(-) diff --git a/src/DynaRecord.ts b/src/DynaRecord.ts index 1d395fa8..d427a0fb 100644 --- a/src/DynaRecord.ts +++ b/src/DynaRecord.ts @@ -14,7 +14,7 @@ import { Update, type UpdateOptions, Delete, - EntityAttributes + type EntityAttributes } from "./operations"; import type { EntityClass, Optional } from "./types"; import { createInstance } from "./utils"; @@ -263,26 +263,29 @@ abstract class DynaRecord implements DynaRecordBase { // // // TODO typedoc // TODO add to readme // TODO test that this returns instance of class - // TDO - public async update( + // TODO test that only updatedable attributes can be updated. Meaning EntityDefinedAttributes + // TODO add unit test that only entity defined attributes can be set onthis + // - for example setting id is not allowed (for types and via schemas) + // - this should be tested via type tests AND runtime schema tests + // - If there are not equivalent tests for the static method paramters then add those + public async update( attributes: UpdateOptions ): Promise { const InstanceClass = this.constructor as EntityClass; const op = new Update(InstanceClass); - await op.run(this.id, attributes); + const updatedAttributes = await op.run(this.id, attributes); - // TODO test that calling instance wasnt mutated - const updatedInstanceAttrs = structuredClone( - this - ) as unknown as EntityAttributes; + const clone = structuredClone(this); // Update the current instance with new attributes - Object.assign(updatedInstanceAttrs, attributes); + Object.assign(clone, updatedAttributes); - // TODO test that calling instance wasnt mutated + const updatedInstance = Object.fromEntries( + Object.entries(clone).filter(([_, value]) => value !== null) + ) as EntityAttributes; // Return the updated instance, which is of type `this` - return createInstance(InstanceClass, updatedInstanceAttrs); + return createInstance(InstanceClass, updatedInstance); } /** diff --git a/src/metadata/EntityMetadata.ts b/src/metadata/EntityMetadata.ts index cc8eb4e2..68aba0f7 100644 --- a/src/metadata/EntityMetadata.ts +++ b/src/metadata/EntityMetadata.ts @@ -78,6 +78,8 @@ class EntityMetadata { this.#zodAttributes[attrMeta.name] = attrMeta.type; } + // TODO check that if I were to pass non entity defined attributes such as pk or id (such as by using 'any' to remove type checks) that there are runtime checks that only attributes that are updatedable can be updated + // would that be a different method? /** * Validate all an entities attributes (used on create) * @param attributes @@ -94,6 +96,11 @@ class EntityMetadata { } } + // TODO I think that this needs to be validating on entity defined attributes only.... + // otherwise would this allow things like "id" "pk" etc to be updated without runtime schema validation? + // add tests for this + // In which case I think this needs to be renamed? or a new method and this validates pre input? + // Are changes needed to validate full as well? /** * Validate partial entities attributes (used on update) * @param attributes diff --git a/src/operations/Update/Update.ts b/src/operations/Update/Update.ts index af80176c..ddcdabc5 100644 --- a/src/operations/Update/Update.ts +++ b/src/operations/Update/Update.ts @@ -12,7 +12,7 @@ import { extractForeignKeyFromEntity } from "../utils"; import OperationBase from "../OperationBase"; -import type { UpdateOptions } from "./types"; +import type { UpdatedAttributes, UpdateOptions } from "./types"; import type { EntityClass } from "../../types"; import Metadata from "../../metadata"; @@ -38,13 +38,19 @@ class Update extends OperationBase { * @param id The id of the entity being updated * @param attributes Attributes on the model to update. */ - public async run(id: string, attributes: UpdateOptions): Promise { + public async run( + id: string, + attributes: UpdateOptions + ): Promise> { const entityMeta = Metadata.getEntity(this.EntityClass.name); entityMeta.validatePartial(attributes); - this.buildUpdateItemTransaction(id, attributes); + const updatedAttrs = this.buildUpdateItemTransaction(id, attributes); + await this.buildRelationshipTransactions(id, attributes); await this.#transactionBuilder.executeTransaction(); + + return updatedAttrs; } /** @@ -55,7 +61,7 @@ class Update extends OperationBase { private buildUpdateItemTransaction( id: string, attributes: UpdateOptions - ): void { + ): UpdatedAttributes { const { name: tableName } = this.tableMetadata; const pk = this.tableMetadata.partitionKeyAttribute.name; @@ -66,7 +72,7 @@ class Update extends OperationBase { [sk]: this.EntityClass.name }; - const updatedAttrs: Partial = { + const updatedAttrs: UpdatedAttributes = { ...attributes, updatedAt: new Date() }; @@ -84,6 +90,8 @@ class Update extends OperationBase { }, `${this.EntityClass.name} with ID '${id}' does not exist` ); + + return updatedAttrs; } /** diff --git a/src/operations/Update/types.ts b/src/operations/Update/types.ts index 241e1d17..4d1e23ce 100644 --- a/src/operations/Update/types.ts +++ b/src/operations/Update/types.ts @@ -33,3 +33,11 @@ type AllowNullForNullable = { export type UpdateOptions = Partial< AllowNullForNullable> >; + +/** + * Attributes of an entity that were updated. Including the auto generated updatedAt date + */ +export type UpdatedAttributes = Pick< + Partial, + "updatedAt" +>; diff --git a/tests/integration/Update.test.ts b/tests/integration/Update.test.ts index e1e34f4c..f751e0c5 100644 --- a/tests/integration/Update.test.ts +++ b/tests/integration/Update.test.ts @@ -20,8 +20,14 @@ import { DateAttribute, StringAttribute } from "../../src/decorators"; -import { type ForeignKey } from "../../src/types"; +import { + type NullableForeignKey, + type PartitionKey, + type SortKey, + type ForeignKey +} from "../../src/types"; import { ValidationError } from "../../src"; +import { createInstance } from "../../src/utils"; jest.mock("uuid"); @@ -34,6 +40,11 @@ const mockTransact = jest.fn(); const mockedUuidv4 = jest.mocked(uuidv4); +// TODO start here... add tests. +// - Make a describe for the static tests +// - and make a describe for instance tests that copies/modefies all of them +// TODO do the same for types test + jest.mock("@aws-sdk/client-dynamodb", () => { return { TransactionCanceledException: jest.fn().mockImplementation((...params) => { @@ -117,401 +128,303 @@ describe("Update", () => { jest.clearAllMocks(); }); - it("will update an entity without foreign key attributes", async () => { - expect.assertions(6); + describe("static method", () => { + it("will update an entity without foreign key attributes", async () => { + expect.assertions(6); - jest.setSystemTime(new Date("2023-10-16T03:31:35.918Z")); + jest.setSystemTime(new Date("2023-10-16T03:31:35.918Z")); - expect( - // eslint-disable-next-line @typescript-eslint/no-confusing-void-expression - await Customer.update("123", { - name: "New Name", - address: "new Address" - }) - ).toBeUndefined(); - expect(mockSend.mock.calls).toEqual([[{ name: "TransactWriteCommand" }]]); - expect(mockGet.mock.calls).toEqual([]); - expect(mockedGetCommand.mock.calls).toEqual([]); - expect(mockTransact.mock.calls).toEqual([[]]); - expect(mockTransactWriteCommand.mock.calls).toEqual([ - [ - { - TransactItems: [ - { - Update: { - TableName: "mock-table", - Key: { PK: "Customer#123", SK: "Customer" }, - UpdateExpression: - "SET #Name = :Name, #Address = :Address, #UpdatedAt = :UpdatedAt", - ConditionExpression: "attribute_exists(PK)", - ExpressionAttributeNames: { - "#Address": "Address", - "#Name": "Name", - "#UpdatedAt": "UpdatedAt" - }, - ExpressionAttributeValues: { - ":Address": "new Address", - ":Name": "New Name", - ":UpdatedAt": "2023-10-16T03:31:35.918Z" + expect( + // eslint-disable-next-line @typescript-eslint/no-confusing-void-expression + await Customer.update("123", { + name: "New Name", + address: "new Address" + }) + ).toBeUndefined(); + expect(mockSend.mock.calls).toEqual([[{ name: "TransactWriteCommand" }]]); + expect(mockGet.mock.calls).toEqual([]); + expect(mockedGetCommand.mock.calls).toEqual([]); + expect(mockTransact.mock.calls).toEqual([[]]); + expect(mockTransactWriteCommand.mock.calls).toEqual([ + [ + { + TransactItems: [ + { + Update: { + TableName: "mock-table", + Key: { PK: "Customer#123", SK: "Customer" }, + UpdateExpression: + "SET #Name = :Name, #Address = :Address, #UpdatedAt = :UpdatedAt", + ConditionExpression: "attribute_exists(PK)", + ExpressionAttributeNames: { + "#Address": "Address", + "#Name": "Name", + "#UpdatedAt": "UpdatedAt" + }, + ExpressionAttributeValues: { + ":Address": "new Address", + ":Name": "New Name", + ":UpdatedAt": "2023-10-16T03:31:35.918Z" + } } } - } - ] - } - ] - ]); - }); + ] + } + ] + ]); + }); - it("will update an entity and remove attributes", async () => { - expect.assertions(6); + it("will update an entity and remove attributes", async () => { + expect.assertions(6); - jest.setSystemTime(new Date("2023-10-16T03:31:35.918Z")); + jest.setSystemTime(new Date("2023-10-16T03:31:35.918Z")); - expect( - // eslint-disable-next-line @typescript-eslint/no-confusing-void-expression - await ContactInformation.update("123", { - email: "new@example.com", - phone: null - }) - ).toBeUndefined(); - expect(mockSend.mock.calls).toEqual([[{ name: "TransactWriteCommand" }]]); - expect(mockGet.mock.calls).toEqual([]); - expect(mockedGetCommand.mock.calls).toEqual([]); - expect(mockTransact.mock.calls).toEqual([[]]); - expect(mockTransactWriteCommand.mock.calls).toEqual([ - [ - { - TransactItems: [ - { - Update: { - ConditionExpression: "attribute_exists(PK)", - ExpressionAttributeNames: { - "#Email": "Email", - "#Phone": "Phone", - "#UpdatedAt": "UpdatedAt" - }, - ExpressionAttributeValues: { - ":Email": "new@example.com", - ":UpdatedAt": "2023-10-16T03:31:35.918Z" - }, - Key: { PK: "ContactInformation#123", SK: "ContactInformation" }, - TableName: "mock-table", - UpdateExpression: - "SET #Email = :Email, #UpdatedAt = :UpdatedAt REMOVE #Phone" + expect( + // eslint-disable-next-line @typescript-eslint/no-confusing-void-expression + await ContactInformation.update("123", { + email: "new@example.com", + phone: null + }) + ).toBeUndefined(); + expect(mockSend.mock.calls).toEqual([[{ name: "TransactWriteCommand" }]]); + expect(mockGet.mock.calls).toEqual([]); + expect(mockedGetCommand.mock.calls).toEqual([]); + expect(mockTransact.mock.calls).toEqual([[]]); + expect(mockTransactWriteCommand.mock.calls).toEqual([ + [ + { + TransactItems: [ + { + Update: { + ConditionExpression: "attribute_exists(PK)", + ExpressionAttributeNames: { + "#Email": "Email", + "#Phone": "Phone", + "#UpdatedAt": "UpdatedAt" + }, + ExpressionAttributeValues: { + ":Email": "new@example.com", + ":UpdatedAt": "2023-10-16T03:31:35.918Z" + }, + Key: { + PK: "ContactInformation#123", + SK: "ContactInformation" + }, + TableName: "mock-table", + UpdateExpression: + "SET #Email = :Email, #UpdatedAt = :UpdatedAt REMOVE #Phone" + } } - } - ] - } - ] - ]); - }); + ] + } + ] + ]); + }); - it("will update and remove multiple attributes", async () => { - expect.assertions(6); + it("will update and remove multiple attributes", async () => { + expect.assertions(6); - jest.setSystemTime(new Date("2023-10-16T03:31:35.918Z")); + jest.setSystemTime(new Date("2023-10-16T03:31:35.918Z")); - expect( - // eslint-disable-next-line @typescript-eslint/no-confusing-void-expression - await MockInformation.update("123", { - address: "11 Some St", - email: "new@example.com", - state: null, - phone: null - }) - ).toBeUndefined(); - expect(mockSend.mock.calls).toEqual([[{ name: "TransactWriteCommand" }]]); - expect(mockGet.mock.calls).toEqual([]); - expect(mockedGetCommand.mock.calls).toEqual([]); - expect(mockTransact.mock.calls).toEqual([[]]); - expect(mockTransactWriteCommand.mock.calls).toEqual([ - [ - { - TransactItems: [ - { - Update: { - ConditionExpression: "attribute_exists(PK)", - ExpressionAttributeNames: { - "#Address": "Address", - "#Email": "Email", - "#Phone": "Phone", - "#State": "State", - "#UpdatedAt": "UpdatedAt" - }, - ExpressionAttributeValues: { - ":Address": "11 Some St", - ":Email": "new@example.com", - ":UpdatedAt": "2023-10-16T03:31:35.918Z" - }, - Key: { PK: "MockInformation#123", SK: "MockInformation" }, - TableName: "mock-table", - UpdateExpression: - "SET #Address = :Address, #Email = :Email, #UpdatedAt = :UpdatedAt REMOVE #State, #Phone" + expect( + // eslint-disable-next-line @typescript-eslint/no-confusing-void-expression + await MockInformation.update("123", { + address: "11 Some St", + email: "new@example.com", + state: null, + phone: null + }) + ).toBeUndefined(); + expect(mockSend.mock.calls).toEqual([[{ name: "TransactWriteCommand" }]]); + expect(mockGet.mock.calls).toEqual([]); + expect(mockedGetCommand.mock.calls).toEqual([]); + expect(mockTransact.mock.calls).toEqual([[]]); + expect(mockTransactWriteCommand.mock.calls).toEqual([ + [ + { + TransactItems: [ + { + Update: { + ConditionExpression: "attribute_exists(PK)", + ExpressionAttributeNames: { + "#Address": "Address", + "#Email": "Email", + "#Phone": "Phone", + "#State": "State", + "#UpdatedAt": "UpdatedAt" + }, + ExpressionAttributeValues: { + ":Address": "11 Some St", + ":Email": "new@example.com", + ":UpdatedAt": "2023-10-16T03:31:35.918Z" + }, + Key: { PK: "MockInformation#123", SK: "MockInformation" }, + TableName: "mock-table", + UpdateExpression: + "SET #Address = :Address, #Email = :Email, #UpdatedAt = :UpdatedAt REMOVE #State, #Phone" + } } - } - ] - } - ] - ]); - }); + ] + } + ] + ]); + }); + + it("will error if any attributes are the wrong type", async () => { + expect.assertions(5); + + try { + await MockInformation.update("123", { + someDate: "111" as any // Force any to test runtime validations + }); + } catch (e: any) { + expect(e).toBeInstanceOf(ValidationError); + expect(e.message).toEqual("Validation errors"); + expect(e.cause).toEqual([ + { + code: "invalid_type", + expected: "date", + message: "Expected date, received string", + path: ["someDate"], + received: "string" + } + ]); + expect(mockSend.mock.calls).toEqual([undefined]); + expect(mockTransactWriteCommand.mock.calls).toEqual([]); + } + }); - it("will error if any attributes are the wrong type", async () => { - expect.assertions(5); + it("will allow nullable attributes to be set to null", async () => { + expect.assertions(5); - try { await MockInformation.update("123", { - someDate: "111" as any // Force any to test runtime validations + someDate: null }); - } catch (e: any) { - expect(e).toBeInstanceOf(ValidationError); - expect(e.message).toEqual("Validation errors"); - expect(e.cause).toEqual([ - { - code: "invalid_type", - expected: "date", - message: "Expected date, received string", - path: ["someDate"], - received: "string" - } + + expect(mockSend.mock.calls).toEqual([[{ name: "TransactWriteCommand" }]]); + expect(mockGet.mock.calls).toEqual([]); + expect(mockedGetCommand.mock.calls).toEqual([]); + expect(mockTransact.mock.calls).toEqual([[]]); + expect(mockTransactWriteCommand.mock.calls).toEqual([ + [ + { + TransactItems: [ + { + Update: { + ConditionExpression: "attribute_exists(PK)", + ExpressionAttributeNames: { + "#UpdatedAt": "UpdatedAt", + "#someDate": "someDate" + }, + ExpressionAttributeValues: { + ":UpdatedAt": "2023-10-16T03:31:35.918Z", + ":someDate": undefined + }, + Key: { PK: "MockInformation#123", SK: "MockInformation" }, + TableName: "mock-table", + UpdateExpression: + "SET #someDate = :someDate, #UpdatedAt = :UpdatedAt" + } + } + ] + } + ] ]); - expect(mockSend.mock.calls).toEqual([undefined]); - expect(mockTransactWriteCommand.mock.calls).toEqual([]); - } - }); + }); - it("will allow nullable attributes to be set to null", async () => { - expect.assertions(5); + it("will not allow non nullable attributes to be null", async () => { + expect.assertions(5); - await MockInformation.update("123", { - someDate: null + try { + await MyModelNonNullableAttribute.update("123", { + myAttribute: null as any // Force any to test runtime validations + }); + } catch (e: any) { + expect(e).toBeInstanceOf(ValidationError); + expect(e.message).toEqual("Validation errors"); + expect(e.cause).toEqual([ + { + code: "invalid_type", + expected: "date", + message: "Expected date, received null", + path: ["myAttribute"], + received: "null" + } + ]); + expect(mockSend.mock.calls).toEqual([undefined]); + expect(mockTransactWriteCommand.mock.calls).toEqual([]); + } }); - expect(mockSend.mock.calls).toEqual([[{ name: "TransactWriteCommand" }]]); - expect(mockGet.mock.calls).toEqual([]); - expect(mockedGetCommand.mock.calls).toEqual([]); - expect(mockTransact.mock.calls).toEqual([[]]); - expect(mockTransactWriteCommand.mock.calls).toEqual([ - [ - { - TransactItems: [ - { - Update: { - ConditionExpression: "attribute_exists(PK)", - ExpressionAttributeNames: { - "#UpdatedAt": "UpdatedAt", - "#someDate": "someDate" - }, - ExpressionAttributeValues: { - ":UpdatedAt": "2023-10-16T03:31:35.918Z", - ":someDate": undefined - }, - Key: { PK: "MockInformation#123", SK: "MockInformation" }, - TableName: "mock-table", - UpdateExpression: - "SET #someDate = :someDate, #UpdatedAt = :UpdatedAt" - } - } - ] - } - ] - ]); - }); - - it("will not allow non nullable attributes to be null", async () => { - expect.assertions(5); + it("will allow nullable attributes to be set to null", async () => { + expect.assertions(5); - try { - await MyModelNonNullableAttribute.update("123", { - myAttribute: null as any // Force any to test runtime validations + await MockInformation.update("123", { + someDate: null }); - } catch (e: any) { - expect(e).toBeInstanceOf(ValidationError); - expect(e.message).toEqual("Validation errors"); - expect(e.cause).toEqual([ - { - code: "invalid_type", - expected: "date", - message: "Expected date, received null", - path: ["myAttribute"], - received: "null" - } - ]); - expect(mockSend.mock.calls).toEqual([undefined]); - expect(mockTransactWriteCommand.mock.calls).toEqual([]); - } - }); - - it("will allow nullable attributes to be set to null", async () => { - expect.assertions(5); - await MockInformation.update("123", { - someDate: null + expect(mockSend.mock.calls).toEqual([[{ name: "TransactWriteCommand" }]]); + expect(mockGet.mock.calls).toEqual([]); + expect(mockedGetCommand.mock.calls).toEqual([]); + expect(mockTransact.mock.calls).toEqual([[]]); + expect(mockTransactWriteCommand.mock.calls).toEqual([ + [ + { + TransactItems: [ + { + Update: { + ConditionExpression: "attribute_exists(PK)", + ExpressionAttributeNames: { + "#UpdatedAt": "UpdatedAt", + "#someDate": "someDate" + }, + ExpressionAttributeValues: { + ":UpdatedAt": "2023-10-16T03:31:35.918Z", + ":someDate": undefined + }, + Key: { PK: "MockInformation#123", SK: "MockInformation" }, + TableName: "mock-table", + UpdateExpression: + "SET #someDate = :someDate, #UpdatedAt = :UpdatedAt" + } + } + ] + } + ] + ]); }); - expect(mockSend.mock.calls).toEqual([[{ name: "TransactWriteCommand" }]]); - expect(mockGet.mock.calls).toEqual([]); - expect(mockedGetCommand.mock.calls).toEqual([]); - expect(mockTransact.mock.calls).toEqual([[]]); - expect(mockTransactWriteCommand.mock.calls).toEqual([ - [ - { - TransactItems: [ - { - Update: { - ConditionExpression: "attribute_exists(PK)", - ExpressionAttributeNames: { - "#UpdatedAt": "UpdatedAt", - "#someDate": "someDate" - }, - ExpressionAttributeValues: { - ":UpdatedAt": "2023-10-16T03:31:35.918Z", - ":someDate": undefined - }, - Key: { PK: "MockInformation#123", SK: "MockInformation" }, - TableName: "mock-table", - UpdateExpression: - "SET #someDate = :someDate, #UpdatedAt = :UpdatedAt" - } + describe("ForeignKey is updated for entity which BelongsTo an entity who HasOne of it", () => { + describe("when the entity does not already belong to another entity", () => { + beforeEach(() => { + jest.setSystemTime(new Date("2023-10-16T03:31:35.918Z")); + mockedUuidv4.mockReturnValueOnce("belongsToLinkId1"); + mockGet.mockResolvedValue({ + Item: { + PK: "ContactInformation#123", + SK: "ContactInformation", + Id: "123", + Email: "old-email@example.com", + Phone: "555-555-5555", + CustomerId: undefined // Does not already belong to customer } - ] - } - ] - ]); - }); - - describe("ForeignKey is updated for entity which BelongsTo an entity who HasOne of it", () => { - describe("when the entity does not already belong to another entity", () => { - beforeEach(() => { - jest.setSystemTime(new Date("2023-10-16T03:31:35.918Z")); - mockedUuidv4.mockReturnValueOnce("belongsToLinkId1"); - mockGet.mockResolvedValue({ - Item: { - PK: "ContactInformation#123", - SK: "ContactInformation", - Id: "123", - Email: "old-email@example.com", - Phone: "555-555-5555", - CustomerId: undefined // Does not already belong to customer - } + }); }); - }); - afterEach(() => { - mockedUuidv4.mockReset(); - }); + afterEach(() => { + mockedUuidv4.mockReset(); + }); - it("will update the foreign key if the entity being associated with exists", async () => { - expect.assertions(6); + it("will update the foreign key if the entity being associated with exists", async () => { + expect.assertions(6); - expect( - // eslint-disable-next-line @typescript-eslint/no-confusing-void-expression - await ContactInformation.update("123", { - email: "new-email@example.com", - customerId: "456" - }) - ).toBeUndefined(); - expect(mockSend.mock.calls).toEqual([ - [{ name: "GetCommand" }], - [{ name: "TransactWriteCommand" }] - ]); - expect(mockGet.mock.calls).toEqual([[]]); - expect(mockedGetCommand.mock.calls).toEqual([ - [ - { - TableName: "mock-table", - Key: { PK: "ContactInformation#123", SK: "ContactInformation" }, - ConsistentRead: true - } - ] - ]); - expect(mockTransact.mock.calls).toEqual([[]]); - expect(mockTransactWriteCommand.mock.calls).toEqual([ - [ - { - TransactItems: [ - { - Update: { - TableName: "mock-table", - Key: { - PK: "ContactInformation#123", - SK: "ContactInformation" - }, - UpdateExpression: - "SET #Email = :Email, #CustomerId = :CustomerId, #UpdatedAt = :UpdatedAt", - // Check that the entity being updated exists - ConditionExpression: "attribute_exists(PK)", - ExpressionAttributeNames: { - "#CustomerId": "CustomerId", - "#Email": "Email", - "#UpdatedAt": "UpdatedAt" - }, - ExpressionAttributeValues: { - ":CustomerId": "456", - ":Email": "new-email@example.com", - ":UpdatedAt": "2023-10-16T03:31:35.918Z" - } - } - }, - { - // Check that the entity being associated with exists - ConditionCheck: { - TableName: "mock-table", - Key: { PK: "Customer#456", SK: "Customer" }, - ConditionExpression: "attribute_exists(PK)" - } - }, - { - Put: { - TableName: "mock-table", - ConditionExpression: "attribute_not_exists(PK)", - Item: { - PK: "Customer#456", - SK: "ContactInformation", - Id: "belongsToLinkId1", - Type: "BelongsToLink", - ForeignEntityType: "ContactInformation", - ForeignKey: "123", - CreatedAt: "2023-10-16T03:31:35.918Z", - UpdatedAt: "2023-10-16T03:31:35.918Z" - } - } - } - ] - } - ] - ]); - }); - - it("will throw an error if the entity being updated does not exist", async () => { - expect.assertions(7); - - mockGet.mockResolvedValueOnce({}); // Entity does not exist but will fail in transaction - - mockSend.mockReturnValueOnce(undefined).mockImplementationOnce(() => { - mockTransact(); - throw new TransactionCanceledException({ - message: "MockMessage", - CancellationReasons: [ - { Code: "ConditionalCheckFailed" }, - { Code: "None" }, - { Code: "None" } - ], - $metadata: {} - }); - }); - - try { - await ContactInformation.update("123", { - email: "new-email@example.com", - customerId: "456" - }); - } catch (e: any) { - expect(e.constructor.name).toEqual("TransactionWriteFailedError"); - expect(e.errors).toEqual([ - new ConditionalCheckFailedError( - "ConditionalCheckFailed: ContactInformation with ID '123' does not exist" - ) - ]); + expect( + // eslint-disable-next-line @typescript-eslint/no-confusing-void-expression + await ContactInformation.update("123", { + email: "new-email@example.com", + customerId: "456" + }) + ).toBeUndefined(); expect(mockSend.mock.calls).toEqual([ [{ name: "GetCommand" }], [{ name: "TransactWriteCommand" }] @@ -582,37 +495,305 @@ describe("Update", () => { } ] ]); - } - }); + }); - it("will throw an error if the entity being associated with does not exist", async () => { - expect.assertions(7); + it("will throw an error if the entity being updated does not exist", async () => { + expect.assertions(7); - mockSend.mockReturnValueOnce(undefined).mockImplementationOnce(() => { - mockTransact(); - throw new TransactionCanceledException({ - message: "MockMessage", - CancellationReasons: [ - { Code: "None" }, - { Code: "ConditionalCheckFailed" }, - { Code: "None" } - ], - $metadata: {} + mockGet.mockResolvedValueOnce({}); // Entity does not exist but will fail in transaction + + mockSend.mockReturnValueOnce(undefined).mockImplementationOnce(() => { + mockTransact(); + throw new TransactionCanceledException({ + message: "MockMessage", + CancellationReasons: [ + { Code: "ConditionalCheckFailed" }, + { Code: "None" }, + { Code: "None" } + ], + $metadata: {} + }); }); + + try { + await ContactInformation.update("123", { + email: "new-email@example.com", + customerId: "456" + }); + } catch (e: any) { + expect(e.constructor.name).toEqual("TransactionWriteFailedError"); + expect(e.errors).toEqual([ + new ConditionalCheckFailedError( + "ConditionalCheckFailed: ContactInformation with ID '123' does not exist" + ) + ]); + expect(mockSend.mock.calls).toEqual([ + [{ name: "GetCommand" }], + [{ name: "TransactWriteCommand" }] + ]); + expect(mockGet.mock.calls).toEqual([[]]); + expect(mockedGetCommand.mock.calls).toEqual([ + [ + { + TableName: "mock-table", + Key: { + PK: "ContactInformation#123", + SK: "ContactInformation" + }, + ConsistentRead: true + } + ] + ]); + expect(mockTransact.mock.calls).toEqual([[]]); + expect(mockTransactWriteCommand.mock.calls).toEqual([ + [ + { + TransactItems: [ + { + Update: { + TableName: "mock-table", + Key: { + PK: "ContactInformation#123", + SK: "ContactInformation" + }, + UpdateExpression: + "SET #Email = :Email, #CustomerId = :CustomerId, #UpdatedAt = :UpdatedAt", + // Check that the entity being updated exists + ConditionExpression: "attribute_exists(PK)", + ExpressionAttributeNames: { + "#CustomerId": "CustomerId", + "#Email": "Email", + "#UpdatedAt": "UpdatedAt" + }, + ExpressionAttributeValues: { + ":CustomerId": "456", + ":Email": "new-email@example.com", + ":UpdatedAt": "2023-10-16T03:31:35.918Z" + } + } + }, + { + // Check that the entity being associated with exists + ConditionCheck: { + TableName: "mock-table", + Key: { PK: "Customer#456", SK: "Customer" }, + ConditionExpression: "attribute_exists(PK)" + } + }, + { + Put: { + TableName: "mock-table", + ConditionExpression: "attribute_not_exists(PK)", + Item: { + PK: "Customer#456", + SK: "ContactInformation", + Id: "belongsToLinkId1", + Type: "BelongsToLink", + ForeignEntityType: "ContactInformation", + ForeignKey: "123", + CreatedAt: "2023-10-16T03:31:35.918Z", + UpdatedAt: "2023-10-16T03:31:35.918Z" + } + } + } + ] + } + ] + ]); + } }); - try { - await ContactInformation.update("123", { - email: "new-email@example.com", - customerId: "456" + it("will throw an error if the entity being associated with does not exist", async () => { + expect.assertions(7); + + mockSend.mockReturnValueOnce(undefined).mockImplementationOnce(() => { + mockTransact(); + throw new TransactionCanceledException({ + message: "MockMessage", + CancellationReasons: [ + { Code: "None" }, + { Code: "ConditionalCheckFailed" }, + { Code: "None" } + ], + $metadata: {} + }); }); - } catch (e: any) { - expect(e.constructor.name).toEqual("TransactionWriteFailedError"); - expect(e.errors).toEqual([ - new ConditionalCheckFailedError( - "ConditionalCheckFailed: Customer with ID '456' does not exist" - ) + + try { + await ContactInformation.update("123", { + email: "new-email@example.com", + customerId: "456" + }); + } catch (e: any) { + expect(e.constructor.name).toEqual("TransactionWriteFailedError"); + expect(e.errors).toEqual([ + new ConditionalCheckFailedError( + "ConditionalCheckFailed: Customer with ID '456' does not exist" + ) + ]); + expect(mockSend.mock.calls).toEqual([ + [{ name: "GetCommand" }], + [{ name: "TransactWriteCommand" }] + ]); + expect(mockGet.mock.calls).toEqual([[]]); + expect(mockedGetCommand.mock.calls).toEqual([ + [ + { + TableName: "mock-table", + Key: { + PK: "ContactInformation#123", + SK: "ContactInformation" + }, + ConsistentRead: true + } + ] + ]); + expect(mockTransact.mock.calls).toEqual([[]]); + expect(mockTransactWriteCommand.mock.calls).toEqual([ + [ + { + TransactItems: [ + { + Update: { + TableName: "mock-table", + Key: { + PK: "ContactInformation#123", + SK: "ContactInformation" + }, + UpdateExpression: + "SET #Email = :Email, #CustomerId = :CustomerId, #UpdatedAt = :UpdatedAt", + // Check that the entity being updated exists + ConditionExpression: "attribute_exists(PK)", + ExpressionAttributeNames: { + "#CustomerId": "CustomerId", + "#Email": "Email", + "#UpdatedAt": "UpdatedAt" + }, + ExpressionAttributeValues: { + ":CustomerId": "456", + ":Email": "new-email@example.com", + ":UpdatedAt": "2023-10-16T03:31:35.918Z" + } + } + }, + { + // Check that the entity being associated with exists + ConditionCheck: { + TableName: "mock-table", + Key: { PK: "Customer#456", SK: "Customer" }, + ConditionExpression: "attribute_exists(PK)" + } + }, + { + Put: { + TableName: "mock-table", + ConditionExpression: "attribute_not_exists(PK)", + Item: { + PK: "Customer#456", + SK: "ContactInformation", + Id: "belongsToLinkId1", + Type: "BelongsToLink", + ForeignEntityType: "ContactInformation", + ForeignKey: "123", + CreatedAt: "2023-10-16T03:31:35.918Z", + UpdatedAt: "2023-10-16T03:31:35.918Z" + } + } + } + ] + } + ] + ]); + } + }); + + it("will remove a nullable foreign key", async () => { + expect.assertions(6); + + expect( + // eslint-disable-next-line @typescript-eslint/no-confusing-void-expression + await ContactInformation.update("123", { + email: "new-email@example.com", + customerId: null + }) + ).toBeUndefined(); + expect(mockSend.mock.calls).toEqual([ + [{ name: "GetCommand" }], + [{ name: "TransactWriteCommand" }] + ]); + expect(mockGet.mock.calls).toEqual([[]]); + expect(mockedGetCommand.mock.calls).toEqual([ + [ + { + TableName: "mock-table", + Key: { PK: "ContactInformation#123", SK: "ContactInformation" }, + ConsistentRead: true + } + ] + ]); + expect(mockTransact.mock.calls).toEqual([[]]); + expect(mockTransactWriteCommand.mock.calls).toEqual([ + [ + { + TransactItems: [ + { + Update: { + TableName: "mock-table", + Key: { + PK: "ContactInformation#123", + SK: "ContactInformation" + }, + ConditionExpression: "attribute_exists(PK)", + ExpressionAttributeValues: { + ":Email": "new-email@example.com", + ":UpdatedAt": "2023-10-16T03:31:35.918Z" + }, + ExpressionAttributeNames: { + "#Email": "Email", + "#UpdatedAt": "UpdatedAt", + "#CustomerId": "CustomerId" + }, + UpdateExpression: + "SET #Email = :Email, #UpdatedAt = :UpdatedAt REMOVE #CustomerId" + } + } + ] + } + ] ]); + }); + }); + + describe("when the entity belongs to another another entity (Adds delete transaction for existing BelongsToLink)", () => { + beforeEach(() => { + jest.setSystemTime(new Date("2023-10-16T03:31:35.918Z")); + mockedUuidv4.mockReturnValueOnce("belongsToLinkId1"); + mockGet.mockResolvedValue({ + Item: { + PK: "ContactInformation#123", + SK: "ContactInformation", + Id: "123", + Email: "old-email@example.com", + Phone: "555-555-5555", + CustomerId: "789" // Already belongs to customer + } + }); + }); + + afterEach(() => { + mockedUuidv4.mockReset(); + }); + + it("will update the foreign key and delete the old BelongsToLink if the entity being associated with exists", async () => { + expect.assertions(6); + + expect( + // eslint-disable-next-line @typescript-eslint/no-confusing-void-expression + await ContactInformation.update("123", { + email: "new-email@example.com", + customerId: "456" + }) + ).toBeUndefined(); expect(mockSend.mock.calls).toEqual([ [{ name: "GetCommand" }], [{ name: "TransactWriteCommand" }] @@ -663,6 +844,16 @@ describe("Update", () => { ConditionExpression: "attribute_exists(PK)" } }, + { + // Delete old BelongsToLink + Delete: { + TableName: "mock-table", + Key: { + PK: "Customer#789", + SK: "ContactInformation" + } + } + }, { Put: { TableName: "mock-table", @@ -683,209 +874,367 @@ describe("Update", () => { } ] ]); - } - }); + }); - it("will remove a nullable foreign key", async () => { - expect.assertions(6); + it("will throw an error if the entity being updated does not exist", async () => { + expect.assertions(7); - expect( - // eslint-disable-next-line @typescript-eslint/no-confusing-void-expression - await ContactInformation.update("123", { - email: "new-email@example.com", - customerId: null - }) - ).toBeUndefined(); - expect(mockSend.mock.calls).toEqual([ - [{ name: "GetCommand" }], - [{ name: "TransactWriteCommand" }] - ]); - expect(mockGet.mock.calls).toEqual([[]]); - expect(mockedGetCommand.mock.calls).toEqual([ - [ - { - TableName: "mock-table", - Key: { PK: "ContactInformation#123", SK: "ContactInformation" }, - ConsistentRead: true - } - ] - ]); - expect(mockTransact.mock.calls).toEqual([[]]); - expect(mockTransactWriteCommand.mock.calls).toEqual([ - [ - { - TransactItems: [ + mockGet.mockResolvedValueOnce({}); // Entity does not exist but will fail in transaction + + mockSend.mockReturnValueOnce(undefined).mockImplementationOnce(() => { + mockTransact(); + throw new TransactionCanceledException({ + message: "MockMessage", + CancellationReasons: [ + { Code: "ConditionalCheckFailed" }, + { Code: "None" }, + { Code: "None" }, + { Code: "None" } + ], + $metadata: {} + }); + }); + + try { + await ContactInformation.update("123", { + email: "new-email@example.com", + customerId: "456" + }); + } catch (e: any) { + expect(e.constructor.name).toEqual("TransactionWriteFailedError"); + expect(e.errors).toEqual([ + new ConditionalCheckFailedError( + "ConditionalCheckFailed: ContactInformation with ID '123' does not exist" + ) + ]); + expect(mockSend.mock.calls).toEqual([ + [{ name: "GetCommand" }], + [{ name: "TransactWriteCommand" }] + ]); + expect(mockGet.mock.calls).toEqual([[]]); + expect(mockedGetCommand.mock.calls).toEqual([ + [ { - Update: { - TableName: "mock-table", - Key: { - PK: "ContactInformation#123", - SK: "ContactInformation" - }, - ConditionExpression: "attribute_exists(PK)", - ExpressionAttributeValues: { - ":Email": "new-email@example.com", - ":UpdatedAt": "2023-10-16T03:31:35.918Z" + TableName: "mock-table", + Key: { + PK: "ContactInformation#123", + SK: "ContactInformation" + }, + ConsistentRead: true + } + ] + ]); + expect(mockTransact.mock.calls).toEqual([[]]); + expect(mockTransactWriteCommand.mock.calls).toEqual([ + [ + { + TransactItems: [ + { + Update: { + TableName: "mock-table", + Key: { + PK: "ContactInformation#123", + SK: "ContactInformation" + }, + UpdateExpression: + "SET #Email = :Email, #CustomerId = :CustomerId, #UpdatedAt = :UpdatedAt", + // Check that the entity being updated exists + ConditionExpression: "attribute_exists(PK)", + ExpressionAttributeNames: { + "#CustomerId": "CustomerId", + "#Email": "Email", + "#UpdatedAt": "UpdatedAt" + }, + ExpressionAttributeValues: { + ":CustomerId": "456", + ":Email": "new-email@example.com", + ":UpdatedAt": "2023-10-16T03:31:35.918Z" + } + } }, - ExpressionAttributeNames: { - "#Email": "Email", - "#UpdatedAt": "UpdatedAt", - "#CustomerId": "CustomerId" + { + // Check that the entity being associated with exists + ConditionCheck: { + TableName: "mock-table", + Key: { PK: "Customer#456", SK: "Customer" }, + ConditionExpression: "attribute_exists(PK)" + } }, - UpdateExpression: - "SET #Email = :Email, #UpdatedAt = :UpdatedAt REMOVE #CustomerId" - } + // No Delete transaction because the item does not exist to look up the foreign key to build the delete operation with + { + Put: { + TableName: "mock-table", + ConditionExpression: "attribute_not_exists(PK)", + Item: { + PK: "Customer#456", + SK: "ContactInformation", + Id: "belongsToLinkId1", + Type: "BelongsToLink", + ForeignEntityType: "ContactInformation", + ForeignKey: "123", + CreatedAt: "2023-10-16T03:31:35.918Z", + UpdatedAt: "2023-10-16T03:31:35.918Z" + } + } + } + ] } ] - } - ] - ]); - }); - }); - - describe("when the entity belongs to another another entity (Adds delete transaction for existing BelongsToLink)", () => { - beforeEach(() => { - jest.setSystemTime(new Date("2023-10-16T03:31:35.918Z")); - mockedUuidv4.mockReturnValueOnce("belongsToLinkId1"); - mockGet.mockResolvedValue({ - Item: { - PK: "ContactInformation#123", - SK: "ContactInformation", - Id: "123", - Email: "old-email@example.com", - Phone: "555-555-5555", - CustomerId: "789" // Already belongs to customer + ]); } }); - }); - afterEach(() => { - mockedUuidv4.mockReset(); - }); + it("will throw an error if the associated entity does not exist", async () => { + expect.assertions(7); - it("will update the foreign key and delete the old BelongsToLink if the entity being associated with exists", async () => { - expect.assertions(6); + mockSend.mockReturnValueOnce(undefined).mockImplementationOnce(() => { + mockTransact(); + throw new TransactionCanceledException({ + message: "MockMessage", + CancellationReasons: [ + { Code: "None" }, + { Code: "ConditionalCheckFailed" }, + { Code: "None" }, + { Code: "None" } + ], + $metadata: {} + }); + }); - expect( - // eslint-disable-next-line @typescript-eslint/no-confusing-void-expression - await ContactInformation.update("123", { - email: "new-email@example.com", - customerId: "456" - }) - ).toBeUndefined(); - expect(mockSend.mock.calls).toEqual([ - [{ name: "GetCommand" }], - [{ name: "TransactWriteCommand" }] - ]); - expect(mockGet.mock.calls).toEqual([[]]); - expect(mockedGetCommand.mock.calls).toEqual([ - [ - { - TableName: "mock-table", - Key: { PK: "ContactInformation#123", SK: "ContactInformation" }, - ConsistentRead: true - } - ] - ]); - expect(mockTransact.mock.calls).toEqual([[]]); - expect(mockTransactWriteCommand.mock.calls).toEqual([ - [ - { - TransactItems: [ + try { + await ContactInformation.update("123", { + email: "new-email@example.com", + customerId: "456" + }); + } catch (e: any) { + expect(e.constructor.name).toEqual("TransactionWriteFailedError"); + expect(e.errors).toEqual([ + new ConditionalCheckFailedError( + "ConditionalCheckFailed: Customer with ID '456' does not exist" + ) + ]); + expect(mockSend.mock.calls).toEqual([ + [{ name: "GetCommand" }], + [{ name: "TransactWriteCommand" }] + ]); + expect(mockGet.mock.calls).toEqual([[]]); + expect(mockedGetCommand.mock.calls).toEqual([ + [ { - Update: { - TableName: "mock-table", - Key: { - PK: "ContactInformation#123", - SK: "ContactInformation" + TableName: "mock-table", + Key: { + PK: "ContactInformation#123", + SK: "ContactInformation" + }, + ConsistentRead: true + } + ] + ]); + expect(mockTransact.mock.calls).toEqual([[]]); + expect(mockTransactWriteCommand.mock.calls).toEqual([ + [ + { + TransactItems: [ + { + Update: { + TableName: "mock-table", + Key: { + PK: "ContactInformation#123", + SK: "ContactInformation" + }, + UpdateExpression: + "SET #Email = :Email, #CustomerId = :CustomerId, #UpdatedAt = :UpdatedAt", + // Check that the entity being updated exists + ConditionExpression: "attribute_exists(PK)", + ExpressionAttributeNames: { + "#CustomerId": "CustomerId", + "#Email": "Email", + "#UpdatedAt": "UpdatedAt" + }, + ExpressionAttributeValues: { + ":CustomerId": "456", + ":Email": "new-email@example.com", + ":UpdatedAt": "2023-10-16T03:31:35.918Z" + } + } }, - UpdateExpression: - "SET #Email = :Email, #CustomerId = :CustomerId, #UpdatedAt = :UpdatedAt", - // Check that the entity being updated exists - ConditionExpression: "attribute_exists(PK)", - ExpressionAttributeNames: { - "#CustomerId": "CustomerId", - "#Email": "Email", - "#UpdatedAt": "UpdatedAt" + { + // Check that the entity being associated with exists + ConditionCheck: { + TableName: "mock-table", + Key: { PK: "Customer#456", SK: "Customer" }, + ConditionExpression: "attribute_exists(PK)" + } }, - ExpressionAttributeValues: { - ":CustomerId": "456", - ":Email": "new-email@example.com", - ":UpdatedAt": "2023-10-16T03:31:35.918Z" + { + // Delete old BelongsToLink + Delete: { + TableName: "mock-table", + Key: { + PK: "Customer#789", + SK: "ContactInformation" + } + } + }, + { + Put: { + TableName: "mock-table", + ConditionExpression: "attribute_not_exists(PK)", + Item: { + PK: "Customer#456", + SK: "ContactInformation", + Id: "belongsToLinkId1", + Type: "BelongsToLink", + ForeignEntityType: "ContactInformation", + ForeignKey: "123", + CreatedAt: "2023-10-16T03:31:35.918Z", + UpdatedAt: "2023-10-16T03:31:35.918Z" + } + } } - } - }, - { - // Check that the entity being associated with exists - ConditionCheck: { - TableName: "mock-table", - Key: { PK: "Customer#456", SK: "Customer" }, - ConditionExpression: "attribute_exists(PK)" - } - }, - { - // Delete old BelongsToLink - Delete: { - TableName: "mock-table", - Key: { - PK: "Customer#789", - SK: "ContactInformation" - } - } - }, - { - Put: { - TableName: "mock-table", - ConditionExpression: "attribute_not_exists(PK)", - Item: { - PK: "Customer#456", - SK: "ContactInformation", - Id: "belongsToLinkId1", - Type: "BelongsToLink", - ForeignEntityType: "ContactInformation", - ForeignKey: "123", - CreatedAt: "2023-10-16T03:31:35.918Z", - UpdatedAt: "2023-10-16T03:31:35.918Z" - } - } + ] } ] - } - ] - ]); - }); + ]); + } + }); - it("will throw an error if the entity being updated does not exist", async () => { - expect.assertions(7); + it("will throw an error if the entity is already associated with the requested entity", async () => { + expect.assertions(7); - mockGet.mockResolvedValueOnce({}); // Entity does not exist but will fail in transaction + mockGet.mockResolvedValueOnce({ + Item: { + PK: "ContactInformation#123", + SK: "ContactInformation", + Id: "123", + Email: "old-email@example.com", + Phone: "555-555-5555", + CustomerId: "456" // Already belongs to customer, the same being updated + } + }); - mockSend.mockReturnValueOnce(undefined).mockImplementationOnce(() => { - mockTransact(); - throw new TransactionCanceledException({ - message: "MockMessage", - CancellationReasons: [ - { Code: "ConditionalCheckFailed" }, - { Code: "None" }, - { Code: "None" }, - { Code: "None" } - ], - $metadata: {} + mockSend.mockReturnValueOnce(undefined).mockImplementationOnce(() => { + mockTransact(); + throw new TransactionCanceledException({ + message: "MockMessage", + CancellationReasons: [ + { Code: "None" }, + { Code: "None" }, + { Code: "None" }, + { Code: "ConditionalCheckFailed" } + ], + $metadata: {} + }); }); + + try { + await ContactInformation.update("123", { + email: "new-email@example.com", + customerId: "456" + }); + } catch (e: any) { + expect(e.constructor.name).toEqual("TransactionWriteFailedError"); + expect(e.errors).toEqual([ + new ConditionalCheckFailedError( + "ConditionalCheckFailed: Customer with id: 456 already has an associated ContactInformation" + ) + ]); + expect(mockSend.mock.calls).toEqual([ + [{ name: "GetCommand" }], + [{ name: "TransactWriteCommand" }] + ]); + expect(mockGet.mock.calls).toEqual([[]]); + expect(mockedGetCommand.mock.calls).toEqual([ + [ + { + TableName: "mock-table", + Key: { + PK: "ContactInformation#123", + SK: "ContactInformation" + }, + ConsistentRead: true + } + ] + ]); + expect(mockTransact.mock.calls).toEqual([[]]); + expect(mockTransactWriteCommand.mock.calls).toEqual([ + [ + { + TransactItems: [ + { + Update: { + TableName: "mock-table", + Key: { + PK: "ContactInformation#123", + SK: "ContactInformation" + }, + UpdateExpression: + "SET #Email = :Email, #CustomerId = :CustomerId, #UpdatedAt = :UpdatedAt", + // Check that the entity being updated exists + ConditionExpression: "attribute_exists(PK)", + ExpressionAttributeNames: { + "#CustomerId": "CustomerId", + "#Email": "Email", + "#UpdatedAt": "UpdatedAt" + }, + ExpressionAttributeValues: { + ":CustomerId": "456", + ":Email": "new-email@example.com", + ":UpdatedAt": "2023-10-16T03:31:35.918Z" + } + } + }, + { + // Check that the entity being associated with exists + ConditionCheck: { + TableName: "mock-table", + Key: { PK: "Customer#456", SK: "Customer" }, + ConditionExpression: "attribute_exists(PK)" + } + }, + { + // Delete old BelongsToLink + Delete: { + TableName: "mock-table", + Key: { + PK: "Customer#456", + SK: "ContactInformation" + } + } + }, + { + Put: { + TableName: "mock-table", + ConditionExpression: "attribute_not_exists(PK)", + Item: { + PK: "Customer#456", + SK: "ContactInformation", + Id: "belongsToLinkId1", + Type: "BelongsToLink", + ForeignEntityType: "ContactInformation", + ForeignKey: "123", + CreatedAt: "2023-10-16T03:31:35.918Z", + UpdatedAt: "2023-10-16T03:31:35.918Z" + } + } + } + ] + } + ] + ]); + } }); - try { - await ContactInformation.update("123", { - email: "new-email@example.com", - customerId: "456" - }); - } catch (e: any) { - expect(e.constructor.name).toEqual("TransactionWriteFailedError"); - expect(e.errors).toEqual([ - new ConditionalCheckFailedError( - "ConditionalCheckFailed: ContactInformation with ID '123' does not exist" - ) - ]); + it("will remove a nullable foreign key and delete the BelongsToLinks for the associated entity", async () => { + expect.assertions(6); + + expect( + // eslint-disable-next-line @typescript-eslint/no-confusing-void-expression + await ContactInformation.update("123", { + email: "new-email@example.com", + customerId: null + }) + ).toBeUndefined(); expect(mockSend.mock.calls).toEqual([ [{ name: "GetCommand" }], [{ name: "TransactWriteCommand" }] @@ -912,83 +1261,64 @@ describe("Update", () => { PK: "ContactInformation#123", SK: "ContactInformation" }, - UpdateExpression: - "SET #Email = :Email, #CustomerId = :CustomerId, #UpdatedAt = :UpdatedAt", - // Check that the entity being updated exists ConditionExpression: "attribute_exists(PK)", - ExpressionAttributeNames: { - "#CustomerId": "CustomerId", - "#Email": "Email", - "#UpdatedAt": "UpdatedAt" - }, ExpressionAttributeValues: { - ":CustomerId": "456", ":Email": "new-email@example.com", ":UpdatedAt": "2023-10-16T03:31:35.918Z" - } - } - }, - { - // Check that the entity being associated with exists - ConditionCheck: { - TableName: "mock-table", - Key: { PK: "Customer#456", SK: "Customer" }, - ConditionExpression: "attribute_exists(PK)" + }, + ExpressionAttributeNames: { + "#Email": "Email", + "#UpdatedAt": "UpdatedAt", + "#CustomerId": "CustomerId" + }, + UpdateExpression: + "SET #Email = :Email, #UpdatedAt = :UpdatedAt REMOVE #CustomerId" } }, - // No Delete transaction because the item does not exist to look up the foreign key to build the delete operation with { - Put: { + Delete: { TableName: "mock-table", - ConditionExpression: "attribute_not_exists(PK)", - Item: { - PK: "Customer#456", - SK: "ContactInformation", - Id: "belongsToLinkId1", - Type: "BelongsToLink", - ForeignEntityType: "ContactInformation", - ForeignKey: "123", - CreatedAt: "2023-10-16T03:31:35.918Z", - UpdatedAt: "2023-10-16T03:31:35.918Z" - } + Key: { PK: "Customer#789", SK: "ContactInformation" } } } ] } ] ]); - } + }); }); + }); - it("will throw an error if the associated entity does not exist", async () => { - expect.assertions(7); - - mockSend.mockReturnValueOnce(undefined).mockImplementationOnce(() => { - mockTransact(); - throw new TransactionCanceledException({ - message: "MockMessage", - CancellationReasons: [ - { Code: "None" }, - { Code: "ConditionalCheckFailed" }, - { Code: "None" }, - { Code: "None" } - ], - $metadata: {} + describe("ForeignKey is updated for entity which BelongsTo an entity who HasMany of it", () => { + describe("when the entity does not already belong to another entity", () => { + beforeEach(() => { + jest.setSystemTime(new Date("2023-10-16T03:31:35.918Z")); + mockedUuidv4.mockReturnValueOnce("belongsToLinkId1"); + mockGet.mockResolvedValue({ + Item: { + PK: "PaymentMethod#123", + SK: "PaymentMethod", + Id: "123", + lastFour: "1234", + CustomerId: undefined // Does not already belong to customer + } }); }); - try { - await ContactInformation.update("123", { - email: "new-email@example.com", - customerId: "456" - }); - } catch (e: any) { - expect(e.constructor.name).toEqual("TransactionWriteFailedError"); - expect(e.errors).toEqual([ - new ConditionalCheckFailedError( - "ConditionalCheckFailed: Customer with ID '456' does not exist" - ) - ]); + afterEach(() => { + mockedUuidv4.mockReset(); + }); + + it("will update the foreign key if the entity being associated with exists", async () => { + expect.assertions(6); + + expect( + // eslint-disable-next-line @typescript-eslint/no-confusing-void-expression + await PaymentMethod.update("123", { + lastFour: "5678", + customerId: "456" + }) + ).toBeUndefined(); expect(mockSend.mock.calls).toEqual([ [{ name: "GetCommand" }], [{ name: "TransactWriteCommand" }] @@ -998,7 +1328,7 @@ describe("Update", () => { [ { TableName: "mock-table", - Key: { PK: "ContactInformation#123", SK: "ContactInformation" }, + Key: { PK: "PaymentMethod#123", SK: "PaymentMethod" }, ConsistentRead: true } ] @@ -1011,54 +1341,39 @@ describe("Update", () => { { Update: { TableName: "mock-table", - Key: { - PK: "ContactInformation#123", - SK: "ContactInformation" - }, + Key: { PK: "PaymentMethod#123", SK: "PaymentMethod" }, UpdateExpression: - "SET #Email = :Email, #CustomerId = :CustomerId, #UpdatedAt = :UpdatedAt", - // Check that the entity being updated exists + "SET #LastFour = :LastFour, #CustomerId = :CustomerId, #UpdatedAt = :UpdatedAt", ConditionExpression: "attribute_exists(PK)", ExpressionAttributeNames: { "#CustomerId": "CustomerId", - "#Email": "Email", + "#LastFour": "LastFour", "#UpdatedAt": "UpdatedAt" }, ExpressionAttributeValues: { ":CustomerId": "456", - ":Email": "new-email@example.com", + ":LastFour": "5678", ":UpdatedAt": "2023-10-16T03:31:35.918Z" } } }, { - // Check that the entity being associated with exists ConditionCheck: { TableName: "mock-table", Key: { PK: "Customer#456", SK: "Customer" }, ConditionExpression: "attribute_exists(PK)" } }, - { - // Delete old BelongsToLink - Delete: { - TableName: "mock-table", - Key: { - PK: "Customer#789", - SK: "ContactInformation" - } - } - }, { Put: { TableName: "mock-table", ConditionExpression: "attribute_not_exists(PK)", Item: { PK: "Customer#456", - SK: "ContactInformation", + SK: "PaymentMethod#123", Id: "belongsToLinkId1", Type: "BelongsToLink", - ForeignEntityType: "ContactInformation", + ForeignEntityType: "PaymentMethod", ForeignKey: "123", CreatedAt: "2023-10-16T03:31:35.918Z", UpdatedAt: "2023-10-16T03:31:35.918Z" @@ -1069,49 +1384,221 @@ describe("Update", () => { } ] ]); - } - }); + }); - it("will throw an error if the entity is already associated with the requested entity", async () => { - expect.assertions(7); + it("will throw an error if the entity being updated does not exist", async () => { + expect.assertions(7); - mockGet.mockResolvedValueOnce({ - Item: { - PK: "ContactInformation#123", - SK: "ContactInformation", - Id: "123", - Email: "old-email@example.com", - Phone: "555-555-5555", - CustomerId: "456" // Already belongs to customer, the same being updated + mockGet.mockResolvedValueOnce({}); // Entity does not exist but will fail in transaction + mockSend.mockReturnValueOnce(undefined).mockImplementationOnce(() => { + mockTransact(); + throw new TransactionCanceledException({ + message: "MockMessage", + CancellationReasons: [ + { Code: "ConditionalCheckFailed" }, + { Code: "None" }, + { Code: "None" } + ], + $metadata: {} + }); + }); + + try { + await PaymentMethod.update("123", { + lastFour: "5678", + customerId: "456" + }); + } catch (e: any) { + expect(e.constructor.name).toEqual("TransactionWriteFailedError"); + expect(e.errors).toEqual([ + new ConditionalCheckFailedError( + "ConditionalCheckFailed: PaymentMethod with ID '123' does not exist" + ) + ]); + expect(mockSend.mock.calls).toEqual([ + [{ name: "GetCommand" }], + [{ name: "TransactWriteCommand" }] + ]); + expect(mockGet.mock.calls).toEqual([[]]); + expect(mockedGetCommand.mock.calls).toEqual([ + [ + { + TableName: "mock-table", + Key: { PK: "PaymentMethod#123", SK: "PaymentMethod" }, + ConsistentRead: true + } + ] + ]); + expect(mockTransact.mock.calls).toEqual([[]]); + expect(mockTransactWriteCommand.mock.calls).toEqual([ + [ + { + TransactItems: [ + { + Update: { + TableName: "mock-table", + Key: { PK: "PaymentMethod#123", SK: "PaymentMethod" }, + UpdateExpression: + "SET #LastFour = :LastFour, #CustomerId = :CustomerId, #UpdatedAt = :UpdatedAt", + ConditionExpression: "attribute_exists(PK)", + ExpressionAttributeNames: { + "#CustomerId": "CustomerId", + "#LastFour": "LastFour", + "#UpdatedAt": "UpdatedAt" + }, + ExpressionAttributeValues: { + ":CustomerId": "456", + ":LastFour": "5678", + ":UpdatedAt": "2023-10-16T03:31:35.918Z" + } + } + }, + { + ConditionCheck: { + TableName: "mock-table", + Key: { PK: "Customer#456", SK: "Customer" }, + ConditionExpression: "attribute_exists(PK)" + } + }, + { + Put: { + TableName: "mock-table", + ConditionExpression: "attribute_not_exists(PK)", + Item: { + PK: "Customer#456", + SK: "PaymentMethod#123", + Id: "belongsToLinkId1", + Type: "BelongsToLink", + ForeignEntityType: "PaymentMethod", + ForeignKey: "123", + CreatedAt: "2023-10-16T03:31:35.918Z", + UpdatedAt: "2023-10-16T03:31:35.918Z" + } + } + } + ] + } + ] + ]); } }); - mockSend.mockReturnValueOnce(undefined).mockImplementationOnce(() => { - mockTransact(); - throw new TransactionCanceledException({ - message: "MockMessage", - CancellationReasons: [ - { Code: "None" }, - { Code: "None" }, - { Code: "None" }, - { Code: "ConditionalCheckFailed" } - ], - $metadata: {} + it("will throw an error if the entity being associated with does not exist", async () => { + expect.assertions(7); + + mockSend.mockReturnValueOnce(undefined).mockImplementationOnce(() => { + mockTransact(); + throw new TransactionCanceledException({ + message: "MockMessage", + CancellationReasons: [ + { Code: "None" }, + { Code: "ConditionalCheckFailed" }, + { Code: "None" } + ], + $metadata: {} + }); }); + + try { + await PaymentMethod.update("123", { + lastFour: "5678", + customerId: "456" + }); + } catch (e: any) { + expect(e.constructor.name).toEqual("TransactionWriteFailedError"); + expect(e.errors).toEqual([ + new ConditionalCheckFailedError( + "ConditionalCheckFailed: Customer with ID '456' does not exist" + ) + ]); + expect(mockSend.mock.calls).toEqual([ + [{ name: "GetCommand" }], + [{ name: "TransactWriteCommand" }] + ]); + expect(mockGet.mock.calls).toEqual([[]]); + expect(mockedGetCommand.mock.calls).toEqual([ + [ + { + TableName: "mock-table", + Key: { PK: "PaymentMethod#123", SK: "PaymentMethod" }, + ConsistentRead: true + } + ] + ]); + expect(mockTransact.mock.calls).toEqual([[]]); + expect(mockTransactWriteCommand.mock.calls).toEqual([ + [ + { + TransactItems: [ + { + Update: { + TableName: "mock-table", + Key: { PK: "PaymentMethod#123", SK: "PaymentMethod" }, + UpdateExpression: + "SET #LastFour = :LastFour, #CustomerId = :CustomerId, #UpdatedAt = :UpdatedAt", + ConditionExpression: "attribute_exists(PK)", + ExpressionAttributeNames: { + "#CustomerId": "CustomerId", + "#LastFour": "LastFour", + "#UpdatedAt": "UpdatedAt" + }, + ExpressionAttributeValues: { + ":CustomerId": "456", + ":LastFour": "5678", + ":UpdatedAt": "2023-10-16T03:31:35.918Z" + } + } + }, + { + ConditionCheck: { + TableName: "mock-table", + Key: { PK: "Customer#456", SK: "Customer" }, + ConditionExpression: "attribute_exists(PK)" + } + }, + { + Put: { + TableName: "mock-table", + ConditionExpression: "attribute_not_exists(PK)", + Item: { + PK: "Customer#456", + SK: "PaymentMethod#123", + Id: "belongsToLinkId1", + Type: "BelongsToLink", + ForeignEntityType: "PaymentMethod", + ForeignKey: "123", + CreatedAt: "2023-10-16T03:31:35.918Z", + UpdatedAt: "2023-10-16T03:31:35.918Z" + } + } + } + ] + } + ] + ]); + } }); - try { - await ContactInformation.update("123", { - email: "new-email@example.com", - customerId: "456" + it("will remove a nullable foreign key", async () => { + expect.assertions(6); + + mockGet.mockResolvedValueOnce({ + Item: { + PK: "Pet#123", + SK: "Pet", + Id: "123", + name: "Fido", + OwnerId: undefined // Does not already belong an owner + } }); - } catch (e: any) { - expect(e.constructor.name).toEqual("TransactionWriteFailedError"); - expect(e.errors).toEqual([ - new ConditionalCheckFailedError( - "ConditionalCheckFailed: Customer with id: 456 already has an associated ContactInformation" - ) - ]); + + expect( + // eslint-disable-next-line @typescript-eslint/no-confusing-void-expression + await Pet.update("123", { + name: "New Name", + ownerId: null + }) + ).toBeUndefined(); expect(mockSend.mock.calls).toEqual([ [{ name: "GetCommand" }], [{ name: "TransactWriteCommand" }] @@ -1121,7 +1608,7 @@ describe("Update", () => { [ { TableName: "mock-table", - Key: { PK: "ContactInformation#123", SK: "ContactInformation" }, + Key: { PK: "Pet#123", SK: "Pet" }, ConsistentRead: true } ] @@ -1134,28 +1621,96 @@ describe("Update", () => { { Update: { TableName: "mock-table", - Key: { - PK: "ContactInformation#123", - SK: "ContactInformation" + Key: { PK: "Pet#123", SK: "Pet" }, + ConditionExpression: "attribute_exists(PK)", + ExpressionAttributeNames: { + "#Name": "Name", + "#OwnerId": "OwnerId", + "#UpdatedAt": "UpdatedAt" + }, + ExpressionAttributeValues: { + ":Name": "New Name", + ":UpdatedAt": "2023-10-16T03:31:35.918Z" }, UpdateExpression: - "SET #Email = :Email, #CustomerId = :CustomerId, #UpdatedAt = :UpdatedAt", - // Check that the entity being updated exists + "SET #Name = :Name, #UpdatedAt = :UpdatedAt REMOVE #OwnerId" + } + } + ] + } + ] + ]); + }); + }); + + describe("when the entity belongs to another another entity (Adds delete transaction for existing BelongsToLink)", () => { + beforeEach(() => { + jest.setSystemTime(new Date("2023-10-16T03:31:35.918Z")); + mockedUuidv4.mockReturnValueOnce("belongsToLinkId1"); + mockGet.mockResolvedValue({ + Item: { + PK: "PaymentMethod#123", + SK: "PaymentMethod", + Id: "123", + lastFour: "1234", + CustomerId: "789" // Already belongs to customer + } + }); + }); + + afterEach(() => { + mockedUuidv4.mockReset(); + }); + + it("will update the foreign key if the entity being associated with exists", async () => { + expect.assertions(6); + + expect( + // eslint-disable-next-line @typescript-eslint/no-confusing-void-expression + await PaymentMethod.update("123", { + lastFour: "5678", + customerId: "456" + }) + ).toBeUndefined(); + expect(mockSend.mock.calls).toEqual([ + [{ name: "GetCommand" }], + [{ name: "TransactWriteCommand" }] + ]); + expect(mockGet.mock.calls).toEqual([[]]); + expect(mockedGetCommand.mock.calls).toEqual([ + [ + { + TableName: "mock-table", + Key: { PK: "PaymentMethod#123", SK: "PaymentMethod" }, + ConsistentRead: true + } + ] + ]); + expect(mockTransact.mock.calls).toEqual([[]]); + expect(mockTransactWriteCommand.mock.calls).toEqual([ + [ + { + TransactItems: [ + { + Update: { + TableName: "mock-table", + Key: { PK: "PaymentMethod#123", SK: "PaymentMethod" }, + UpdateExpression: + "SET #LastFour = :LastFour, #CustomerId = :CustomerId, #UpdatedAt = :UpdatedAt", ConditionExpression: "attribute_exists(PK)", ExpressionAttributeNames: { "#CustomerId": "CustomerId", - "#Email": "Email", + "#LastFour": "LastFour", "#UpdatedAt": "UpdatedAt" }, ExpressionAttributeValues: { ":CustomerId": "456", - ":Email": "new-email@example.com", + ":LastFour": "5678", ":UpdatedAt": "2023-10-16T03:31:35.918Z" } } }, { - // Check that the entity being associated with exists ConditionCheck: { TableName: "mock-table", Key: { PK: "Customer#456", SK: "Customer" }, @@ -1167,8 +1722,8 @@ describe("Update", () => { Delete: { TableName: "mock-table", Key: { - PK: "Customer#456", - SK: "ContactInformation" + PK: "Customer#789", + SK: "PaymentMethod#123" } } }, @@ -1178,10 +1733,10 @@ describe("Update", () => { ConditionExpression: "attribute_not_exists(PK)", Item: { PK: "Customer#456", - SK: "ContactInformation", + SK: "PaymentMethod#123", Id: "belongsToLinkId1", Type: "BelongsToLink", - ForeignEntityType: "ContactInformation", + ForeignEntityType: "PaymentMethod", ForeignKey: "123", CreatedAt: "2023-10-16T03:31:35.918Z", UpdatedAt: "2023-10-16T03:31:35.918Z" @@ -1192,101 +1747,462 @@ describe("Update", () => { } ] ]); - } - }); + }); - it("will remove a nullable foreign key and delete the BelongsToLinks for the associated entity", async () => { - expect.assertions(6); + it("will throw an error if the entity being updated does not exist", async () => { + expect.assertions(7); - expect( - // eslint-disable-next-line @typescript-eslint/no-confusing-void-expression - await ContactInformation.update("123", { - email: "new-email@example.com", - customerId: null - }) - ).toBeUndefined(); - expect(mockSend.mock.calls).toEqual([ - [{ name: "GetCommand" }], - [{ name: "TransactWriteCommand" }] - ]); - expect(mockGet.mock.calls).toEqual([[]]); - expect(mockedGetCommand.mock.calls).toEqual([ - [ - { - TableName: "mock-table", - Key: { PK: "ContactInformation#123", SK: "ContactInformation" }, - ConsistentRead: true - } - ] - ]); - expect(mockTransact.mock.calls).toEqual([[]]); - expect(mockTransactWriteCommand.mock.calls).toEqual([ - [ - { - TransactItems: [ + mockGet.mockResolvedValueOnce({}); // Entity does not exist but will fail in transaction + + mockSend.mockReturnValueOnce(undefined).mockImplementationOnce(() => { + mockTransact(); + throw new TransactionCanceledException({ + message: "MockMessage", + CancellationReasons: [ + { Code: "ConditionalCheckFailed" }, + { Code: "None" }, + { Code: "None" }, + { Code: "None" } + ], + $metadata: {} + }); + }); + + try { + await PaymentMethod.update("123", { + lastFour: "5678", + customerId: "456" + }); + } catch (e: any) { + expect(e.constructor.name).toEqual("TransactionWriteFailedError"); + expect(e.errors).toEqual([ + new ConditionalCheckFailedError( + "ConditionalCheckFailed: PaymentMethod with ID '123' does not exist" + ) + ]); + expect(mockSend.mock.calls).toEqual([ + [{ name: "GetCommand" }], + [{ name: "TransactWriteCommand" }] + ]); + expect(mockGet.mock.calls).toEqual([[]]); + expect(mockedGetCommand.mock.calls).toEqual([ + [ { - Update: { - TableName: "mock-table", - Key: { - PK: "ContactInformation#123", - SK: "ContactInformation" + TableName: "mock-table", + Key: { PK: "PaymentMethod#123", SK: "PaymentMethod" }, + ConsistentRead: true + } + ] + ]); + expect(mockTransact.mock.calls).toEqual([[]]); + expect(mockTransactWriteCommand.mock.calls).toEqual([ + [ + { + TransactItems: [ + { + Update: { + TableName: "mock-table", + Key: { PK: "PaymentMethod#123", SK: "PaymentMethod" }, + UpdateExpression: + "SET #LastFour = :LastFour, #CustomerId = :CustomerId, #UpdatedAt = :UpdatedAt", + ConditionExpression: "attribute_exists(PK)", + ExpressionAttributeNames: { + "#CustomerId": "CustomerId", + "#LastFour": "LastFour", + "#UpdatedAt": "UpdatedAt" + }, + ExpressionAttributeValues: { + ":CustomerId": "456", + ":LastFour": "5678", + ":UpdatedAt": "2023-10-16T03:31:35.918Z" + } + } }, - ConditionExpression: "attribute_exists(PK)", - ExpressionAttributeValues: { - ":Email": "new-email@example.com", - ":UpdatedAt": "2023-10-16T03:31:35.918Z" + { + ConditionCheck: { + TableName: "mock-table", + Key: { PK: "Customer#456", SK: "Customer" }, + ConditionExpression: "attribute_exists(PK)" + } }, - ExpressionAttributeNames: { - "#Email": "Email", - "#UpdatedAt": "UpdatedAt", - "#CustomerId": "CustomerId" + // No Delete transaction because the item does not exist to look up the foreign key to build the delete operation with + { + Put: { + TableName: "mock-table", + ConditionExpression: "attribute_not_exists(PK)", + Item: { + PK: "Customer#456", + SK: "PaymentMethod#123", + Id: "belongsToLinkId1", + Type: "BelongsToLink", + ForeignEntityType: "PaymentMethod", + ForeignKey: "123", + CreatedAt: "2023-10-16T03:31:35.918Z", + UpdatedAt: "2023-10-16T03:31:35.918Z" + } + } + } + ] + } + ] + ]); + } + }); + + it("will throw an error if the entity being associated with does not exist", async () => { + expect.assertions(7); + + mockSend.mockReturnValueOnce(undefined).mockImplementationOnce(() => { + mockTransact(); + throw new TransactionCanceledException({ + message: "MockMessage", + CancellationReasons: [ + { Code: "None" }, + { Code: "ConditionalCheckFailed" }, + { Code: "None" }, + { Code: "None" } + ], + $metadata: {} + }); + }); + + try { + await PaymentMethod.update("123", { + lastFour: "5678", + customerId: "456" + }); + } catch (e: any) { + expect(e.constructor.name).toEqual("TransactionWriteFailedError"); + expect(e.errors).toEqual([ + new ConditionalCheckFailedError( + "ConditionalCheckFailed: Customer with ID '456' does not exist" + ) + ]); + expect(mockSend.mock.calls).toEqual([ + [{ name: "GetCommand" }], + [{ name: "TransactWriteCommand" }] + ]); + expect(mockGet.mock.calls).toEqual([[]]); + expect(mockedGetCommand.mock.calls).toEqual([ + [ + { + TableName: "mock-table", + Key: { PK: "PaymentMethod#123", SK: "PaymentMethod" }, + ConsistentRead: true + } + ] + ]); + expect(mockTransact.mock.calls).toEqual([[]]); + expect(mockTransactWriteCommand.mock.calls).toEqual([ + [ + { + TransactItems: [ + { + Update: { + TableName: "mock-table", + Key: { PK: "PaymentMethod#123", SK: "PaymentMethod" }, + UpdateExpression: + "SET #LastFour = :LastFour, #CustomerId = :CustomerId, #UpdatedAt = :UpdatedAt", + ConditionExpression: "attribute_exists(PK)", + ExpressionAttributeNames: { + "#CustomerId": "CustomerId", + "#LastFour": "LastFour", + "#UpdatedAt": "UpdatedAt" + }, + ExpressionAttributeValues: { + ":CustomerId": "456", + ":LastFour": "5678", + ":UpdatedAt": "2023-10-16T03:31:35.918Z" + } + } }, - UpdateExpression: - "SET #Email = :Email, #UpdatedAt = :UpdatedAt REMOVE #CustomerId" - } - }, + { + ConditionCheck: { + TableName: "mock-table", + Key: { PK: "Customer#456", SK: "Customer" }, + ConditionExpression: "attribute_exists(PK)" + } + }, + { + // Delete old BelongsToLink + Delete: { + TableName: "mock-table", + Key: { + PK: "Customer#789", + SK: "PaymentMethod#123" + } + } + }, + { + Put: { + TableName: "mock-table", + ConditionExpression: "attribute_not_exists(PK)", + Item: { + PK: "Customer#456", + SK: "PaymentMethod#123", + Id: "belongsToLinkId1", + Type: "BelongsToLink", + ForeignEntityType: "PaymentMethod", + ForeignKey: "123", + CreatedAt: "2023-10-16T03:31:35.918Z", + UpdatedAt: "2023-10-16T03:31:35.918Z" + } + } + } + ] + } + ] + ]); + } + }); + + it("will throw an error if the entity is already associated with the requested entity", async () => { + expect.assertions(7); + + mockGet.mockResolvedValueOnce({ + Item: { + PK: "PaymentMethod#123", + SK: "PaymentMethod", + Id: "123", + lastFour: "1234", + CustomerId: "456" // Already belongs to customer, the same being updated + } + }); + + mockSend.mockReturnValueOnce(undefined).mockImplementationOnce(() => { + mockTransact(); + throw new TransactionCanceledException({ + message: "MockMessage", + CancellationReasons: [ + { Code: "None" }, + { Code: "None" }, + { Code: "None" }, + { Code: "ConditionalCheckFailed" } + ], + $metadata: {} + }); + }); + + try { + await PaymentMethod.update("123", { + lastFour: "5678", + customerId: "456" + }); + } catch (e: any) { + expect(e.constructor.name).toEqual("TransactionWriteFailedError"); + expect(e.errors).toEqual([ + new ConditionalCheckFailedError( + "ConditionalCheckFailed: PaymentMethod with ID '123' already belongs to Customer with Id '456'" + ) + ]); + expect(mockSend.mock.calls).toEqual([ + [{ name: "GetCommand" }], + [{ name: "TransactWriteCommand" }] + ]); + expect(mockGet.mock.calls).toEqual([[]]); + expect(mockedGetCommand.mock.calls).toEqual([ + [ { - Delete: { - TableName: "mock-table", - Key: { PK: "Customer#789", SK: "ContactInformation" } - } + TableName: "mock-table", + Key: { PK: "PaymentMethod#123", SK: "PaymentMethod" }, + ConsistentRead: true + } + ] + ]); + expect(mockTransact.mock.calls).toEqual([[]]); + expect(mockTransactWriteCommand.mock.calls).toEqual([ + [ + { + TransactItems: [ + { + Update: { + TableName: "mock-table", + Key: { PK: "PaymentMethod#123", SK: "PaymentMethod" }, + UpdateExpression: + "SET #LastFour = :LastFour, #CustomerId = :CustomerId, #UpdatedAt = :UpdatedAt", + ConditionExpression: "attribute_exists(PK)", + ExpressionAttributeNames: { + "#CustomerId": "CustomerId", + "#LastFour": "LastFour", + "#UpdatedAt": "UpdatedAt" + }, + ExpressionAttributeValues: { + ":CustomerId": "456", + ":LastFour": "5678", + ":UpdatedAt": "2023-10-16T03:31:35.918Z" + } + } + }, + { + ConditionCheck: { + TableName: "mock-table", + Key: { PK: "Customer#456", SK: "Customer" }, + ConditionExpression: "attribute_exists(PK)" + } + }, + { + // Delete old BelongsToLink + Delete: { + TableName: "mock-table", + Key: { + PK: "Customer#456", + SK: "PaymentMethod#123" + } + } + }, + { + Put: { + TableName: "mock-table", + ConditionExpression: "attribute_not_exists(PK)", + Item: { + PK: "Customer#456", + SK: "PaymentMethod#123", + Id: "belongsToLinkId1", + Type: "BelongsToLink", + ForeignEntityType: "PaymentMethod", + ForeignKey: "123", + CreatedAt: "2023-10-16T03:31:35.918Z", + UpdatedAt: "2023-10-16T03:31:35.918Z" + } + } + } + ] } ] + ]); + } + }); + + it("will remove a nullable foreign key and delete the associated BelongsToLinks", async () => { + expect.assertions(6); + + mockGet.mockResolvedValueOnce({ + Item: { + PK: "Pet#123", + SK: "Pet", + Id: "123", + name: "Fido", + OwnerId: "456" // Does not already belong an owner } - ] - ]); + }); + + expect( + // eslint-disable-next-line @typescript-eslint/no-confusing-void-expression + await Pet.update("123", { + name: "New Name", + ownerId: null + }) + ).toBeUndefined(); + expect(mockSend.mock.calls).toEqual([ + [{ name: "GetCommand" }], + [{ name: "TransactWriteCommand" }] + ]); + expect(mockGet.mock.calls).toEqual([[]]); + expect(mockedGetCommand.mock.calls).toEqual([ + [ + { + TableName: "mock-table", + Key: { PK: "Pet#123", SK: "Pet" }, + ConsistentRead: true + } + ] + ]); + expect(mockTransact.mock.calls).toEqual([[]]); + expect(mockTransactWriteCommand.mock.calls).toEqual([ + [ + { + TransactItems: [ + { + Update: { + TableName: "mock-table", + Key: { PK: "Pet#123", SK: "Pet" }, + ConditionExpression: "attribute_exists(PK)", + ExpressionAttributeNames: { + "#Name": "Name", + "#OwnerId": "OwnerId", + "#UpdatedAt": "UpdatedAt" + }, + ExpressionAttributeValues: { + ":Name": "New Name", + ":UpdatedAt": "2023-10-16T03:31:35.918Z" + }, + UpdateExpression: + "SET #Name = :Name, #UpdatedAt = :UpdatedAt REMOVE #OwnerId" + } + }, + { + Delete: { + TableName: "mock-table", + Key: { PK: "Person#456", SK: "Pet#123" } + } + } + ] + } + ] + ]); + }); }); }); - }); - describe("ForeignKey is updated for entity which BelongsTo an entity who HasMany of it", () => { - describe("when the entity does not already belong to another entity", () => { + describe("A model is updating multiple ForeignKeys of different relationship types", () => { + @Entity + class Model1 extends MockTable { + @HasOne(() => Model3, { foreignKey: "model1Id" }) + public model3: Model3; + } + + @Entity + class Model2 extends MockTable { + @HasMany(() => Model3, { foreignKey: "model2Id" }) + public model3: Model3[]; + } + + @Entity + class Model3 extends MockTable { + @StringAttribute({ alias: "Name" }) + public name: string; + + @ForeignKeyAttribute({ alias: "Model1Id" }) + public model1Id: ForeignKey; + + @ForeignKeyAttribute({ alias: "Model2Id" }) + public model2Id: ForeignKey; + + @BelongsTo(() => Model1, { foreignKey: "model1Id" }) + public model1: Model1; + + @BelongsTo(() => Model2, { foreignKey: "model2Id" }) + public model2: Model2; + } + beforeEach(() => { jest.setSystemTime(new Date("2023-10-16T03:31:35.918Z")); - mockedUuidv4.mockReturnValueOnce("belongsToLinkId1"); + mockedUuidv4 + .mockReturnValueOnce("belongsToLinkId1") + .mockReturnValueOnce("belongsToLinkId2"); + }); + + it("can update foreign keys for an entity that includes both HasMany and Belongs to relationships", async () => { + expect.assertions(6); + mockGet.mockResolvedValue({ Item: { - PK: "PaymentMethod#123", - SK: "PaymentMethod", + PK: "Model3#123", + SK: "Model3", Id: "123", - lastFour: "1234", - CustomerId: undefined // Does not already belong to customer + Name: "originalName", + Phone: "555-555-5555", + Model1Id: undefined, + Model2Id: undefined } }); - }); - - afterEach(() => { - mockedUuidv4.mockReset(); - }); - - it("will update the foreign key if the entity being associated with exists", async () => { - expect.assertions(6); expect( // eslint-disable-next-line @typescript-eslint/no-confusing-void-expression - await PaymentMethod.update("123", { - lastFour: "5678", - customerId: "456" + await Model3.update("123", { + name: "newName", + model1Id: "model1-ID", + model2Id: "model2-ID" }) ).toBeUndefined(); expect(mockSend.mock.calls).toEqual([ @@ -1298,7 +2214,7 @@ describe("Update", () => { [ { TableName: "mock-table", - Key: { PK: "PaymentMethod#123", SK: "PaymentMethod" }, + Key: { PK: "Model3#123", SK: "Model3" }, ConsistentRead: true } ] @@ -1311,18 +2227,20 @@ describe("Update", () => { { Update: { TableName: "mock-table", - Key: { PK: "PaymentMethod#123", SK: "PaymentMethod" }, + Key: { PK: "Model3#123", SK: "Model3" }, UpdateExpression: - "SET #LastFour = :LastFour, #CustomerId = :CustomerId, #UpdatedAt = :UpdatedAt", + "SET #Name = :Name, #Model1Id = :Model1Id, #Model2Id = :Model2Id, #UpdatedAt = :UpdatedAt", ConditionExpression: "attribute_exists(PK)", ExpressionAttributeNames: { - "#CustomerId": "CustomerId", - "#LastFour": "LastFour", + "#Model1Id": "Model1Id", + "#Model2Id": "Model2Id", + "#Name": "Name", "#UpdatedAt": "UpdatedAt" }, ExpressionAttributeValues: { - ":CustomerId": "456", - ":LastFour": "5678", + ":Model1Id": "model1-ID", + ":Model2Id": "model2-ID", + ":Name": "newName", ":UpdatedAt": "2023-10-16T03:31:35.918Z" } } @@ -1330,7 +2248,7 @@ describe("Update", () => { { ConditionCheck: { TableName: "mock-table", - Key: { PK: "Customer#456", SK: "Customer" }, + Key: { PK: "Model1#model1-ID", SK: "Model1" }, ConditionExpression: "attribute_exists(PK)" } }, @@ -1339,11 +2257,34 @@ describe("Update", () => { TableName: "mock-table", ConditionExpression: "attribute_not_exists(PK)", Item: { - PK: "Customer#456", - SK: "PaymentMethod#123", + PK: "Model1#model1-ID", + SK: "Model3", Id: "belongsToLinkId1", Type: "BelongsToLink", - ForeignEntityType: "PaymentMethod", + ForeignEntityType: "Model3", + ForeignKey: "123", + CreatedAt: "2023-10-16T03:31:35.918Z", + UpdatedAt: "2023-10-16T03:31:35.918Z" + } + } + }, + { + ConditionCheck: { + TableName: "mock-table", + Key: { PK: "Model2#model2-ID", SK: "Model2" }, + ConditionExpression: "attribute_exists(PK)" + } + }, + { + Put: { + TableName: "mock-table", + ConditionExpression: "attribute_not_exists(PK)", + Item: { + PK: "Model2#model2-ID", + SK: "Model3#123", + Id: "belongsToLinkId2", + Type: "BelongsToLink", + ForeignEntityType: "Model3", ForeignKey: "123", CreatedAt: "2023-10-16T03:31:35.918Z", UpdatedAt: "2023-10-16T03:31:35.918Z" @@ -1356,90 +2297,1291 @@ describe("Update", () => { ]); }); - it("will throw an error if the entity being updated does not exist", async () => { - expect.assertions(7); + it("alternate table (different alias/keys) - can update foreign keys for an entity that includes both HasMany and Belongs to relationships", async () => { + expect.assertions(6); - mockGet.mockResolvedValueOnce({}); // Entity does not exist but will fail in transaction - mockSend.mockReturnValueOnce(undefined).mockImplementationOnce(() => { - mockTransact(); - throw new TransactionCanceledException({ - message: "MockMessage", - CancellationReasons: [ - { Code: "ConditionalCheckFailed" }, - { Code: "None" }, - { Code: "None" } - ], - $metadata: {} - }); + mockGet.mockResolvedValueOnce({ + Item: { + myPk: "Grade|123", + mySk: "Grade", + id: "123", + type: "Grade", + gradeValue: "A+", + assignmentId: "456", + studentId: "789", + createdAt: "2023-10-16T03:31:35.918Z", + updatedAt: "2023-10-16T03:31:35.918Z" + } }); - try { - await PaymentMethod.update("123", { - lastFour: "5678", - customerId: "456" - }); - } catch (e: any) { - expect(e.constructor.name).toEqual("TransactionWriteFailedError"); - expect(e.errors).toEqual([ - new ConditionalCheckFailedError( - "ConditionalCheckFailed: PaymentMethod with ID '123' does not exist" - ) - ]); - expect(mockSend.mock.calls).toEqual([ - [{ name: "GetCommand" }], - [{ name: "TransactWriteCommand" }] - ]); - expect(mockGet.mock.calls).toEqual([[]]); - expect(mockedGetCommand.mock.calls).toEqual([ - [ - { - TableName: "mock-table", - Key: { PK: "PaymentMethod#123", SK: "PaymentMethod" }, - ConsistentRead: true - } - ] - ]); - expect(mockTransact.mock.calls).toEqual([[]]); - expect(mockTransactWriteCommand.mock.calls).toEqual([ - [ - { - TransactItems: [ - { - Update: { - TableName: "mock-table", - Key: { PK: "PaymentMethod#123", SK: "PaymentMethod" }, - UpdateExpression: - "SET #LastFour = :LastFour, #CustomerId = :CustomerId, #UpdatedAt = :UpdatedAt", - ConditionExpression: "attribute_exists(PK)", - ExpressionAttributeNames: { - "#CustomerId": "CustomerId", - "#LastFour": "LastFour", - "#UpdatedAt": "UpdatedAt" - }, - ExpressionAttributeValues: { - ":CustomerId": "456", - ":LastFour": "5678", - ":UpdatedAt": "2023-10-16T03:31:35.918Z" - } - } - }, - { - ConditionCheck: { - TableName: "mock-table", - Key: { PK: "Customer#456", SK: "Customer" }, - ConditionExpression: "attribute_exists(PK)" + expect( + // eslint-disable-next-line @typescript-eslint/no-confusing-void-expression + await Grade.update("123", { + gradeValue: "B", + assignmentId: "111", + studentId: "222" + }) + ).toBeUndefined(); + expect(mockSend.mock.calls).toEqual([ + [{ name: "GetCommand" }], + [{ name: "TransactWriteCommand" }] + ]); + expect(mockGet.mock.calls).toEqual([[]]); + expect(mockedGetCommand.mock.calls).toEqual([ + [ + { + TableName: "other-table", + Key: { myPk: "Grade|123", mySk: "Grade" }, + ConsistentRead: true + } + ] + ]); + expect(mockTransact.mock.calls).toEqual([[]]); + expect(mockTransactWriteCommand.mock.calls).toEqual([ + [ + { + TransactItems: [ + { + Update: { + TableName: "other-table", + Key: { myPk: "Grade|123", mySk: "Grade" }, + UpdateExpression: + "SET #LetterValue = :LetterValue, #assignmentId = :assignmentId, #studentId = :studentId, #updatedAt = :updatedAt", + ConditionExpression: "attribute_exists(myPk)", + ExpressionAttributeNames: { + "#LetterValue": "LetterValue", + "#assignmentId": "assignmentId", + "#studentId": "studentId", + "#updatedAt": "updatedAt" + }, + ExpressionAttributeValues: { + ":LetterValue": "B", + ":assignmentId": "111", + ":studentId": "222", + ":updatedAt": "2023-10-16T03:31:35.918Z" } - }, - { - Put: { - TableName: "mock-table", + } + }, + { + ConditionCheck: { + TableName: "other-table", + Key: { myPk: "Assignment|111", mySk: "Assignment" }, + ConditionExpression: "attribute_exists(myPk)" + } + }, + { + Delete: { + TableName: "other-table", + Key: { myPk: "Assignment|456", mySk: "Grade" } + } + }, + { + Put: { + TableName: "other-table", + ConditionExpression: "attribute_not_exists(myPk)", + Item: { + myPk: "Assignment|111", + mySk: "Grade", + id: "belongsToLinkId1", + type: "BelongsToLink", + foreignKey: "123", + foreignEntityType: "Grade", + createdAt: "2023-10-16T03:31:35.918Z", + updatedAt: "2023-10-16T03:31:35.918Z" + } + } + }, + { + ConditionCheck: { + TableName: "other-table", + Key: { myPk: "Student|222", mySk: "Student" }, + ConditionExpression: "attribute_exists(myPk)" + } + }, + { + Delete: { + TableName: "other-table", + Key: { myPk: "Student|789", mySk: "Grade|123" } + } + }, + { + Put: { + TableName: "other-table", + ConditionExpression: "attribute_not_exists(myPk)", + Item: { + myPk: "Student|222", + mySk: "Grade|123", + id: "belongsToLinkId2", + type: "BelongsToLink", + foreignKey: "123", + foreignEntityType: "Grade", + createdAt: "2023-10-16T03:31:35.918Z", + updatedAt: "2023-10-16T03:31:35.918Z" + } + } + } + ] + } + ] + ]); + }); + }); + + describe("types", () => { + it("will not accept relationship attributes on update", async () => { + await Order.update("123", { + orderDate: new Date(), + paymentMethodId: "123", + customerId: "456", + // @ts-expect-error relationship attributes are not allowed + customer: new Customer() + }); + }); + + it("will not accept function attributes on update", async () => { + @Entity + class MyModel extends MockTable { + @StringAttribute({ alias: "MyAttribute" }) + public myAttribute: string; + + public someMethod(): string { + return "abc123"; + } + } + + // check that built in instance method is not allowed + await MyModel.update("123", { + myAttribute: "someVal", + // @ts-expect-error function attributes are not allowed + update: () => "123" + }); + + // check that custom instance method is not allowed + await MyModel.update("123", { + myAttribute: "someVal", + // @ts-expect-error function attributes are not allowed + someMethod: () => "123" + }); + }); + + it("will allow ForeignKey attributes to be passed at their inferred type without casting to type ForeignKey", async () => { + await Order.update("123", { + orderDate: new Date(), + // @ts-expect-no-error ForeignKey is of type string so it can be passed as such without casing to ForeignKey + paymentMethodId: "123", + // @ts-expect-no-error ForeignKey is of type string so it can be passed as such without casing to ForeignKey + customerId: "456" + }); + }); + + it("will not accept DefaultFields on update because they are managed by dyna-record", async () => { + await Order.update("123", { + // @ts-expect-error default fields are not accepted on update, they are managed by dyna-record + id: "123" + }); + + await Order.update("123", { + // @ts-expect-error default fields are not accepted on update, they are managed by dyna-record + type: "456" + }); + + await Order.update("123", { + // @ts-expect-error default fields are not accepted on update, they are managed by dyna-record + createdAt: new Date() + }); + + await Order.update("123", { + // @ts-expect-error default fields are not accepted on update, they are managed by dyna-record + updatedAt: new Date() + }); + }); + + it("does not require all of an entity attributes to be passed", async () => { + await Order.update("123", { + // @ts-expect-no-error ForeignKey is of type string so it can be passed as such without casing to ForeignKey + paymentMethodId: "123", + // @ts-expect-no-error ForeignKey is of type string so it can be passed as such without casing to ForeignKey + customerId: "456" + }); + }); + + it("will not allow non nullable attributes to be removed (set to null)", async () => { + expect.assertions(3); + + // Tests that the type system does not allow null, and also that if types are ignored the value is checked at runtime + await Order.update("123", { + // @ts-expect-error non-nullable fields cannot be removed (set to null) + paymentMethodId: null + }).catch(e => { + expect(e).toBeInstanceOf(ValidationError); + expect(e.message).toEqual("Validation errors"); + expect(e.cause).toEqual([ + { + code: "invalid_type", + expected: "string", + message: "Expected string, received null", + path: ["paymentMethodId"], + received: "null" + } + ]); + }); + }); + + it("will allow nullable attributes to be removed (set to null)", async () => { + await MyModelNullableAttribute.update("123", { + // @ts-expect-no-error non-nullable fields can be removed (set to null) + myAttribute: null + }); + }); + }); + }); + + describe("instance method", () => { + it("will update an entity without foreign key attributes", async () => { + expect.assertions(7); + + const now = new Date("2023-10-16T03:31:35.918Z"); + jest.setSystemTime(now); + + const instance = createInstance(Customer, { + pk: "test-pk" as PartitionKey, + sk: "test-sk" as SortKey, + id: "123", + name: "test-name", + address: "test-address", + type: "Customer", + createdAt: new Date("2023-10-01"), + updatedAt: new Date("2023-10-02") + }); + + const updatedInstance = await instance.update({ name: "newName" }); + + expect(updatedInstance).toEqual({ + pk: "test-pk", + sk: "test-sk", + id: "123", + name: "newName", // Updated name + type: "Customer", + address: "test-address", + createdAt: new Date("2023-10-01"), + updatedAt: now // Updated at gets updated + }); + expect(mockSend.mock.calls).toEqual([[{ name: "TransactWriteCommand" }]]); + expect(mockGet.mock.calls).toEqual([]); + expect(mockedGetCommand.mock.calls).toEqual([]); + expect(mockTransact.mock.calls).toEqual([[]]); + expect(mockTransactWriteCommand.mock.calls).toEqual([ + [ + { + TransactItems: [ + { + Update: { + TableName: "mock-table", + Key: { PK: "Customer#123", SK: "Customer" }, + UpdateExpression: + "SET #Name = :Name, #UpdatedAt = :UpdatedAt", + ConditionExpression: "attribute_exists(PK)", + ExpressionAttributeNames: { + "#Name": "Name", + "#UpdatedAt": "UpdatedAt" + }, + ExpressionAttributeValues: { + ":Name": "newName", + ":UpdatedAt": now.toISOString() + } + } + } + ] + } + ] + ]); + // Original instance is not mutated + expect(instance).toEqual({ + pk: "test-pk", + sk: "test-sk", + id: "123", + name: "test-name", + type: "Customer", + address: "test-address", + createdAt: new Date("2023-10-01"), + updatedAt: new Date("2023-10-02") + }); + }); + + it("will update an entity and remove attributes", async () => { + expect.assertions(7); + + const now = new Date("2023-10-16T03:31:35.918Z"); + jest.setSystemTime(now); + + const instance = createInstance(ContactInformation, { + pk: "test-pk" as PartitionKey, + sk: "test-sk" as SortKey, + id: "123", + email: "example@example.com", + phone: "555-555-5555", + type: "ContactInformation", + createdAt: new Date("2023-10-01"), + updatedAt: new Date("2023-10-02") + }); + + const updatedInstance = await instance.update({ + email: "new@example.com", + phone: null + }); + + expect(updatedInstance).toEqual({ + pk: "test-pk", + sk: "test-sk", + id: "123", + email: "new@example.com", + phone: undefined, + type: "ContactInformation", + createdAt: new Date("2023-10-01"), + updatedAt: now + }); + expect(mockSend.mock.calls).toEqual([[{ name: "TransactWriteCommand" }]]); + expect(mockGet.mock.calls).toEqual([]); + expect(mockedGetCommand.mock.calls).toEqual([]); + expect(mockTransact.mock.calls).toEqual([[]]); + expect(mockTransactWriteCommand.mock.calls).toEqual([ + [ + { + TransactItems: [ + { + Update: { + ConditionExpression: "attribute_exists(PK)", + ExpressionAttributeNames: { + "#Email": "Email", + "#Phone": "Phone", + "#UpdatedAt": "UpdatedAt" + }, + ExpressionAttributeValues: { + ":Email": "new@example.com", + ":UpdatedAt": "2023-10-16T03:31:35.918Z" + }, + Key: { + PK: "ContactInformation#123", + SK: "ContactInformation" + }, + TableName: "mock-table", + UpdateExpression: + "SET #Email = :Email, #UpdatedAt = :UpdatedAt REMOVE #Phone" + } + } + ] + } + ] + ]); + // Original instance is not mutated + expect(instance).toEqual({ + pk: "test-pk", + sk: "test-sk", + id: "123", + email: "example@example.com", + phone: "555-555-5555", + type: "ContactInformation", + createdAt: new Date("2023-10-01"), + updatedAt: new Date("2023-10-02") + }); + }); + + it("will update and remove multiple attributes", async () => { + expect.assertions(7); + + const now = new Date("2023-10-16T03:31:35.918Z"); + jest.setSystemTime(now); + + const instance = createInstance(MockInformation, { + pk: "test-pk" as PartitionKey, + sk: "test-sk" as SortKey, + id: "123", + type: "MockInformation", + address: "9 Example Ave", + email: "example@example.com", + state: "SomeState", + phone: "555-555-5555", + createdAt: new Date("2023-10-01"), + updatedAt: new Date("2023-10-02") + }); + + const updatedInstance = await instance.update({ + address: "111 Some St", + email: "new@example.com", + state: null, + phone: null + }); + + expect(updatedInstance).toEqual({ + pk: "test-pk", + sk: "test-sk", + id: "123", + type: "MockInformation", + address: "111 Some St", + email: "new@example.com", + state: undefined, + phone: undefined, + createdAt: new Date("2023-10-01"), + updatedAt: now + }); + expect(mockSend.mock.calls).toEqual([[{ name: "TransactWriteCommand" }]]); + expect(mockGet.mock.calls).toEqual([]); + expect(mockedGetCommand.mock.calls).toEqual([]); + expect(mockTransact.mock.calls).toEqual([[]]); + expect(mockTransactWriteCommand.mock.calls).toEqual([ + [ + { + TransactItems: [ + { + Update: { + ConditionExpression: "attribute_exists(PK)", + ExpressionAttributeNames: { + "#Address": "Address", + "#Email": "Email", + "#Phone": "Phone", + "#State": "State", + "#UpdatedAt": "UpdatedAt" + }, + ExpressionAttributeValues: { + ":Address": "111 Some St", + ":Email": "new@example.com", + ":UpdatedAt": "2023-10-16T03:31:35.918Z" + }, + Key: { PK: "MockInformation#123", SK: "MockInformation" }, + TableName: "mock-table", + UpdateExpression: + "SET #Address = :Address, #Email = :Email, #UpdatedAt = :UpdatedAt REMOVE #State, #Phone" + } + } + ] + } + ] + ]); + // Original instance is not mutated + expect(instance).toEqual({ + pk: "test-pk", + sk: "test-sk", + id: "123", + type: "MockInformation", + address: "9 Example Ave", + email: "example@example.com", + state: "SomeState", + phone: "555-555-5555", + createdAt: new Date("2023-10-01"), + updatedAt: new Date("2023-10-02") + }); + }); + + it("will error if any attributes are the wrong type", async () => { + expect.assertions(5); + + const instance = createInstance(MockInformation, { + pk: "test-pk" as PartitionKey, + sk: "test-sk" as SortKey, + id: "123", + type: "MockInformation", + address: "9 Example Ave", + email: "example@example.com", + state: "SomeState", + phone: "555-555-5555", + createdAt: new Date("2023-10-01"), + updatedAt: new Date("2023-10-02") + }); + + try { + await instance.update({ + someDate: "111" as any // Force any to test runtime validations + }); + } catch (e: any) { + expect(e).toBeInstanceOf(ValidationError); + expect(e.message).toEqual("Validation errors"); + expect(e.cause).toEqual([ + { + code: "invalid_type", + expected: "date", + message: "Expected date, received string", + path: ["someDate"], + received: "string" + } + ]); + expect(mockSend.mock.calls).toEqual([undefined]); + expect(mockTransactWriteCommand.mock.calls).toEqual([]); + } + }); + + it("will allow nullable attributes to be set to null", async () => { + expect.assertions(7); + + const now = new Date("2023-10-16T03:31:35.918Z"); + jest.setSystemTime(now); + + const instance = createInstance(MockInformation, { + pk: "test-pk" as PartitionKey, + sk: "test-sk" as SortKey, + id: "123", + type: "MockInformation", + address: "9 Example Ave", + email: "example@example.com", + someDate: new Date(), + state: "SomeState", + phone: "555-555-5555", + createdAt: new Date("2023-10-01"), + updatedAt: new Date("2023-10-02") + }); + + const updatedInstance = await instance.update({ someDate: null }); + + expect(updatedInstance).toEqual({ + pk: "test-pk" as PartitionKey, + sk: "test-sk" as SortKey, + id: "123", + type: "MockInformation", + address: "9 Example Ave", + email: "example@example.com", + someDate: undefined, + state: "SomeState", + phone: "555-555-5555", + createdAt: new Date("2023-10-01"), + updatedAt: now + }); + expect(mockSend.mock.calls).toEqual([[{ name: "TransactWriteCommand" }]]); + expect(mockGet.mock.calls).toEqual([]); + expect(mockedGetCommand.mock.calls).toEqual([]); + expect(mockTransact.mock.calls).toEqual([[]]); + expect(mockTransactWriteCommand.mock.calls).toEqual([ + [ + { + TransactItems: [ + { + Update: { + ConditionExpression: "attribute_exists(PK)", + ExpressionAttributeNames: { + "#UpdatedAt": "UpdatedAt", + "#someDate": "someDate" + }, + ExpressionAttributeValues: { + ":UpdatedAt": "2023-10-16T03:31:35.918Z", + ":someDate": undefined + }, + Key: { PK: "MockInformation#123", SK: "MockInformation" }, + TableName: "mock-table", + UpdateExpression: + "SET #someDate = :someDate, #UpdatedAt = :UpdatedAt" + } + } + ] + } + ] + ]); + // Original instance is not mutated + expect(instance).toEqual({ + pk: "test-pk", + sk: "test-sk", + id: "123", + type: "MockInformation", + address: "9 Example Ave", + email: "example@example.com", + someDate: new Date(), + state: "SomeState", + phone: "555-555-5555", + createdAt: new Date("2023-10-01"), + updatedAt: new Date("2023-10-02") + }); + }); + + it("will not allow non nullable attributes to be null", async () => { + expect.assertions(5); + + const instance = createInstance(MyModelNonNullableAttribute, { + pk: "test-pk" as PartitionKey, + sk: "test-sk" as SortKey, + id: "123", + type: "MyModelNonNullableAttribute", + myAttribute: new Date(), + createdAt: new Date("2023-10-01"), + updatedAt: new Date("2023-10-02") + }); + + try { + await instance.update({ + myAttribute: null as any // Force any to test runtime validations + }); + } catch (e: any) { + expect(e).toBeInstanceOf(ValidationError); + expect(e.message).toEqual("Validation errors"); + expect(e.cause).toEqual([ + { + code: "invalid_type", + expected: "date", + message: "Expected date, received null", + path: ["myAttribute"], + received: "null" + } + ]); + expect(mockSend.mock.calls).toEqual([undefined]); + expect(mockTransactWriteCommand.mock.calls).toEqual([]); + } + }); + + it("will allow nullable attributes to be set to null", async () => { + expect.assertions(7); + + const now = new Date("2023-10-16T03:31:35.918Z"); + jest.setSystemTime(now); + + const instance = createInstance(MockInformation, { + pk: "test-pk" as PartitionKey, + sk: "test-sk" as SortKey, + id: "123", + type: "MockInformation", + address: "9 Example Ave", + email: "example@example.com", + someDate: new Date(), + state: "SomeState", + phone: "555-555-5555", + createdAt: new Date("2023-10-01"), + updatedAt: new Date("2023-10-02") + }); + + const updatedInstance = await instance.update({ someDate: null }); + + expect(updatedInstance).toEqual({ + pk: "test-pk" as PartitionKey, + sk: "test-sk" as SortKey, + id: "123", + type: "MockInformation", + address: "9 Example Ave", + email: "example@example.com", + someDate: undefined, + state: "SomeState", + phone: "555-555-5555", + createdAt: new Date("2023-10-01"), + updatedAt: now + }); + expect(mockSend.mock.calls).toEqual([[{ name: "TransactWriteCommand" }]]); + expect(mockGet.mock.calls).toEqual([]); + expect(mockedGetCommand.mock.calls).toEqual([]); + expect(mockTransact.mock.calls).toEqual([[]]); + expect(mockTransactWriteCommand.mock.calls).toEqual([ + [ + { + TransactItems: [ + { + Update: { + ConditionExpression: "attribute_exists(PK)", + ExpressionAttributeNames: { + "#UpdatedAt": "UpdatedAt", + "#someDate": "someDate" + }, + ExpressionAttributeValues: { + ":UpdatedAt": "2023-10-16T03:31:35.918Z", + ":someDate": undefined + }, + Key: { PK: "MockInformation#123", SK: "MockInformation" }, + TableName: "mock-table", + UpdateExpression: + "SET #someDate = :someDate, #UpdatedAt = :UpdatedAt" + } + } + ] + } + ] + ]); + // Original instance is not mutated + expect(instance).toEqual({ + pk: "test-pk", + sk: "test-sk", + id: "123", + type: "MockInformation", + address: "9 Example Ave", + email: "example@example.com", + someDate: new Date(), + state: "SomeState", + phone: "555-555-5555", + createdAt: new Date("2023-10-01"), + updatedAt: new Date("2023-10-02") + }); + }); + + describe("ForeignKey is updated for entity which BelongsTo an entity who HasOne of it", () => { + describe("when the entity does not already belong to another entity", () => { + const now = new Date("2023-10-16T03:31:35.918Z"); + + beforeEach(() => { + jest.setSystemTime(now); + mockedUuidv4.mockReturnValueOnce("belongsToLinkId1"); + mockGet.mockResolvedValue({ + Item: { + PK: "ContactInformation#123", + SK: "ContactInformation", + Id: "123", + Email: "old-email@example.com", + Phone: "555-555-5555", + CustomerId: undefined // Does not already belong to customer + } + }); + }); + + afterEach(() => { + mockedUuidv4.mockReset(); + }); + + it("will update the foreign key if the entity being associated with exists", async () => { + expect.assertions(7); + + const instance = createInstance(ContactInformation, { + pk: "test-pk" as PartitionKey, + sk: "test-sk" as SortKey, + id: "123", + email: "example@example.com", + phone: "555-555-5555", + type: "ContactInformation", + createdAt: new Date("2023-10-01"), + updatedAt: new Date("2023-10-02") + }); + + const updatedInstance = await instance.update({ + email: "new-email@example.com", + customerId: "456" + }); + + expect(updatedInstance).toEqual({ + pk: "test-pk", + sk: "test-sk", + id: "123", + email: "new-email@example.com", + customerId: "456", + phone: "555-555-5555", + type: "ContactInformation", + createdAt: new Date("2023-10-01"), + updatedAt: now + }); + expect(mockSend.mock.calls).toEqual([ + [{ name: "GetCommand" }], + [{ name: "TransactWriteCommand" }] + ]); + expect(mockGet.mock.calls).toEqual([[]]); + expect(mockedGetCommand.mock.calls).toEqual([ + [ + { + TableName: "mock-table", + Key: { PK: "ContactInformation#123", SK: "ContactInformation" }, + ConsistentRead: true + } + ] + ]); + expect(mockTransact.mock.calls).toEqual([[]]); + expect(mockTransactWriteCommand.mock.calls).toEqual([ + [ + { + TransactItems: [ + { + Update: { + TableName: "mock-table", + Key: { + PK: "ContactInformation#123", + SK: "ContactInformation" + }, + UpdateExpression: + "SET #Email = :Email, #CustomerId = :CustomerId, #UpdatedAt = :UpdatedAt", + // Check that the entity being updated exists + ConditionExpression: "attribute_exists(PK)", + ExpressionAttributeNames: { + "#CustomerId": "CustomerId", + "#Email": "Email", + "#UpdatedAt": "UpdatedAt" + }, + ExpressionAttributeValues: { + ":CustomerId": "456", + ":Email": "new-email@example.com", + ":UpdatedAt": "2023-10-16T03:31:35.918Z" + } + } + }, + { + // Check that the entity being associated with exists + ConditionCheck: { + TableName: "mock-table", + Key: { PK: "Customer#456", SK: "Customer" }, + ConditionExpression: "attribute_exists(PK)" + } + }, + { + Put: { + TableName: "mock-table", + ConditionExpression: "attribute_not_exists(PK)", + Item: { + PK: "Customer#456", + SK: "ContactInformation", + Id: "belongsToLinkId1", + Type: "BelongsToLink", + ForeignEntityType: "ContactInformation", + ForeignKey: "123", + CreatedAt: "2023-10-16T03:31:35.918Z", + UpdatedAt: "2023-10-16T03:31:35.918Z" + } + } + } + ] + } + ] + ]); + // Original instance is not mutated + expect(instance).toEqual({ + pk: "test-pk", + sk: "test-sk", + id: "123", + email: "example@example.com", + phone: "555-555-5555", + type: "ContactInformation", + createdAt: new Date("2023-10-01"), + updatedAt: new Date("2023-10-02") + }); + }); + + it("will throw an error if the entity being updated does not exist", async () => { + expect.assertions(7); + + const instance = createInstance(ContactInformation, { + pk: "test-pk" as PartitionKey, + sk: "test-sk" as SortKey, + id: "123", + email: "example@example.com", + phone: "555-555-5555", + type: "ContactInformation", + createdAt: new Date("2023-10-01"), + updatedAt: new Date("2023-10-02") + }); + + mockGet.mockResolvedValueOnce({}); // Entity does not exist but will fail in transaction + + mockSend.mockReturnValueOnce(undefined).mockImplementationOnce(() => { + mockTransact(); + throw new TransactionCanceledException({ + message: "MockMessage", + CancellationReasons: [ + { Code: "ConditionalCheckFailed" }, + { Code: "None" }, + { Code: "None" } + ], + $metadata: {} + }); + }); + + try { + await instance.update({ + email: "new-email@example.com", + customerId: "456" + }); + } catch (e: any) { + expect(e.constructor.name).toEqual("TransactionWriteFailedError"); + expect(e.errors).toEqual([ + new ConditionalCheckFailedError( + "ConditionalCheckFailed: ContactInformation with ID '123' does not exist" + ) + ]); + expect(mockSend.mock.calls).toEqual([ + [{ name: "GetCommand" }], + [{ name: "TransactWriteCommand" }] + ]); + expect(mockGet.mock.calls).toEqual([[]]); + expect(mockedGetCommand.mock.calls).toEqual([ + [ + { + TableName: "mock-table", + Key: { + PK: "ContactInformation#123", + SK: "ContactInformation" + }, + ConsistentRead: true + } + ] + ]); + expect(mockTransact.mock.calls).toEqual([[]]); + expect(mockTransactWriteCommand.mock.calls).toEqual([ + [ + { + TransactItems: [ + { + Update: { + TableName: "mock-table", + Key: { + PK: "ContactInformation#123", + SK: "ContactInformation" + }, + UpdateExpression: + "SET #Email = :Email, #CustomerId = :CustomerId, #UpdatedAt = :UpdatedAt", + // Check that the entity being updated exists + ConditionExpression: "attribute_exists(PK)", + ExpressionAttributeNames: { + "#CustomerId": "CustomerId", + "#Email": "Email", + "#UpdatedAt": "UpdatedAt" + }, + ExpressionAttributeValues: { + ":CustomerId": "456", + ":Email": "new-email@example.com", + ":UpdatedAt": "2023-10-16T03:31:35.918Z" + } + } + }, + { + // Check that the entity being associated with exists + ConditionCheck: { + TableName: "mock-table", + Key: { PK: "Customer#456", SK: "Customer" }, + ConditionExpression: "attribute_exists(PK)" + } + }, + { + Put: { + TableName: "mock-table", + ConditionExpression: "attribute_not_exists(PK)", + Item: { + PK: "Customer#456", + SK: "ContactInformation", + Id: "belongsToLinkId1", + Type: "BelongsToLink", + ForeignEntityType: "ContactInformation", + ForeignKey: "123", + CreatedAt: "2023-10-16T03:31:35.918Z", + UpdatedAt: "2023-10-16T03:31:35.918Z" + } + } + } + ] + } + ] + ]); + } + }); + + it("will throw an error if the entity being associated with does not exist", async () => { + expect.assertions(7); + + const instance = createInstance(ContactInformation, { + pk: "test-pk" as PartitionKey, + sk: "test-sk" as SortKey, + id: "123", + email: "example@example.com", + phone: "555-555-5555", + type: "ContactInformation", + createdAt: new Date("2023-10-01"), + updatedAt: new Date("2023-10-02") + }); + + mockSend.mockReturnValueOnce(undefined).mockImplementationOnce(() => { + mockTransact(); + throw new TransactionCanceledException({ + message: "MockMessage", + CancellationReasons: [ + { Code: "None" }, + { Code: "ConditionalCheckFailed" }, + { Code: "None" } + ], + $metadata: {} + }); + }); + + try { + await instance.update({ + email: "new-email@example.com", + customerId: "456" + }); + } catch (e: any) { + expect(e.constructor.name).toEqual("TransactionWriteFailedError"); + expect(e.errors).toEqual([ + new ConditionalCheckFailedError( + "ConditionalCheckFailed: Customer with ID '456' does not exist" + ) + ]); + expect(mockSend.mock.calls).toEqual([ + [{ name: "GetCommand" }], + [{ name: "TransactWriteCommand" }] + ]); + expect(mockGet.mock.calls).toEqual([[]]); + expect(mockedGetCommand.mock.calls).toEqual([ + [ + { + TableName: "mock-table", + Key: { + PK: "ContactInformation#123", + SK: "ContactInformation" + }, + ConsistentRead: true + } + ] + ]); + expect(mockTransact.mock.calls).toEqual([[]]); + expect(mockTransactWriteCommand.mock.calls).toEqual([ + [ + { + TransactItems: [ + { + Update: { + TableName: "mock-table", + Key: { + PK: "ContactInformation#123", + SK: "ContactInformation" + }, + UpdateExpression: + "SET #Email = :Email, #CustomerId = :CustomerId, #UpdatedAt = :UpdatedAt", + // Check that the entity being updated exists + ConditionExpression: "attribute_exists(PK)", + ExpressionAttributeNames: { + "#CustomerId": "CustomerId", + "#Email": "Email", + "#UpdatedAt": "UpdatedAt" + }, + ExpressionAttributeValues: { + ":CustomerId": "456", + ":Email": "new-email@example.com", + ":UpdatedAt": "2023-10-16T03:31:35.918Z" + } + } + }, + { + // Check that the entity being associated with exists + ConditionCheck: { + TableName: "mock-table", + Key: { PK: "Customer#456", SK: "Customer" }, + ConditionExpression: "attribute_exists(PK)" + } + }, + { + Put: { + TableName: "mock-table", + ConditionExpression: "attribute_not_exists(PK)", + Item: { + PK: "Customer#456", + SK: "ContactInformation", + Id: "belongsToLinkId1", + Type: "BelongsToLink", + ForeignEntityType: "ContactInformation", + ForeignKey: "123", + CreatedAt: "2023-10-16T03:31:35.918Z", + UpdatedAt: "2023-10-16T03:31:35.918Z" + } + } + } + ] + } + ] + ]); + } + }); + + it("will remove a nullable foreign key", async () => { + expect.assertions(7); + + const instance = createInstance(ContactInformation, { + pk: "test-pk" as PartitionKey, + sk: "test-sk" as SortKey, + id: "123", + email: "example@example.com", + phone: "555-555-5555", + type: "ContactInformation", + createdAt: new Date("2023-10-01"), + updatedAt: new Date("2023-10-02") + }); + + expect( + await instance.update({ + email: "new-email@example.com", + customerId: null + }) + ).toEqual({ + pk: "test-pk" as PartitionKey, + sk: "test-sk" as SortKey, + id: "123", + email: "new-email@example.com", + phone: "555-555-5555", + customerId: undefined, + type: "ContactInformation", + createdAt: new Date("2023-10-01"), + updatedAt: now + }); + expect(mockSend.mock.calls).toEqual([ + [{ name: "GetCommand" }], + [{ name: "TransactWriteCommand" }] + ]); + expect(mockGet.mock.calls).toEqual([[]]); + expect(mockedGetCommand.mock.calls).toEqual([ + [ + { + TableName: "mock-table", + Key: { PK: "ContactInformation#123", SK: "ContactInformation" }, + ConsistentRead: true + } + ] + ]); + expect(mockTransact.mock.calls).toEqual([[]]); + expect(mockTransactWriteCommand.mock.calls).toEqual([ + [ + { + TransactItems: [ + { + Update: { + TableName: "mock-table", + Key: { + PK: "ContactInformation#123", + SK: "ContactInformation" + }, + ConditionExpression: "attribute_exists(PK)", + ExpressionAttributeValues: { + ":Email": "new-email@example.com", + ":UpdatedAt": "2023-10-16T03:31:35.918Z" + }, + ExpressionAttributeNames: { + "#Email": "Email", + "#UpdatedAt": "UpdatedAt", + "#CustomerId": "CustomerId" + }, + UpdateExpression: + "SET #Email = :Email, #UpdatedAt = :UpdatedAt REMOVE #CustomerId" + } + } + ] + } + ] + ]); + // Original instance is not mutated + expect(instance).toEqual({ + pk: "test-pk", + sk: "test-sk", + id: "123", + email: "example@example.com", + phone: "555-555-5555", + type: "ContactInformation", + createdAt: new Date("2023-10-01"), + updatedAt: new Date("2023-10-02") + }); + }); + }); + + describe("when the entity belongs to another another entity (Adds delete transaction for existing BelongsToLink)", () => { + const now = new Date("2023-10-16T03:31:35.918Z"); + + beforeEach(() => { + jest.setSystemTime(now); + mockedUuidv4.mockReturnValueOnce("belongsToLinkId1"); + mockGet.mockResolvedValue({ + Item: { + PK: "ContactInformation#123", + SK: "ContactInformation", + Id: "123", + Email: "old-email@example.com", + Phone: "555-555-5555", + CustomerId: "789" // Already belongs to customer + } + }); + }); + + afterEach(() => { + mockedUuidv4.mockReset(); + }); + + it("will update the foreign key and delete the old BelongsToLink if the entity being associated with exists", async () => { + expect.assertions(7); + + const instance = createInstance(ContactInformation, { + pk: "test-pk" as PartitionKey, + sk: "test-sk" as SortKey, + id: "123", + email: "example@example.com", + phone: "555-555-5555", + type: "ContactInformation", + customerId: "789" as NullableForeignKey, + createdAt: new Date("2023-10-01"), + updatedAt: new Date("2023-10-02") + }); + + const updatedInstance = await instance.update({ + email: "new-email@example.com", + customerId: "456" + }); + + expect(updatedInstance).toEqual({ + pk: "test-pk", + sk: "test-sk", + id: "123", + email: "new-email@example.com", + customerId: "456", + phone: "555-555-5555", + type: "ContactInformation", + createdAt: new Date("2023-10-01"), + updatedAt: now + }); + expect(mockSend.mock.calls).toEqual([ + [{ name: "GetCommand" }], + [{ name: "TransactWriteCommand" }] + ]); + expect(mockGet.mock.calls).toEqual([[]]); + expect(mockedGetCommand.mock.calls).toEqual([ + [ + { + TableName: "mock-table", + Key: { PK: "ContactInformation#123", SK: "ContactInformation" }, + ConsistentRead: true + } + ] + ]); + expect(mockTransact.mock.calls).toEqual([[]]); + expect(mockTransactWriteCommand.mock.calls).toEqual([ + [ + { + TransactItems: [ + { + Update: { + TableName: "mock-table", + Key: { + PK: "ContactInformation#123", + SK: "ContactInformation" + }, + UpdateExpression: + "SET #Email = :Email, #CustomerId = :CustomerId, #UpdatedAt = :UpdatedAt", + // Check that the entity being updated exists + ConditionExpression: "attribute_exists(PK)", + ExpressionAttributeNames: { + "#CustomerId": "CustomerId", + "#Email": "Email", + "#UpdatedAt": "UpdatedAt" + }, + ExpressionAttributeValues: { + ":CustomerId": "456", + ":Email": "new-email@example.com", + ":UpdatedAt": "2023-10-16T03:31:35.918Z" + } + } + }, + { + // Check that the entity being associated with exists + ConditionCheck: { + TableName: "mock-table", + Key: { PK: "Customer#456", SK: "Customer" }, + ConditionExpression: "attribute_exists(PK)" + } + }, + { + // Delete old BelongsToLink + Delete: { + TableName: "mock-table", + Key: { + PK: "Customer#789", + SK: "ContactInformation" + } + } + }, + { + Put: { + TableName: "mock-table", ConditionExpression: "attribute_not_exists(PK)", Item: { PK: "Customer#456", - SK: "PaymentMethod#123", + SK: "ContactInformation", Id: "belongsToLinkId1", Type: "BelongsToLink", - ForeignEntityType: "PaymentMethod", + ForeignEntityType: "ContactInformation", ForeignKey: "123", CreatedAt: "2023-10-16T03:31:35.918Z", UpdatedAt: "2023-10-16T03:31:35.918Z" @@ -1450,37 +3592,553 @@ describe("Update", () => { } ] ]); - } + // Assert that original instance was not mutated + expect(instance).toEqual({ + pk: "test-pk", + sk: "test-sk", + id: "123", + email: "example@example.com", + phone: "555-555-5555", + type: "ContactInformation", + customerId: "789", + createdAt: new Date("2023-10-01"), + updatedAt: new Date("2023-10-02") + }); + }); + + it("will throw an error if the entity being updated does not exist", async () => { + expect.assertions(7); + + const instance = createInstance(ContactInformation, { + pk: "test-pk" as PartitionKey, + sk: "test-sk" as SortKey, + id: "123", + email: "example@example.com", + phone: "555-555-5555", + type: "ContactInformation", + customerId: "789" as NullableForeignKey, + createdAt: new Date("2023-10-01"), + updatedAt: new Date("2023-10-02") + }); + + mockGet.mockResolvedValueOnce({}); // Entity does not exist but will fail in transaction + + mockSend.mockReturnValueOnce(undefined).mockImplementationOnce(() => { + mockTransact(); + throw new TransactionCanceledException({ + message: "MockMessage", + CancellationReasons: [ + { Code: "ConditionalCheckFailed" }, + { Code: "None" }, + { Code: "None" }, + { Code: "None" } + ], + $metadata: {} + }); + }); + + try { + await instance.update({ + email: "new-email@example.com", + customerId: "456" + }); + } catch (e: any) { + expect(e.constructor.name).toEqual("TransactionWriteFailedError"); + expect(e.errors).toEqual([ + new ConditionalCheckFailedError( + "ConditionalCheckFailed: ContactInformation with ID '123' does not exist" + ) + ]); + expect(mockSend.mock.calls).toEqual([ + [{ name: "GetCommand" }], + [{ name: "TransactWriteCommand" }] + ]); + expect(mockGet.mock.calls).toEqual([[]]); + expect(mockedGetCommand.mock.calls).toEqual([ + [ + { + TableName: "mock-table", + Key: { + PK: "ContactInformation#123", + SK: "ContactInformation" + }, + ConsistentRead: true + } + ] + ]); + expect(mockTransact.mock.calls).toEqual([[]]); + expect(mockTransactWriteCommand.mock.calls).toEqual([ + [ + { + TransactItems: [ + { + Update: { + TableName: "mock-table", + Key: { + PK: "ContactInformation#123", + SK: "ContactInformation" + }, + UpdateExpression: + "SET #Email = :Email, #CustomerId = :CustomerId, #UpdatedAt = :UpdatedAt", + // Check that the entity being updated exists + ConditionExpression: "attribute_exists(PK)", + ExpressionAttributeNames: { + "#CustomerId": "CustomerId", + "#Email": "Email", + "#UpdatedAt": "UpdatedAt" + }, + ExpressionAttributeValues: { + ":CustomerId": "456", + ":Email": "new-email@example.com", + ":UpdatedAt": "2023-10-16T03:31:35.918Z" + } + } + }, + { + // Check that the entity being associated with exists + ConditionCheck: { + TableName: "mock-table", + Key: { PK: "Customer#456", SK: "Customer" }, + ConditionExpression: "attribute_exists(PK)" + } + }, + // No Delete transaction because the item does not exist to look up the foreign key to build the delete operation with + { + Put: { + TableName: "mock-table", + ConditionExpression: "attribute_not_exists(PK)", + Item: { + PK: "Customer#456", + SK: "ContactInformation", + Id: "belongsToLinkId1", + Type: "BelongsToLink", + ForeignEntityType: "ContactInformation", + ForeignKey: "123", + CreatedAt: "2023-10-16T03:31:35.918Z", + UpdatedAt: "2023-10-16T03:31:35.918Z" + } + } + } + ] + } + ] + ]); + } + }); + + it("will throw an error if the associated entity does not exist", async () => { + expect.assertions(7); + + const instance = createInstance(ContactInformation, { + pk: "test-pk" as PartitionKey, + sk: "test-sk" as SortKey, + id: "123", + email: "example@example.com", + phone: "555-555-5555", + type: "ContactInformation", + customerId: "789" as NullableForeignKey, + createdAt: new Date("2023-10-01"), + updatedAt: new Date("2023-10-02") + }); + + mockSend.mockReturnValueOnce(undefined).mockImplementationOnce(() => { + mockTransact(); + throw new TransactionCanceledException({ + message: "MockMessage", + CancellationReasons: [ + { Code: "None" }, + { Code: "ConditionalCheckFailed" }, + { Code: "None" }, + { Code: "None" } + ], + $metadata: {} + }); + }); + + try { + await instance.update({ + email: "new-email@example.com", + customerId: "456" + }); + } catch (e: any) { + expect(e.constructor.name).toEqual("TransactionWriteFailedError"); + expect(e.errors).toEqual([ + new ConditionalCheckFailedError( + "ConditionalCheckFailed: Customer with ID '456' does not exist" + ) + ]); + expect(mockSend.mock.calls).toEqual([ + [{ name: "GetCommand" }], + [{ name: "TransactWriteCommand" }] + ]); + expect(mockGet.mock.calls).toEqual([[]]); + expect(mockedGetCommand.mock.calls).toEqual([ + [ + { + TableName: "mock-table", + Key: { + PK: "ContactInformation#123", + SK: "ContactInformation" + }, + ConsistentRead: true + } + ] + ]); + expect(mockTransact.mock.calls).toEqual([[]]); + expect(mockTransactWriteCommand.mock.calls).toEqual([ + [ + { + TransactItems: [ + { + Update: { + TableName: "mock-table", + Key: { + PK: "ContactInformation#123", + SK: "ContactInformation" + }, + UpdateExpression: + "SET #Email = :Email, #CustomerId = :CustomerId, #UpdatedAt = :UpdatedAt", + // Check that the entity being updated exists + ConditionExpression: "attribute_exists(PK)", + ExpressionAttributeNames: { + "#CustomerId": "CustomerId", + "#Email": "Email", + "#UpdatedAt": "UpdatedAt" + }, + ExpressionAttributeValues: { + ":CustomerId": "456", + ":Email": "new-email@example.com", + ":UpdatedAt": "2023-10-16T03:31:35.918Z" + } + } + }, + { + // Check that the entity being associated with exists + ConditionCheck: { + TableName: "mock-table", + Key: { PK: "Customer#456", SK: "Customer" }, + ConditionExpression: "attribute_exists(PK)" + } + }, + { + // Delete old BelongsToLink + Delete: { + TableName: "mock-table", + Key: { + PK: "Customer#789", + SK: "ContactInformation" + } + } + }, + { + Put: { + TableName: "mock-table", + ConditionExpression: "attribute_not_exists(PK)", + Item: { + PK: "Customer#456", + SK: "ContactInformation", + Id: "belongsToLinkId1", + Type: "BelongsToLink", + ForeignEntityType: "ContactInformation", + ForeignKey: "123", + CreatedAt: "2023-10-16T03:31:35.918Z", + UpdatedAt: "2023-10-16T03:31:35.918Z" + } + } + } + ] + } + ] + ]); + } + }); + + it("will throw an error if the entity is already associated with the requested entity", async () => { + expect.assertions(7); + + const instance = createInstance(ContactInformation, { + pk: "test-pk" as PartitionKey, + sk: "test-sk" as SortKey, + id: "123", + email: "example@example.com", + phone: "555-555-5555", + type: "ContactInformation", + customerId: "789" as NullableForeignKey, + createdAt: new Date("2023-10-01"), + updatedAt: new Date("2023-10-02") + }); + + mockGet.mockResolvedValueOnce({ + Item: { + PK: "ContactInformation#123", + SK: "ContactInformation", + Id: "123", + Email: "old-email@example.com", + Phone: "555-555-5555", + CustomerId: "456" // Already belongs to customer, the same being updated + } + }); + + mockSend.mockReturnValueOnce(undefined).mockImplementationOnce(() => { + mockTransact(); + throw new TransactionCanceledException({ + message: "MockMessage", + CancellationReasons: [ + { Code: "None" }, + { Code: "None" }, + { Code: "None" }, + { Code: "ConditionalCheckFailed" } + ], + $metadata: {} + }); + }); + + try { + await instance.update({ + email: "new-email@example.com", + customerId: "456" + }); + } catch (e: any) { + expect(e.constructor.name).toEqual("TransactionWriteFailedError"); + expect(e.errors).toEqual([ + new ConditionalCheckFailedError( + "ConditionalCheckFailed: Customer with id: 456 already has an associated ContactInformation" + ) + ]); + expect(mockSend.mock.calls).toEqual([ + [{ name: "GetCommand" }], + [{ name: "TransactWriteCommand" }] + ]); + expect(mockGet.mock.calls).toEqual([[]]); + expect(mockedGetCommand.mock.calls).toEqual([ + [ + { + TableName: "mock-table", + Key: { + PK: "ContactInformation#123", + SK: "ContactInformation" + }, + ConsistentRead: true + } + ] + ]); + expect(mockTransact.mock.calls).toEqual([[]]); + expect(mockTransactWriteCommand.mock.calls).toEqual([ + [ + { + TransactItems: [ + { + Update: { + TableName: "mock-table", + Key: { + PK: "ContactInformation#123", + SK: "ContactInformation" + }, + UpdateExpression: + "SET #Email = :Email, #CustomerId = :CustomerId, #UpdatedAt = :UpdatedAt", + // Check that the entity being updated exists + ConditionExpression: "attribute_exists(PK)", + ExpressionAttributeNames: { + "#CustomerId": "CustomerId", + "#Email": "Email", + "#UpdatedAt": "UpdatedAt" + }, + ExpressionAttributeValues: { + ":CustomerId": "456", + ":Email": "new-email@example.com", + ":UpdatedAt": "2023-10-16T03:31:35.918Z" + } + } + }, + { + // Check that the entity being associated with exists + ConditionCheck: { + TableName: "mock-table", + Key: { PK: "Customer#456", SK: "Customer" }, + ConditionExpression: "attribute_exists(PK)" + } + }, + { + // Delete old BelongsToLink + Delete: { + TableName: "mock-table", + Key: { + PK: "Customer#456", + SK: "ContactInformation" + } + } + }, + { + Put: { + TableName: "mock-table", + ConditionExpression: "attribute_not_exists(PK)", + Item: { + PK: "Customer#456", + SK: "ContactInformation", + Id: "belongsToLinkId1", + Type: "BelongsToLink", + ForeignEntityType: "ContactInformation", + ForeignKey: "123", + CreatedAt: "2023-10-16T03:31:35.918Z", + UpdatedAt: "2023-10-16T03:31:35.918Z" + } + } + } + ] + } + ] + ]); + } + }); + + it("will remove a nullable foreign key and delete the BelongsToLinks for the associated entity", async () => { + expect.assertions(7); + + const instance = createInstance(ContactInformation, { + pk: "test-pk" as PartitionKey, + sk: "test-sk" as SortKey, + id: "123", + email: "example@example.com", + phone: "555-555-5555", + type: "ContactInformation", + customerId: "789" as NullableForeignKey, + createdAt: new Date("2023-10-01"), + updatedAt: new Date("2023-10-02") + }); + + const updatedInstance = await instance.update({ + email: "new-email@example.com", + customerId: null + }); + + expect(updatedInstance).toEqual({ + pk: "test-pk" as PartitionKey, + sk: "test-sk" as SortKey, + id: "123", + email: "new-email@example.com", + customerId: undefined, + phone: "555-555-5555", + type: "ContactInformation", + createdAt: new Date("2023-10-01"), + updatedAt: now + }); + expect(mockSend.mock.calls).toEqual([ + [{ name: "GetCommand" }], + [{ name: "TransactWriteCommand" }] + ]); + expect(mockGet.mock.calls).toEqual([[]]); + expect(mockedGetCommand.mock.calls).toEqual([ + [ + { + TableName: "mock-table", + Key: { PK: "ContactInformation#123", SK: "ContactInformation" }, + ConsistentRead: true + } + ] + ]); + expect(mockTransact.mock.calls).toEqual([[]]); + expect(mockTransactWriteCommand.mock.calls).toEqual([ + [ + { + TransactItems: [ + { + Update: { + TableName: "mock-table", + Key: { + PK: "ContactInformation#123", + SK: "ContactInformation" + }, + ConditionExpression: "attribute_exists(PK)", + ExpressionAttributeValues: { + ":Email": "new-email@example.com", + ":UpdatedAt": "2023-10-16T03:31:35.918Z" + }, + ExpressionAttributeNames: { + "#Email": "Email", + "#UpdatedAt": "UpdatedAt", + "#CustomerId": "CustomerId" + }, + UpdateExpression: + "SET #Email = :Email, #UpdatedAt = :UpdatedAt REMOVE #CustomerId" + } + }, + { + Delete: { + TableName: "mock-table", + Key: { PK: "Customer#789", SK: "ContactInformation" } + } + } + ] + } + ] + ]); + // Assert that original instance was not mutated + expect(instance).toEqual({ + pk: "test-pk", + sk: "test-sk", + id: "123", + email: "example@example.com", + phone: "555-555-5555", + type: "ContactInformation", + customerId: "789", + createdAt: new Date("2023-10-01"), + updatedAt: new Date("2023-10-02") + }); + }); }); + }); - it("will throw an error if the entity being associated with does not exist", async () => { - expect.assertions(7); + describe("ForeignKey is updated for entity which BelongsTo an entity who HasMany of it", () => { + describe("when the entity does not already belong to another entity", () => { + const now = new Date("2023-10-16T03:31:35.918Z"); - mockSend.mockReturnValueOnce(undefined).mockImplementationOnce(() => { - mockTransact(); - throw new TransactionCanceledException({ - message: "MockMessage", - CancellationReasons: [ - { Code: "None" }, - { Code: "ConditionalCheckFailed" }, - { Code: "None" } - ], - $metadata: {} + beforeEach(() => { + jest.setSystemTime(now); + mockedUuidv4.mockReturnValueOnce("belongsToLinkId1"); + mockGet.mockResolvedValue({ + Item: { + PK: "PaymentMethod#123", + SK: "PaymentMethod", + Id: "123", + lastFour: "1234", + CustomerId: undefined // Does not already belong to customer + } }); }); - try { - await PaymentMethod.update("123", { + afterEach(() => { + mockedUuidv4.mockReset(); + }); + + it("will update the foreign key if the entity being associated with exists", async () => { + expect.assertions(7); + + const instance = createInstance(PaymentMethod, { + pk: "test-pk" as PartitionKey, + sk: "test-sk" as SortKey, + id: "123", + type: "PaymentMethod", + lastFour: "1234", + customerId: "111" as ForeignKey, + createdAt: new Date("2023-10-01"), + updatedAt: new Date("2023-10-02") + }); + + const updatedInstance = await instance.update({ lastFour: "5678", customerId: "456" }); - } catch (e: any) { - expect(e.constructor.name).toEqual("TransactionWriteFailedError"); - expect(e.errors).toEqual([ - new ConditionalCheckFailedError( - "ConditionalCheckFailed: Customer with ID '456' does not exist" - ) - ]); + + expect(updatedInstance).toEqual({ + pk: "test-pk", + sk: "test-sk", + id: "123", + type: "PaymentMethod", + lastFour: "5678", + customerId: "456", + createdAt: new Date("2023-10-01"), + updatedAt: now + }); expect(mockSend.mock.calls).toEqual([ [{ name: "GetCommand" }], [{ name: "TransactWriteCommand" }] @@ -1546,210 +4204,270 @@ describe("Update", () => { } ] ]); - } - }); + // Assert original instance not mutated + expect(instance).toEqual({ + pk: "test-pk", + sk: "test-sk", + id: "123", + type: "PaymentMethod", + lastFour: "1234", + customerId: "111", + createdAt: new Date("2023-10-01"), + updatedAt: new Date("2023-10-02") + }); + }); - it("will remove a nullable foreign key", async () => { - expect.assertions(6); + it("will throw an error if the entity being updated does not exist", async () => { + expect.assertions(7); - mockGet.mockResolvedValueOnce({ - Item: { - PK: "Pet#123", - SK: "Pet", - Id: "123", - name: "Fido", - OwnerId: undefined // Does not already belong an owner - } - }); + const instance = createInstance(PaymentMethod, { + pk: "test-pk" as PartitionKey, + sk: "test-sk" as SortKey, + id: "123", + type: "PaymentMethod", + lastFour: "1234", + customerId: "111" as ForeignKey, + createdAt: new Date("2023-10-01"), + updatedAt: new Date("2023-10-02") + }); - expect( - // eslint-disable-next-line @typescript-eslint/no-confusing-void-expression - await Pet.update("123", { - name: "New Name", - ownerId: null - }) - ).toBeUndefined(); - expect(mockSend.mock.calls).toEqual([ - [{ name: "GetCommand" }], - [{ name: "TransactWriteCommand" }] - ]); - expect(mockGet.mock.calls).toEqual([[]]); - expect(mockedGetCommand.mock.calls).toEqual([ - [ - { - TableName: "mock-table", - Key: { PK: "Pet#123", SK: "Pet" }, - ConsistentRead: true - } - ] - ]); - expect(mockTransact.mock.calls).toEqual([[]]); - expect(mockTransactWriteCommand.mock.calls).toEqual([ - [ - { - TransactItems: [ + mockGet.mockResolvedValueOnce({}); // Entity does not exist but will fail in transaction + mockSend.mockReturnValueOnce(undefined).mockImplementationOnce(() => { + mockTransact(); + throw new TransactionCanceledException({ + message: "MockMessage", + CancellationReasons: [ + { Code: "ConditionalCheckFailed" }, + { Code: "None" }, + { Code: "None" } + ], + $metadata: {} + }); + }); + + try { + await instance.update({ lastFour: "5678", customerId: "456" }); + } catch (e: any) { + expect(e.constructor.name).toEqual("TransactionWriteFailedError"); + expect(e.errors).toEqual([ + new ConditionalCheckFailedError( + "ConditionalCheckFailed: PaymentMethod with ID '123' does not exist" + ) + ]); + expect(mockSend.mock.calls).toEqual([ + [{ name: "GetCommand" }], + [{ name: "TransactWriteCommand" }] + ]); + expect(mockGet.mock.calls).toEqual([[]]); + expect(mockedGetCommand.mock.calls).toEqual([ + [ { - Update: { - TableName: "mock-table", - Key: { PK: "Pet#123", SK: "Pet" }, - ConditionExpression: "attribute_exists(PK)", - ExpressionAttributeNames: { - "#Name": "Name", - "#OwnerId": "OwnerId", - "#UpdatedAt": "UpdatedAt" + TableName: "mock-table", + Key: { PK: "PaymentMethod#123", SK: "PaymentMethod" }, + ConsistentRead: true + } + ] + ]); + expect(mockTransact.mock.calls).toEqual([[]]); + expect(mockTransactWriteCommand.mock.calls).toEqual([ + [ + { + TransactItems: [ + { + Update: { + TableName: "mock-table", + Key: { PK: "PaymentMethod#123", SK: "PaymentMethod" }, + UpdateExpression: + "SET #LastFour = :LastFour, #CustomerId = :CustomerId, #UpdatedAt = :UpdatedAt", + ConditionExpression: "attribute_exists(PK)", + ExpressionAttributeNames: { + "#CustomerId": "CustomerId", + "#LastFour": "LastFour", + "#UpdatedAt": "UpdatedAt" + }, + ExpressionAttributeValues: { + ":CustomerId": "456", + ":LastFour": "5678", + ":UpdatedAt": "2023-10-16T03:31:35.918Z" + } + } }, - ExpressionAttributeValues: { - ":Name": "New Name", - ":UpdatedAt": "2023-10-16T03:31:35.918Z" + { + ConditionCheck: { + TableName: "mock-table", + Key: { PK: "Customer#456", SK: "Customer" }, + ConditionExpression: "attribute_exists(PK)" + } }, - UpdateExpression: - "SET #Name = :Name, #UpdatedAt = :UpdatedAt REMOVE #OwnerId" - } + { + Put: { + TableName: "mock-table", + ConditionExpression: "attribute_not_exists(PK)", + Item: { + PK: "Customer#456", + SK: "PaymentMethod#123", + Id: "belongsToLinkId1", + Type: "BelongsToLink", + ForeignEntityType: "PaymentMethod", + ForeignKey: "123", + CreatedAt: "2023-10-16T03:31:35.918Z", + UpdatedAt: "2023-10-16T03:31:35.918Z" + } + } + } + ] } ] - } - ] - ]); - }); - }); - - describe("when the entity belongs to another another entity (Adds delete transaction for existing BelongsToLink)", () => { - beforeEach(() => { - jest.setSystemTime(new Date("2023-10-16T03:31:35.918Z")); - mockedUuidv4.mockReturnValueOnce("belongsToLinkId1"); - mockGet.mockResolvedValue({ - Item: { - PK: "PaymentMethod#123", - SK: "PaymentMethod", - Id: "123", - lastFour: "1234", - CustomerId: "789" // Already belongs to customer + ]); } }); - }); - afterEach(() => { - mockedUuidv4.mockReset(); - }); + it("will throw an error if the entity being associated with does not exist", async () => { + expect.assertions(7); - it("will update the foreign key if the entity being associated with exists", async () => { - expect.assertions(6); + const instance = createInstance(PaymentMethod, { + pk: "test-pk" as PartitionKey, + sk: "test-sk" as SortKey, + id: "123", + type: "PaymentMethod", + lastFour: "1234", + customerId: "111" as ForeignKey, + createdAt: new Date("2023-10-01"), + updatedAt: new Date("2023-10-02") + }); - expect( - // eslint-disable-next-line @typescript-eslint/no-confusing-void-expression - await PaymentMethod.update("123", { - lastFour: "5678", - customerId: "456" - }) - ).toBeUndefined(); - expect(mockSend.mock.calls).toEqual([ - [{ name: "GetCommand" }], - [{ name: "TransactWriteCommand" }] - ]); - expect(mockGet.mock.calls).toEqual([[]]); - expect(mockedGetCommand.mock.calls).toEqual([ - [ - { - TableName: "mock-table", - Key: { PK: "PaymentMethod#123", SK: "PaymentMethod" }, - ConsistentRead: true - } - ] - ]); - expect(mockTransact.mock.calls).toEqual([[]]); - expect(mockTransactWriteCommand.mock.calls).toEqual([ - [ - { - TransactItems: [ - { - Update: { - TableName: "mock-table", - Key: { PK: "PaymentMethod#123", SK: "PaymentMethod" }, - UpdateExpression: - "SET #LastFour = :LastFour, #CustomerId = :CustomerId, #UpdatedAt = :UpdatedAt", - ConditionExpression: "attribute_exists(PK)", - ExpressionAttributeNames: { - "#CustomerId": "CustomerId", - "#LastFour": "LastFour", - "#UpdatedAt": "UpdatedAt" - }, - ExpressionAttributeValues: { - ":CustomerId": "456", - ":LastFour": "5678", - ":UpdatedAt": "2023-10-16T03:31:35.918Z" - } - } - }, - { - ConditionCheck: { - TableName: "mock-table", - Key: { PK: "Customer#456", SK: "Customer" }, - ConditionExpression: "attribute_exists(PK)" - } - }, + mockSend.mockReturnValueOnce(undefined).mockImplementationOnce(() => { + mockTransact(); + throw new TransactionCanceledException({ + message: "MockMessage", + CancellationReasons: [ + { Code: "None" }, + { Code: "ConditionalCheckFailed" }, + { Code: "None" } + ], + $metadata: {} + }); + }); + + try { + await instance.update({ + lastFour: "5678", + customerId: "456" + }); + } catch (e: any) { + expect(e.constructor.name).toEqual("TransactionWriteFailedError"); + expect(e.errors).toEqual([ + new ConditionalCheckFailedError( + "ConditionalCheckFailed: Customer with ID '456' does not exist" + ) + ]); + expect(mockSend.mock.calls).toEqual([ + [{ name: "GetCommand" }], + [{ name: "TransactWriteCommand" }] + ]); + expect(mockGet.mock.calls).toEqual([[]]); + expect(mockedGetCommand.mock.calls).toEqual([ + [ { - // Delete old BelongsToLink - Delete: { - TableName: "mock-table", - Key: { - PK: "Customer#789", - SK: "PaymentMethod#123" - } - } - }, + TableName: "mock-table", + Key: { PK: "PaymentMethod#123", SK: "PaymentMethod" }, + ConsistentRead: true + } + ] + ]); + expect(mockTransact.mock.calls).toEqual([[]]); + expect(mockTransactWriteCommand.mock.calls).toEqual([ + [ { - Put: { - TableName: "mock-table", - ConditionExpression: "attribute_not_exists(PK)", - Item: { - PK: "Customer#456", - SK: "PaymentMethod#123", - Id: "belongsToLinkId1", - Type: "BelongsToLink", - ForeignEntityType: "PaymentMethod", - ForeignKey: "123", - CreatedAt: "2023-10-16T03:31:35.918Z", - UpdatedAt: "2023-10-16T03:31:35.918Z" + TransactItems: [ + { + Update: { + TableName: "mock-table", + Key: { PK: "PaymentMethod#123", SK: "PaymentMethod" }, + UpdateExpression: + "SET #LastFour = :LastFour, #CustomerId = :CustomerId, #UpdatedAt = :UpdatedAt", + ConditionExpression: "attribute_exists(PK)", + ExpressionAttributeNames: { + "#CustomerId": "CustomerId", + "#LastFour": "LastFour", + "#UpdatedAt": "UpdatedAt" + }, + ExpressionAttributeValues: { + ":CustomerId": "456", + ":LastFour": "5678", + ":UpdatedAt": "2023-10-16T03:31:35.918Z" + } + } + }, + { + ConditionCheck: { + TableName: "mock-table", + Key: { PK: "Customer#456", SK: "Customer" }, + ConditionExpression: "attribute_exists(PK)" + } + }, + { + Put: { + TableName: "mock-table", + ConditionExpression: "attribute_not_exists(PK)", + Item: { + PK: "Customer#456", + SK: "PaymentMethod#123", + Id: "belongsToLinkId1", + Type: "BelongsToLink", + ForeignEntityType: "PaymentMethod", + ForeignKey: "123", + CreatedAt: "2023-10-16T03:31:35.918Z", + UpdatedAt: "2023-10-16T03:31:35.918Z" + } + } } - } + ] } ] - } - ] - ]); - }); + ]); + } + }); - it("will throw an error if the entity being updated does not exist", async () => { - expect.assertions(7); + it("will remove a nullable foreign key", async () => { + expect.assertions(7); - mockGet.mockResolvedValueOnce({}); // Entity does not exist but will fail in transaction + const instance = createInstance(Pet, { + pk: "test-pk" as PartitionKey, + sk: "test-sk" as SortKey, + id: "123", + type: "Pet", + name: "fido", + ownerId: undefined, + createdAt: new Date("2023-10-01"), + updatedAt: new Date("2023-10-02") + }); - mockSend.mockReturnValueOnce(undefined).mockImplementationOnce(() => { - mockTransact(); - throw new TransactionCanceledException({ - message: "MockMessage", - CancellationReasons: [ - { Code: "ConditionalCheckFailed" }, - { Code: "None" }, - { Code: "None" }, - { Code: "None" } - ], - $metadata: {} + mockGet.mockResolvedValueOnce({ + Item: { + PK: "Pet#123", + SK: "Pet", + Id: "123", + name: "Fido", + OwnerId: undefined // Does not already belong an owner + } }); - }); - try { - await PaymentMethod.update("123", { - lastFour: "5678", - customerId: "456" + const updatedInstance = await instance.update({ + name: "New Name", + ownerId: null + }); + + expect(updatedInstance).toEqual({ + pk: "test-pk" as PartitionKey, + sk: "test-sk" as SortKey, + id: "123", + type: "Pet", + name: "New Name", + ownerId: undefined, + createdAt: new Date("2023-10-01"), + updatedAt: now }); - } catch (e: any) { - expect(e.constructor.name).toEqual("TransactionWriteFailedError"); - expect(e.errors).toEqual([ - new ConditionalCheckFailedError( - "ConditionalCheckFailed: PaymentMethod with ID '123' does not exist" - ) - ]); expect(mockSend.mock.calls).toEqual([ [{ name: "GetCommand" }], [{ name: "TransactWriteCommand" }] @@ -1759,95 +4477,104 @@ describe("Update", () => { [ { TableName: "mock-table", - Key: { PK: "PaymentMethod#123", SK: "PaymentMethod" }, + Key: { PK: "Pet#123", SK: "Pet" }, ConsistentRead: true } - ] - ]); - expect(mockTransact.mock.calls).toEqual([[]]); - expect(mockTransactWriteCommand.mock.calls).toEqual([ - [ - { - TransactItems: [ - { - Update: { - TableName: "mock-table", - Key: { PK: "PaymentMethod#123", SK: "PaymentMethod" }, - UpdateExpression: - "SET #LastFour = :LastFour, #CustomerId = :CustomerId, #UpdatedAt = :UpdatedAt", - ConditionExpression: "attribute_exists(PK)", - ExpressionAttributeNames: { - "#CustomerId": "CustomerId", - "#LastFour": "LastFour", - "#UpdatedAt": "UpdatedAt" - }, - ExpressionAttributeValues: { - ":CustomerId": "456", - ":LastFour": "5678", - ":UpdatedAt": "2023-10-16T03:31:35.918Z" - } - } - }, - { - ConditionCheck: { - TableName: "mock-table", - Key: { PK: "Customer#456", SK: "Customer" }, - ConditionExpression: "attribute_exists(PK)" - } - }, - // No Delete transaction because the item does not exist to look up the foreign key to build the delete operation with - { - Put: { - TableName: "mock-table", - ConditionExpression: "attribute_not_exists(PK)", - Item: { - PK: "Customer#456", - SK: "PaymentMethod#123", - Id: "belongsToLinkId1", - Type: "BelongsToLink", - ForeignEntityType: "PaymentMethod", - ForeignKey: "123", - CreatedAt: "2023-10-16T03:31:35.918Z", - UpdatedAt: "2023-10-16T03:31:35.918Z" - } + ] + ]); + expect(mockTransact.mock.calls).toEqual([[]]); + expect(mockTransactWriteCommand.mock.calls).toEqual([ + [ + { + TransactItems: [ + { + Update: { + TableName: "mock-table", + Key: { PK: "Pet#123", SK: "Pet" }, + ConditionExpression: "attribute_exists(PK)", + ExpressionAttributeNames: { + "#Name": "Name", + "#OwnerId": "OwnerId", + "#UpdatedAt": "UpdatedAt" + }, + ExpressionAttributeValues: { + ":Name": "New Name", + ":UpdatedAt": "2023-10-16T03:31:35.918Z" + }, + UpdateExpression: + "SET #Name = :Name, #UpdatedAt = :UpdatedAt REMOVE #OwnerId" } } ] } ] ]); - } + // Assert original instance not mutated + expect(instance).toEqual({ + pk: "test-pk", + sk: "test-sk", + id: "123", + type: "Pet", + name: "fido", + ownerId: undefined, + createdAt: new Date("2023-10-01"), + updatedAt: new Date("2023-10-02") + }); + }); }); - it("will throw an error if the entity being associated with does not exist", async () => { - expect.assertions(7); + describe("when the entity belongs to another another entity (Adds delete transaction for existing BelongsToLink)", () => { + const now = new Date("2023-10-16T03:31:35.918Z"); - mockSend.mockReturnValueOnce(undefined).mockImplementationOnce(() => { - mockTransact(); - throw new TransactionCanceledException({ - message: "MockMessage", - CancellationReasons: [ - { Code: "None" }, - { Code: "ConditionalCheckFailed" }, - { Code: "None" }, - { Code: "None" } - ], - $metadata: {} + const oldCustomerId = "789" as ForeignKey; + + beforeEach(() => { + jest.setSystemTime(now); + mockedUuidv4.mockReturnValueOnce("belongsToLinkId1"); + mockGet.mockResolvedValue({ + Item: { + PK: "PaymentMethod#123", + SK: "PaymentMethod", + Id: "123", + lastFour: "1234", + CustomerId: oldCustomerId // Already belongs to customer + } }); }); - try { - await PaymentMethod.update("123", { + afterEach(() => { + mockedUuidv4.mockReset(); + }); + + it("will update the foreign key if the entity being associated with exists", async () => { + expect.assertions(7); + + const instance = createInstance(PaymentMethod, { + pk: "test-pk" as PartitionKey, + sk: "test-sk" as SortKey, + id: "123", + type: "PaymentMethod", + lastFour: "1234", + customerId: oldCustomerId, + createdAt: new Date("2023-10-01"), + updatedAt: new Date("2023-10-02") + }); + + const updatedInstance = await instance.update({ lastFour: "5678", customerId: "456" }); - } catch (e: any) { - expect(e.constructor.name).toEqual("TransactionWriteFailedError"); - expect(e.errors).toEqual([ - new ConditionalCheckFailedError( - "ConditionalCheckFailed: Customer with ID '456' does not exist" - ) - ]); + + expect(updatedInstance).toEqual({ + pk: "test-pk", + sk: "test-sk", + id: "123", + type: "PaymentMethod", + lastFour: "5678", + customerId: "456", + createdAt: new Date("2023-10-01"), + updatedAt: now + }); expect(mockSend.mock.calls).toEqual([ [{ name: "GetCommand" }], [{ name: "TransactWriteCommand" }] @@ -1923,48 +4650,406 @@ describe("Update", () => { } ] ]); - } - }); + // Assert original instance is not mutated + expect(instance).toEqual({ + pk: "test-pk", + sk: "test-sk", + id: "123", + type: "PaymentMethod", + lastFour: "1234", + customerId: oldCustomerId, + createdAt: new Date("2023-10-01"), + updatedAt: new Date("2023-10-02") + }); + }); - it("will throw an error if the entity is already associated with the requested entity", async () => { - expect.assertions(7); + it("will throw an error if the entity being updated does not exist", async () => { + expect.assertions(7); - mockGet.mockResolvedValueOnce({ - Item: { - PK: "PaymentMethod#123", - SK: "PaymentMethod", - Id: "123", + const instance = createInstance(PaymentMethod, { + pk: "test-pk" as PartitionKey, + sk: "test-sk" as SortKey, + id: "123", + type: "PaymentMethod", + lastFour: "1234", + customerId: oldCustomerId, + createdAt: new Date("2023-10-01"), + updatedAt: new Date("2023-10-02") + }); + + mockGet.mockResolvedValueOnce({}); // Entity does not exist but will fail in transaction + + mockSend.mockReturnValueOnce(undefined).mockImplementationOnce(() => { + mockTransact(); + throw new TransactionCanceledException({ + message: "MockMessage", + CancellationReasons: [ + { Code: "ConditionalCheckFailed" }, + { Code: "None" }, + { Code: "None" }, + { Code: "None" } + ], + $metadata: {} + }); + }); + + try { + await instance.update({ lastFour: "5678", customerId: "456" }); + } catch (e: any) { + expect(e.constructor.name).toEqual("TransactionWriteFailedError"); + expect(e.errors).toEqual([ + new ConditionalCheckFailedError( + "ConditionalCheckFailed: PaymentMethod with ID '123' does not exist" + ) + ]); + expect(mockSend.mock.calls).toEqual([ + [{ name: "GetCommand" }], + [{ name: "TransactWriteCommand" }] + ]); + expect(mockGet.mock.calls).toEqual([[]]); + expect(mockedGetCommand.mock.calls).toEqual([ + [ + { + TableName: "mock-table", + Key: { PK: "PaymentMethod#123", SK: "PaymentMethod" }, + ConsistentRead: true + } + ] + ]); + expect(mockTransact.mock.calls).toEqual([[]]); + expect(mockTransactWriteCommand.mock.calls).toEqual([ + [ + { + TransactItems: [ + { + Update: { + TableName: "mock-table", + Key: { PK: "PaymentMethod#123", SK: "PaymentMethod" }, + UpdateExpression: + "SET #LastFour = :LastFour, #CustomerId = :CustomerId, #UpdatedAt = :UpdatedAt", + ConditionExpression: "attribute_exists(PK)", + ExpressionAttributeNames: { + "#CustomerId": "CustomerId", + "#LastFour": "LastFour", + "#UpdatedAt": "UpdatedAt" + }, + ExpressionAttributeValues: { + ":CustomerId": "456", + ":LastFour": "5678", + ":UpdatedAt": "2023-10-16T03:31:35.918Z" + } + } + }, + { + ConditionCheck: { + TableName: "mock-table", + Key: { PK: "Customer#456", SK: "Customer" }, + ConditionExpression: "attribute_exists(PK)" + } + }, + // No Delete transaction because the item does not exist to look up the foreign key to build the delete operation with + { + Put: { + TableName: "mock-table", + ConditionExpression: "attribute_not_exists(PK)", + Item: { + PK: "Customer#456", + SK: "PaymentMethod#123", + Id: "belongsToLinkId1", + Type: "BelongsToLink", + ForeignEntityType: "PaymentMethod", + ForeignKey: "123", + CreatedAt: "2023-10-16T03:31:35.918Z", + UpdatedAt: "2023-10-16T03:31:35.918Z" + } + } + } + ] + } + ] + ]); + } + }); + + it("will throw an error if the entity being associated with does not exist", async () => { + expect.assertions(7); + + const instance = createInstance(PaymentMethod, { + pk: "test-pk" as PartitionKey, + sk: "test-sk" as SortKey, + id: "123", + type: "PaymentMethod", lastFour: "1234", - CustomerId: "456" // Already belongs to customer, the same being updated + customerId: oldCustomerId, + createdAt: new Date("2023-10-01"), + updatedAt: new Date("2023-10-02") + }); + + mockSend.mockReturnValueOnce(undefined).mockImplementationOnce(() => { + mockTransact(); + throw new TransactionCanceledException({ + message: "MockMessage", + CancellationReasons: [ + { Code: "None" }, + { Code: "ConditionalCheckFailed" }, + { Code: "None" }, + { Code: "None" } + ], + $metadata: {} + }); + }); + + try { + await instance.update({ lastFour: "5678", customerId: "456" }); + } catch (e: any) { + expect(e.constructor.name).toEqual("TransactionWriteFailedError"); + expect(e.errors).toEqual([ + new ConditionalCheckFailedError( + "ConditionalCheckFailed: Customer with ID '456' does not exist" + ) + ]); + expect(mockSend.mock.calls).toEqual([ + [{ name: "GetCommand" }], + [{ name: "TransactWriteCommand" }] + ]); + expect(mockGet.mock.calls).toEqual([[]]); + expect(mockedGetCommand.mock.calls).toEqual([ + [ + { + TableName: "mock-table", + Key: { PK: "PaymentMethod#123", SK: "PaymentMethod" }, + ConsistentRead: true + } + ] + ]); + expect(mockTransact.mock.calls).toEqual([[]]); + expect(mockTransactWriteCommand.mock.calls).toEqual([ + [ + { + TransactItems: [ + { + Update: { + TableName: "mock-table", + Key: { PK: "PaymentMethod#123", SK: "PaymentMethod" }, + UpdateExpression: + "SET #LastFour = :LastFour, #CustomerId = :CustomerId, #UpdatedAt = :UpdatedAt", + ConditionExpression: "attribute_exists(PK)", + ExpressionAttributeNames: { + "#CustomerId": "CustomerId", + "#LastFour": "LastFour", + "#UpdatedAt": "UpdatedAt" + }, + ExpressionAttributeValues: { + ":CustomerId": "456", + ":LastFour": "5678", + ":UpdatedAt": "2023-10-16T03:31:35.918Z" + } + } + }, + { + ConditionCheck: { + TableName: "mock-table", + Key: { PK: "Customer#456", SK: "Customer" }, + ConditionExpression: "attribute_exists(PK)" + } + }, + { + // Delete old BelongsToLink + Delete: { + TableName: "mock-table", + Key: { + PK: "Customer#789", + SK: "PaymentMethod#123" + } + } + }, + { + Put: { + TableName: "mock-table", + ConditionExpression: "attribute_not_exists(PK)", + Item: { + PK: "Customer#456", + SK: "PaymentMethod#123", + Id: "belongsToLinkId1", + Type: "BelongsToLink", + ForeignEntityType: "PaymentMethod", + ForeignKey: "123", + CreatedAt: "2023-10-16T03:31:35.918Z", + UpdatedAt: "2023-10-16T03:31:35.918Z" + } + } + } + ] + } + ] + ]); } }); - mockSend.mockReturnValueOnce(undefined).mockImplementationOnce(() => { - mockTransact(); - throw new TransactionCanceledException({ - message: "MockMessage", - CancellationReasons: [ - { Code: "None" }, - { Code: "None" }, - { Code: "None" }, - { Code: "ConditionalCheckFailed" } - ], - $metadata: {} + it("will throw an error if the entity is already associated with the requested entity", async () => { + expect.assertions(7); + + const instance = createInstance(PaymentMethod, { + pk: "test-pk" as PartitionKey, + sk: "test-sk" as SortKey, + id: "123", + type: "PaymentMethod", + lastFour: "1234", + customerId: oldCustomerId, + createdAt: new Date("2023-10-01"), + updatedAt: new Date("2023-10-02") + }); + + mockGet.mockResolvedValueOnce({ + Item: { + PK: "PaymentMethod#123", + SK: "PaymentMethod", + Id: "123", + lastFour: "1234", + CustomerId: "456" // Already belongs to customer, the same being updated + } + }); + + mockSend.mockReturnValueOnce(undefined).mockImplementationOnce(() => { + mockTransact(); + throw new TransactionCanceledException({ + message: "MockMessage", + CancellationReasons: [ + { Code: "None" }, + { Code: "None" }, + { Code: "None" }, + { Code: "ConditionalCheckFailed" } + ], + $metadata: {} + }); }); + + try { + await instance.update({ lastFour: "5678", customerId: "456" }); + } catch (e: any) { + expect(e.constructor.name).toEqual("TransactionWriteFailedError"); + expect(e.errors).toEqual([ + new ConditionalCheckFailedError( + "ConditionalCheckFailed: PaymentMethod with ID '123' already belongs to Customer with Id '456'" + ) + ]); + expect(mockSend.mock.calls).toEqual([ + [{ name: "GetCommand" }], + [{ name: "TransactWriteCommand" }] + ]); + expect(mockGet.mock.calls).toEqual([[]]); + expect(mockedGetCommand.mock.calls).toEqual([ + [ + { + TableName: "mock-table", + Key: { PK: "PaymentMethod#123", SK: "PaymentMethod" }, + ConsistentRead: true + } + ] + ]); + expect(mockTransact.mock.calls).toEqual([[]]); + expect(mockTransactWriteCommand.mock.calls).toEqual([ + [ + { + TransactItems: [ + { + Update: { + TableName: "mock-table", + Key: { PK: "PaymentMethod#123", SK: "PaymentMethod" }, + UpdateExpression: + "SET #LastFour = :LastFour, #CustomerId = :CustomerId, #UpdatedAt = :UpdatedAt", + ConditionExpression: "attribute_exists(PK)", + ExpressionAttributeNames: { + "#CustomerId": "CustomerId", + "#LastFour": "LastFour", + "#UpdatedAt": "UpdatedAt" + }, + ExpressionAttributeValues: { + ":CustomerId": "456", + ":LastFour": "5678", + ":UpdatedAt": "2023-10-16T03:31:35.918Z" + } + } + }, + { + ConditionCheck: { + TableName: "mock-table", + Key: { PK: "Customer#456", SK: "Customer" }, + ConditionExpression: "attribute_exists(PK)" + } + }, + { + // Delete old BelongsToLink + Delete: { + TableName: "mock-table", + Key: { + PK: "Customer#456", + SK: "PaymentMethod#123" + } + } + }, + { + Put: { + TableName: "mock-table", + ConditionExpression: "attribute_not_exists(PK)", + Item: { + PK: "Customer#456", + SK: "PaymentMethod#123", + Id: "belongsToLinkId1", + Type: "BelongsToLink", + ForeignEntityType: "PaymentMethod", + ForeignKey: "123", + CreatedAt: "2023-10-16T03:31:35.918Z", + UpdatedAt: "2023-10-16T03:31:35.918Z" + } + } + } + ] + } + ] + ]); + } }); - try { - await PaymentMethod.update("123", { - lastFour: "5678", - customerId: "456" + it("will remove a nullable foreign key and delete the associated BelongsToLinks", async () => { + expect.assertions(7); + + const instance = createInstance(Pet, { + pk: "test-pk" as PartitionKey, + sk: "test-sk" as SortKey, + id: "123", + type: "Pet", + name: "fido", + ownerId: "456" as NullableForeignKey, + createdAt: new Date("2023-10-01"), + updatedAt: new Date("2023-10-02") + }); + + mockGet.mockResolvedValueOnce({ + Item: { + PK: "Pet#123", + SK: "Pet", + Id: "123", + name: "Fido", + OwnerId: "456" // Does not already belong an owner + } + }); + + const updatedInstance = await instance.update({ + name: "New Name", + ownerId: null + }); + + expect(updatedInstance).toEqual({ + pk: "test-pk", + sk: "test-sk", + id: "123", + type: "Pet", + name: "New Name", + ownerId: undefined, + createdAt: new Date("2023-10-01"), + updatedAt: now }); - } catch (e: any) { - expect(e.constructor.name).toEqual("TransactionWriteFailedError"); - expect(e.errors).toEqual([ - new ConditionalCheckFailedError( - "ConditionalCheckFailed: PaymentMethod with ID '123' already belongs to Customer with Id '456'" - ) - ]); expect(mockSend.mock.calls).toEqual([ [{ name: "GetCommand" }], [{ name: "TransactWriteCommand" }] @@ -1974,7 +5059,7 @@ describe("Update", () => { [ { TableName: "mock-table", - Key: { PK: "PaymentMethod#123", SK: "PaymentMethod" }, + Key: { PK: "Pet#123", SK: "Pet" }, ConsistentRead: true } ] @@ -1987,82 +5072,280 @@ describe("Update", () => { { Update: { TableName: "mock-table", - Key: { PK: "PaymentMethod#123", SK: "PaymentMethod" }, - UpdateExpression: - "SET #LastFour = :LastFour, #CustomerId = :CustomerId, #UpdatedAt = :UpdatedAt", + Key: { PK: "Pet#123", SK: "Pet" }, ConditionExpression: "attribute_exists(PK)", ExpressionAttributeNames: { - "#CustomerId": "CustomerId", - "#LastFour": "LastFour", + "#Name": "Name", + "#OwnerId": "OwnerId", "#UpdatedAt": "UpdatedAt" }, ExpressionAttributeValues: { - ":CustomerId": "456", - ":LastFour": "5678", + ":Name": "New Name", ":UpdatedAt": "2023-10-16T03:31:35.918Z" - } - } - }, - { - ConditionCheck: { - TableName: "mock-table", - Key: { PK: "Customer#456", SK: "Customer" }, - ConditionExpression: "attribute_exists(PK)" + }, + UpdateExpression: + "SET #Name = :Name, #UpdatedAt = :UpdatedAt REMOVE #OwnerId" } }, { - // Delete old BelongsToLink Delete: { TableName: "mock-table", - Key: { - PK: "Customer#456", - SK: "PaymentMethod#123" - } + Key: { PK: "Person#456", SK: "Pet#123" } } - }, - { - Put: { - TableName: "mock-table", - ConditionExpression: "attribute_not_exists(PK)", - Item: { - PK: "Customer#456", - SK: "PaymentMethod#123", - Id: "belongsToLinkId1", - Type: "BelongsToLink", - ForeignEntityType: "PaymentMethod", - ForeignKey: "123", - CreatedAt: "2023-10-16T03:31:35.918Z", - UpdatedAt: "2023-10-16T03:31:35.918Z" - } + } + ] + } + ] + ]); + // Assert original instance not mutated + expect(instance).toEqual({ + pk: "test-pk", + sk: "test-sk", + id: "123", + type: "Pet", + name: "fido", + ownerId: "456", + createdAt: new Date("2023-10-01"), + updatedAt: new Date("2023-10-02") + }); + }); + }); + }); + + describe("A model is updating multiple ForeignKeys of different relationship types", () => { + @Entity + class OtherModel1 extends MockTable { + @HasOne(() => OtherModel3, { foreignKey: "model1Id" }) + public model3: OtherModel3; + } + + @Entity + class OtherModel2 extends MockTable { + @HasMany(() => OtherModel3, { foreignKey: "model2Id" }) + public model3: OtherModel3[]; + } + + @Entity + class OtherModel3 extends MockTable { + @StringAttribute({ alias: "Name" }) + public name: string; + + @ForeignKeyAttribute({ alias: "Model1Id" }) + public model1Id: ForeignKey; + + @ForeignKeyAttribute({ alias: "Model2Id" }) + public model2Id: ForeignKey; + + @BelongsTo(() => OtherModel1, { foreignKey: "model1Id" }) + public model1: OtherModel1; + + @BelongsTo(() => OtherModel2, { foreignKey: "model2Id" }) + public model2: OtherModel2; + } + + const now = new Date("2023-10-16T03:31:35.918Z"); + + beforeEach(() => { + jest.setSystemTime(now); + mockedUuidv4 + .mockReturnValueOnce("belongsToLinkId1") + .mockReturnValueOnce("belongsToLinkId2"); + }); + + it("can update foreign keys for an entity that includes both HasMany and Belongs to relationships", async () => { + expect.assertions(7); + + const instance = createInstance(OtherModel3, { + pk: "test-pk" as PartitionKey, + sk: "test-sk" as SortKey, + id: "123", + type: "OtherModel3", + name: "test-name", + model1Id: "model1Id" as ForeignKey, + model2Id: "model2Id" as ForeignKey, + createdAt: new Date("2023-10-01"), + updatedAt: new Date("2023-10-02") + }); + + mockGet.mockResolvedValue({ + Item: { + PK: "OtherModel3#123", + SK: "OtherModel3", + Id: "123", + Name: "originalName", + Phone: "555-555-5555", + Model1Id: undefined, + Model2Id: undefined + } + }); + + const updatedInstance = await instance.update({ + name: "newName", + model1Id: "model1-ID", + model2Id: "model2-ID" + }); + + expect(updatedInstance).toEqual({ + pk: "test-pk", + sk: "test-sk", + id: "123", + type: "OtherModel3", + name: "newName", + model1Id: "model1-ID", + model2Id: "model2-ID", + createdAt: new Date("2023-10-01"), + updatedAt: now + }); + expect(mockSend.mock.calls).toEqual([ + [{ name: "GetCommand" }], + [{ name: "TransactWriteCommand" }] + ]); + expect(mockGet.mock.calls).toEqual([[]]); + expect(mockedGetCommand.mock.calls).toEqual([ + [ + { + TableName: "mock-table", + Key: { PK: "OtherModel3#123", SK: "OtherModel3" }, + ConsistentRead: true + } + ] + ]); + expect(mockTransact.mock.calls).toEqual([[]]); + expect(mockTransactWriteCommand.mock.calls).toEqual([ + [ + { + TransactItems: [ + { + Update: { + TableName: "mock-table", + Key: { PK: "OtherModel3#123", SK: "OtherModel3" }, + UpdateExpression: + "SET #Name = :Name, #Model1Id = :Model1Id, #Model2Id = :Model2Id, #UpdatedAt = :UpdatedAt", + ConditionExpression: "attribute_exists(PK)", + ExpressionAttributeNames: { + "#Model1Id": "Model1Id", + "#Model2Id": "Model2Id", + "#Name": "Name", + "#UpdatedAt": "UpdatedAt" + }, + ExpressionAttributeValues: { + ":Model1Id": "model1-ID", + ":Model2Id": "model2-ID", + ":Name": "newName", + ":UpdatedAt": "2023-10-16T03:31:35.918Z" + } + } + }, + { + ConditionCheck: { + TableName: "mock-table", + Key: { PK: "OtherModel1#model1-ID", SK: "OtherModel1" }, + ConditionExpression: "attribute_exists(PK)" + } + }, + { + Put: { + TableName: "mock-table", + ConditionExpression: "attribute_not_exists(PK)", + Item: { + PK: "OtherModel1#model1-ID", + SK: "OtherModel3", + Id: "belongsToLinkId1", + Type: "BelongsToLink", + ForeignEntityType: "OtherModel3", + ForeignKey: "123", + CreatedAt: "2023-10-16T03:31:35.918Z", + UpdatedAt: "2023-10-16T03:31:35.918Z" } } - ] - } - ] - ]); - } + }, + { + ConditionCheck: { + TableName: "mock-table", + Key: { PK: "OtherModel2#model2-ID", SK: "OtherModel2" }, + ConditionExpression: "attribute_exists(PK)" + } + }, + { + Put: { + TableName: "mock-table", + ConditionExpression: "attribute_not_exists(PK)", + Item: { + PK: "OtherModel2#model2-ID", + SK: "OtherModel3#123", + Id: "belongsToLinkId2", + Type: "BelongsToLink", + ForeignEntityType: "OtherModel3", + ForeignKey: "123", + CreatedAt: "2023-10-16T03:31:35.918Z", + UpdatedAt: "2023-10-16T03:31:35.918Z" + } + } + } + ] + } + ] + ]); + // Assert original instance not mutated + expect(instance).toEqual({ + pk: "test-pk", + sk: "test-sk", + id: "123", + type: "OtherModel3", + name: "test-name", + model1Id: "model1Id", + model2Id: "model2Id", + createdAt: new Date("2023-10-01"), + updatedAt: new Date("2023-10-02") + }); }); - it("will remove a nullable foreign key and delete the associated BelongsToLinks", async () => { - expect.assertions(6); + it("alternate table (different alias/keys) - can update foreign keys for an entity that includes both HasMany and Belongs to relationships", async () => { + expect.assertions(7); + + const instance = createInstance(Grade, { + myPk: "Grade#123" as PartitionKey, + mySk: "Grade" as SortKey, + id: "123", + type: "Grade", + gradeValue: "A+", + assignmentId: "456" as ForeignKey, + studentId: "789" as ForeignKey, + createdAt: new Date("2023-10-01"), + updatedAt: new Date("2023-10-02") + }); mockGet.mockResolvedValueOnce({ Item: { - PK: "Pet#123", - SK: "Pet", - Id: "123", - name: "Fido", - OwnerId: "456" // Does not already belong an owner + myPk: "Grade|123", + mySk: "Grade", + id: "123", + type: "Grade", + gradeValue: "A+", + assignmentId: "456", + studentId: "789", + createdAt: "2023-10-16T03:31:35.918Z", + updatedAt: "2023-10-16T03:31:35.918Z" } }); - expect( - // eslint-disable-next-line @typescript-eslint/no-confusing-void-expression - await Pet.update("123", { - name: "New Name", - ownerId: null - }) - ).toBeUndefined(); + const updatedInstance = await instance.update({ + gradeValue: "B", + assignmentId: "111", + studentId: "222" + }); + + expect(updatedInstance).toEqual({ + myPk: "Grade#123", + mySk: "Grade", + id: "123", + type: "Grade", + gradeValue: "B", + assignmentId: "111", + studentId: "222", + createdAt: new Date("2023-10-01"), + updatedAt: now + }); expect(mockSend.mock.calls).toEqual([ [{ name: "GetCommand" }], [{ name: "TransactWriteCommand" }] @@ -2071,8 +5354,8 @@ describe("Update", () => { expect(mockedGetCommand.mock.calls).toEqual([ [ { - TableName: "mock-table", - Key: { PK: "Pet#123", SK: "Pet" }, + TableName: "other-table", + Key: { myPk: "Grade|123", mySk: "Grade" }, ConsistentRead: true } ] @@ -2084,415 +5367,223 @@ describe("Update", () => { TransactItems: [ { Update: { - TableName: "mock-table", - Key: { PK: "Pet#123", SK: "Pet" }, - ConditionExpression: "attribute_exists(PK)", + TableName: "other-table", + Key: { myPk: "Grade|123", mySk: "Grade" }, + UpdateExpression: + "SET #LetterValue = :LetterValue, #assignmentId = :assignmentId, #studentId = :studentId, #updatedAt = :updatedAt", + ConditionExpression: "attribute_exists(myPk)", ExpressionAttributeNames: { - "#Name": "Name", - "#OwnerId": "OwnerId", - "#UpdatedAt": "UpdatedAt" + "#LetterValue": "LetterValue", + "#assignmentId": "assignmentId", + "#studentId": "studentId", + "#updatedAt": "updatedAt" }, ExpressionAttributeValues: { - ":Name": "New Name", - ":UpdatedAt": "2023-10-16T03:31:35.918Z" - }, - UpdateExpression: - "SET #Name = :Name, #UpdatedAt = :UpdatedAt REMOVE #OwnerId" + ":LetterValue": "B", + ":assignmentId": "111", + ":studentId": "222", + ":updatedAt": "2023-10-16T03:31:35.918Z" + } + } + }, + { + ConditionCheck: { + TableName: "other-table", + Key: { myPk: "Assignment|111", mySk: "Assignment" }, + ConditionExpression: "attribute_exists(myPk)" } }, { Delete: { - TableName: "mock-table", - Key: { PK: "Person#456", SK: "Pet#123" } + TableName: "other-table", + Key: { myPk: "Assignment|456", mySk: "Grade" } + } + }, + { + Put: { + TableName: "other-table", + ConditionExpression: "attribute_not_exists(myPk)", + Item: { + myPk: "Assignment|111", + mySk: "Grade", + id: "belongsToLinkId1", + type: "BelongsToLink", + foreignKey: "123", + foreignEntityType: "Grade", + createdAt: "2023-10-16T03:31:35.918Z", + updatedAt: "2023-10-16T03:31:35.918Z" + } + } + }, + { + ConditionCheck: { + TableName: "other-table", + Key: { myPk: "Student|222", mySk: "Student" }, + ConditionExpression: "attribute_exists(myPk)" + } + }, + { + Delete: { + TableName: "other-table", + Key: { myPk: "Student|789", mySk: "Grade|123" } + } + }, + { + Put: { + TableName: "other-table", + ConditionExpression: "attribute_not_exists(myPk)", + Item: { + myPk: "Student|222", + mySk: "Grade|123", + id: "belongsToLinkId2", + type: "BelongsToLink", + foreignKey: "123", + foreignEntityType: "Grade", + createdAt: "2023-10-16T03:31:35.918Z", + updatedAt: "2023-10-16T03:31:35.918Z" + } } } ] } ] ]); - }); - }); - }); - - describe("A model is updating mutiple ForeignKeys of different relationship types", () => { - @Entity - class Model1 extends MockTable { - @HasOne(() => Model3, { foreignKey: "model1Id" }) - public model3: Model3; - } - - @Entity - class Model2 extends MockTable { - @HasMany(() => Model3, { foreignKey: "model2Id" }) - public model3: Model3[]; - } - - @Entity - class Model3 extends MockTable { - @StringAttribute({ alias: "Name" }) - public name: string; - - @ForeignKeyAttribute({ alias: "Model1Id" }) - public model1Id: ForeignKey; - - @ForeignKeyAttribute({ alias: "Model2Id" }) - public model2Id: ForeignKey; - - @BelongsTo(() => Model1, { foreignKey: "model1Id" }) - public model1: Model1; - - @BelongsTo(() => Model2, { foreignKey: "model2Id" }) - public model2: Model2; - } - - beforeEach(() => { - jest.setSystemTime(new Date("2023-10-16T03:31:35.918Z")); - mockedUuidv4 - .mockReturnValueOnce("belongsToLinkId1") - .mockReturnValueOnce("belongsToLinkId2"); - }); - - it("can update foreign keys for an entity that includes both HasMany and Belongs to relationships", async () => { - expect.assertions(6); - - mockGet.mockResolvedValue({ - Item: { - PK: "Model3#123", - SK: "Model3", - Id: "123", - Name: "originalName", - Phone: "555-555-5555", - Model1Id: undefined, - Model2Id: undefined - } - }); - - expect( - // eslint-disable-next-line @typescript-eslint/no-confusing-void-expression - await Model3.update("123", { - name: "newName", - model1Id: "model1-ID", - model2Id: "model2-ID" - }) - ).toBeUndefined(); - expect(mockSend.mock.calls).toEqual([ - [{ name: "GetCommand" }], - [{ name: "TransactWriteCommand" }] - ]); - expect(mockGet.mock.calls).toEqual([[]]); - expect(mockedGetCommand.mock.calls).toEqual([ - [ - { - TableName: "mock-table", - Key: { PK: "Model3#123", SK: "Model3" }, - ConsistentRead: true - } - ] - ]); - expect(mockTransact.mock.calls).toEqual([[]]); - expect(mockTransactWriteCommand.mock.calls).toEqual([ - [ - { - TransactItems: [ - { - Update: { - TableName: "mock-table", - Key: { PK: "Model3#123", SK: "Model3" }, - UpdateExpression: - "SET #Name = :Name, #Model1Id = :Model1Id, #Model2Id = :Model2Id, #UpdatedAt = :UpdatedAt", - ConditionExpression: "attribute_exists(PK)", - ExpressionAttributeNames: { - "#Model1Id": "Model1Id", - "#Model2Id": "Model2Id", - "#Name": "Name", - "#UpdatedAt": "UpdatedAt" - }, - ExpressionAttributeValues: { - ":Model1Id": "model1-ID", - ":Model2Id": "model2-ID", - ":Name": "newName", - ":UpdatedAt": "2023-10-16T03:31:35.918Z" - } - } - }, - { - ConditionCheck: { - TableName: "mock-table", - Key: { PK: "Model1#model1-ID", SK: "Model1" }, - ConditionExpression: "attribute_exists(PK)" - } - }, - { - Put: { - TableName: "mock-table", - ConditionExpression: "attribute_not_exists(PK)", - Item: { - PK: "Model1#model1-ID", - SK: "Model3", - Id: "belongsToLinkId1", - Type: "BelongsToLink", - ForeignEntityType: "Model3", - ForeignKey: "123", - CreatedAt: "2023-10-16T03:31:35.918Z", - UpdatedAt: "2023-10-16T03:31:35.918Z" - } - } - }, - { - ConditionCheck: { - TableName: "mock-table", - Key: { PK: "Model2#model2-ID", SK: "Model2" }, - ConditionExpression: "attribute_exists(PK)" - } - }, - { - Put: { - TableName: "mock-table", - ConditionExpression: "attribute_not_exists(PK)", - Item: { - PK: "Model2#model2-ID", - SK: "Model3#123", - Id: "belongsToLinkId2", - Type: "BelongsToLink", - ForeignEntityType: "Model3", - ForeignKey: "123", - CreatedAt: "2023-10-16T03:31:35.918Z", - UpdatedAt: "2023-10-16T03:31:35.918Z" - } - } - } - ] - } - ] - ]); - }); - - it("alternate table (different alias/keys) - can update foreign keys for an entity that includes both HasMany and Belongs to relationships", async () => { - expect.assertions(6); - - mockGet.mockResolvedValueOnce({ - Item: { - myPk: "Grade|123", + // Assert original instance not mutated + expect(instance).toEqual({ + myPk: "Grade#123", mySk: "Grade", id: "123", type: "Grade", gradeValue: "A+", assignmentId: "456", studentId: "789", - createdAt: "2023-10-16T03:31:35.918Z", - updatedAt: "2023-10-16T03:31:35.918Z" - } + createdAt: new Date("2023-10-01"), + updatedAt: new Date("2023-10-02") + }); }); - - expect( - // eslint-disable-next-line @typescript-eslint/no-confusing-void-expression - await Grade.update("123", { - gradeValue: "B", - assignmentId: "111", - studentId: "222" - }) - ).toBeUndefined(); - expect(mockSend.mock.calls).toEqual([ - [{ name: "GetCommand" }], - [{ name: "TransactWriteCommand" }] - ]); - expect(mockGet.mock.calls).toEqual([[]]); - expect(mockedGetCommand.mock.calls).toEqual([ - [ - { - TableName: "other-table", - Key: { myPk: "Grade|123", mySk: "Grade" }, - ConsistentRead: true - } - ] - ]); - expect(mockTransact.mock.calls).toEqual([[]]); - expect(mockTransactWriteCommand.mock.calls).toEqual([ - [ - { - TransactItems: [ - { - Update: { - TableName: "other-table", - Key: { myPk: "Grade|123", mySk: "Grade" }, - UpdateExpression: - "SET #LetterValue = :LetterValue, #assignmentId = :assignmentId, #studentId = :studentId, #updatedAt = :updatedAt", - ConditionExpression: "attribute_exists(myPk)", - ExpressionAttributeNames: { - "#LetterValue": "LetterValue", - "#assignmentId": "assignmentId", - "#studentId": "studentId", - "#updatedAt": "updatedAt" - }, - ExpressionAttributeValues: { - ":LetterValue": "B", - ":assignmentId": "111", - ":studentId": "222", - ":updatedAt": "2023-10-16T03:31:35.918Z" - } - } - }, - { - ConditionCheck: { - TableName: "other-table", - Key: { myPk: "Assignment|111", mySk: "Assignment" }, - ConditionExpression: "attribute_exists(myPk)" - } - }, - { - Delete: { - TableName: "other-table", - Key: { myPk: "Assignment|456", mySk: "Grade" } - } - }, - { - Put: { - TableName: "other-table", - ConditionExpression: "attribute_not_exists(myPk)", - Item: { - myPk: "Assignment|111", - mySk: "Grade", - id: "belongsToLinkId1", - type: "BelongsToLink", - foreignKey: "123", - foreignEntityType: "Grade", - createdAt: "2023-10-16T03:31:35.918Z", - updatedAt: "2023-10-16T03:31:35.918Z" - } - } - }, - { - ConditionCheck: { - TableName: "other-table", - Key: { myPk: "Student|222", mySk: "Student" }, - ConditionExpression: "attribute_exists(myPk)" - } - }, - { - Delete: { - TableName: "other-table", - Key: { myPk: "Student|789", mySk: "Grade|123" } - } - }, - { - Put: { - TableName: "other-table", - ConditionExpression: "attribute_not_exists(myPk)", - Item: { - myPk: "Student|222", - mySk: "Grade|123", - id: "belongsToLinkId2", - type: "BelongsToLink", - foreignKey: "123", - foreignEntityType: "Grade", - createdAt: "2023-10-16T03:31:35.918Z", - updatedAt: "2023-10-16T03:31:35.918Z" - } - } - } - ] - } - ] - ]); }); - }); - describe("types", () => { - it("will not accept relationship attributes on update", async () => { - await Order.update("123", { - orderDate: new Date(), - paymentMethodId: "123", - customerId: "456", - // @ts-expect-error relationship attributes are not allowed - customer: new Customer() + describe("types", () => { + it("will not accept relationship attributes on update", async () => { + const instance = new Order(); + + await instance.update({ + orderDate: new Date(), + paymentMethodId: "123", + customerId: "456", + // @ts-expect-error relationship attributes are not allowed + customer: new Customer() + }); }); - }); - it("will not accept function attributes on update", async () => { - @Entity - class MyModel extends MockTable { - @StringAttribute({ alias: "MyAttribute" }) - public myAttribute: string; + it("will not accept function attributes on update", async () => { + @Entity + class MyModel extends MockTable { + @StringAttribute({ alias: "MyAttribute" }) + public myAttribute: string; - public someMethod(): string { - return "abc123"; + public someMethod(): string { + return "abc123"; + } } - } - await MyModel.update("123", { - myAttribute: "someVal", - // @ts-expect-error function attributes are not allowed - someMethod: () => "123" - }); - }); + const instance = new MyModel(); - it("will allow ForeignKey attributes to be passed at their inferred type without casting to type ForeignKey", async () => { - await Order.update("123", { - orderDate: new Date(), - // @ts-expect-no-error ForeignKey is of type string so it can be passed as such without casing to ForeignKey - paymentMethodId: "123", - // @ts-expect-no-error ForeignKey is of type string so it can be passed as such without casing to ForeignKey - customerId: "456" - }); - }); + // check that built in instance method is not allowed + await instance.update({ + myAttribute: "someVal", + // @ts-expect-error function attributes are not allowed + update: () => "123" + }); - it("will not accept DefaultFields on update because they are managed by dyna-record", async () => { - await Order.update("123", { - // @ts-expect-error default fields are not accepted on update, they are managed by dyna-record - id: "123" + // check that custom instance method is not allowed + await instance.update({ + myAttribute: "someVal", + // @ts-expect-error function attributes are not allowed + someMethod: () => "123" + }); }); - await Order.update("123", { - // @ts-expect-error default fields are not accepted on update, they are managed by dyna-record - type: "456" - }); + it("will allow ForeignKey attributes to be passed at their inferred type without casting to type ForeignKey", async () => { + const instance = new Order(); - await Order.update("123", { - // @ts-expect-error default fields are not accepted on update, they are managed by dyna-record - createdAt: new Date() + await instance.update({ + orderDate: new Date(), + // @ts-expect-no-error ForeignKey is of type string so it can be passed as such without casing to ForeignKey + paymentMethodId: "123", + // @ts-expect-no-error ForeignKey is of type string so it can be passed as such without casing to ForeignKey + customerId: "456" + }); }); - await Order.update("123", { - // @ts-expect-error default fields are not accepted on update, they are managed by dyna-record - updatedAt: new Date() + it("will not accept DefaultFields on update because they are managed by dyna-record", async () => { + const instance = new Order(); + + await instance.update({ + // @ts-expect-error default fields are not accepted on update, they are managed by dyna-record + id: "123" + }); + + await instance.update({ + // @ts-expect-error default fields are not accepted on update, they are managed by dyna-record + type: "456" + }); + + await instance.update({ + // @ts-expect-error default fields are not accepted on update, they are managed by dyna-record + createdAt: new Date() + }); + + await instance.update({ + // @ts-expect-error default fields are not accepted on update, they are managed by dyna-record + updatedAt: new Date() + }); }); - }); - it("does not require all of an entity attributes to be passed", async () => { - await Order.update("123", { - // @ts-expect-no-error ForeignKey is of type string so it can be passed as such without casing to ForeignKey - paymentMethodId: "123", - // @ts-expect-no-error ForeignKey is of type string so it can be passed as such without casing to ForeignKey - customerId: "456" + it("does not require all of an entity attributes to be passed", async () => { + const instance = new Order(); + + await instance.update({ + // @ts-expect-no-error ForeignKey is of type string so it can be passed as such without casing to ForeignKey + paymentMethodId: "123", + // @ts-expect-no-error ForeignKey is of type string so it can be passed as such without casing to ForeignKey + customerId: "456" + }); }); - }); - it("will not allow non nullable attributes to be removed (set to null)", async () => { - expect.assertions(3); + it("will not allow non nullable attributes to be removed (set to null)", async () => { + expect.assertions(3); - // Tests that the type system does not allow null, and also that if types are ignored the value is checked at runtime - await Order.update("123", { - // @ts-expect-error non-nullable fields cannot be removed (set to null) - paymentMethodId: null - }).catch(e => { - expect(e).toBeInstanceOf(ValidationError); - expect(e.message).toEqual("Validation errors"); - expect(e.cause).toEqual([ - { - code: "invalid_type", - expected: "string", - message: "Expected string, received null", - path: ["paymentMethodId"], - received: "null" - } - ]); + const instance = new Order(); + + // Tests that the type system does not allow null, and also that if types are ignored the value is checked at runtime + await instance + .update({ + // @ts-expect-error non-nullable fields cannot be removed (set to null) + paymentMethodId: null + }) + .catch(e => { + expect(e).toBeInstanceOf(ValidationError); + expect(e.message).toEqual("Validation errors"); + expect(e.cause).toEqual([ + { + code: "invalid_type", + expected: "string", + message: "Expected string, received null", + path: ["paymentMethodId"], + received: "null" + } + ]); + }); }); - }); - it("will allow nullable attributes to be removed (set to null)", async () => { - await MyModelNullableAttribute.update("123", { - // @ts-expect-no-error non-nullable fields can be removed (set to null) - myAttribute: null + it("will allow nullable attributes to be removed (set to null)", async () => { + const instance = new MyModelNullableAttribute(); + + await instance.update({ + // @ts-expect-no-error non-nullable fields can be removed (set to null) + myAttribute: null + }); }); }); }); From f611d6a1d303c78c168329d82a40c4a7fbfc443a Mon Sep 17 00:00:00 2001 From: Drew Date: Thu, 10 Oct 2024 00:57:39 -0600 Subject: [PATCH 04/11] address todos --- README.md | 10 +++++++ src/DynaRecord.ts | 19 +++++++++++--- src/metadata/EntityMetadata.ts | 5 ---- src/metadata/types.ts | 2 -- src/operations/FindById/types.ts | 1 - src/operations/types.ts | 1 - tests/integration/FindById.test.ts | 21 +++++++++++++++ tests/integration/Update.test.ts | 42 ++++++++++++++++++++---------- 8 files changed, 75 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 862a89a3..c532e3b8 100644 --- a/README.md +++ b/README.md @@ -513,6 +513,16 @@ await Pet.update("123", { }); ``` +#### Instance Method + +There is an instance `update` method that has the same rules above, but returns the full updated instance. + +```typescript +const updatedInstance = await petInstance.update({ + ownerId: null +}); +``` + ### Delete [Docs](https://dyna-record.com/classes/default.html#delete) diff --git a/src/DynaRecord.ts b/src/DynaRecord.ts index d427a0fb..adb8f614 100644 --- a/src/DynaRecord.ts +++ b/src/DynaRecord.ts @@ -260,14 +260,27 @@ abstract class DynaRecord implements DynaRecordBase { await op.run(id, attributes); } - // // // TODO typedoc - // TODO add to readme - // TODO test that this returns instance of class // TODO test that only updatedable attributes can be updated. Meaning EntityDefinedAttributes // TODO add unit test that only entity defined attributes can be set onthis // - for example setting id is not allowed (for types and via schemas) // - this should be tested via type tests AND runtime schema tests + // - This includes checking pk, sk, id, createdAt, updatedAt, type and instance methods like update // - If there are not equivalent tests for the static method paramters then add those + + /** + * Same as the static `update` method but on an instance. Returns the full updated instance + * + * + * @example Updating an entity. + * ```typescript + * const updatedInstance = await instance.update({ email: "newemail@example.com", profileId: 789 }); + * ``` + * + * @example Removing a nullable entities attributes + * ```typescript + * const updatedInstance = await instance.update({ email: "newemail@example.com", someKey: null }); + * ``` + */ public async update( attributes: UpdateOptions ): Promise { diff --git a/src/metadata/EntityMetadata.ts b/src/metadata/EntityMetadata.ts index 68aba0f7..352e62be 100644 --- a/src/metadata/EntityMetadata.ts +++ b/src/metadata/EntityMetadata.ts @@ -78,8 +78,6 @@ class EntityMetadata { this.#zodAttributes[attrMeta.name] = attrMeta.type; } - // TODO check that if I were to pass non entity defined attributes such as pk or id (such as by using 'any' to remove type checks) that there are runtime checks that only attributes that are updatedable can be updated - // would that be a different method? /** * Validate all an entities attributes (used on create) * @param attributes @@ -96,9 +94,6 @@ class EntityMetadata { } } - // TODO I think that this needs to be validating on entity defined attributes only.... - // otherwise would this allow things like "id" "pk" etc to be updated without runtime schema validation? - // add tests for this // In which case I think this needs to be renamed? or a new method and this validates pre input? // Are changes needed to validate full as well? /** diff --git a/src/metadata/types.ts b/src/metadata/types.ts index ebc59a5e..febc100a 100644 --- a/src/metadata/types.ts +++ b/src/metadata/types.ts @@ -49,8 +49,6 @@ export type JoinTableMetadataStorage = Record; */ export type DefaultDateFields = "createdAt" | "updatedAt"; -// TODO add unit test that instance method keys are not allowed whereever this isused -// TODO use the exclude on Functionfields /** * Specifies the default fields used in entities, including fields from `DynaRecord` or `BelongsToLink`. Instance methods are excluded */ diff --git a/src/operations/FindById/types.ts b/src/operations/FindById/types.ts index 0a9ed8a2..3f450688 100644 --- a/src/operations/FindById/types.ts +++ b/src/operations/FindById/types.ts @@ -66,7 +66,6 @@ type EntityKeysWithIncludedAssociations< : T[K]; }; -// TODO add test that instance method "update" is included /** * Represents the result of a `FindById` operation, including the main entity and any specified associated entities. * diff --git a/src/operations/types.ts b/src/operations/types.ts index 505f47e6..d5dcaa6e 100644 --- a/src/operations/types.ts +++ b/src/operations/types.ts @@ -70,7 +70,6 @@ export type EntityAttributes = Omit< RelationshipAttributeNames | FunctionFields >; -// // TODO add unit test that methods are not used where every this is used /** * Entity attributes for default fields */ diff --git a/tests/integration/FindById.test.ts b/tests/integration/FindById.test.ts index 56f2c9e5..e357d6d9 100644 --- a/tests/integration/FindById.test.ts +++ b/tests/integration/FindById.test.ts @@ -132,6 +132,27 @@ describe("FindById", () => { expect(mockSend.mock.calls).toEqual([[{ name: "GetCommand" }]]); }); + it("the returned model will have a instance method for update", async () => { + expect.assertions(1); + + mockGet.mockResolvedValueOnce({ + Item: { + PK: "Customer#123", + SK: "Customer", + Id: "123", + Name: "Some Customer", + Address: "11 Some St", + Type: "Customer", + UpdatedAt: "2023-09-15T04:26:31.148Z" + } + }); + + const result = await Customer.findById("123"); + + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(result?.update).toBeInstanceOf(Function); + }); + it("will serialize an entity with a nullable date attribute", async () => { expect.assertions(4); diff --git a/tests/integration/Update.test.ts b/tests/integration/Update.test.ts index f751e0c5..1c37dd4d 100644 --- a/tests/integration/Update.test.ts +++ b/tests/integration/Update.test.ts @@ -2538,7 +2538,7 @@ describe("Update", () => { describe("instance method", () => { it("will update an entity without foreign key attributes", async () => { - expect.assertions(7); + expect.assertions(8); const now = new Date("2023-10-16T03:31:35.918Z"); jest.setSystemTime(now); @@ -2566,6 +2566,7 @@ describe("Update", () => { createdAt: new Date("2023-10-01"), updatedAt: now // Updated at gets updated }); + expect(updatedInstance).toBeInstanceOf(Customer); expect(mockSend.mock.calls).toEqual([[{ name: "TransactWriteCommand" }]]); expect(mockGet.mock.calls).toEqual([]); expect(mockedGetCommand.mock.calls).toEqual([]); @@ -2609,7 +2610,7 @@ describe("Update", () => { }); it("will update an entity and remove attributes", async () => { - expect.assertions(7); + expect.assertions(8); const now = new Date("2023-10-16T03:31:35.918Z"); jest.setSystemTime(now); @@ -2640,6 +2641,7 @@ describe("Update", () => { createdAt: new Date("2023-10-01"), updatedAt: now }); + expect(updatedInstance).toBeInstanceOf(ContactInformation); expect(mockSend.mock.calls).toEqual([[{ name: "TransactWriteCommand" }]]); expect(mockGet.mock.calls).toEqual([]); expect(mockedGetCommand.mock.calls).toEqual([]); @@ -2687,7 +2689,7 @@ describe("Update", () => { }); it("will update and remove multiple attributes", async () => { - expect.assertions(7); + expect.assertions(8); const now = new Date("2023-10-16T03:31:35.918Z"); jest.setSystemTime(now); @@ -2724,6 +2726,7 @@ describe("Update", () => { createdAt: new Date("2023-10-01"), updatedAt: now }); + expect(updatedInstance).toBeInstanceOf(MockInformation); expect(mockSend.mock.calls).toEqual([[{ name: "TransactWriteCommand" }]]); expect(mockGet.mock.calls).toEqual([]); expect(mockedGetCommand.mock.calls).toEqual([]); @@ -2810,7 +2813,7 @@ describe("Update", () => { }); it("will allow nullable attributes to be set to null", async () => { - expect.assertions(7); + expect.assertions(8); const now = new Date("2023-10-16T03:31:35.918Z"); jest.setSystemTime(now); @@ -2844,6 +2847,7 @@ describe("Update", () => { createdAt: new Date("2023-10-01"), updatedAt: now }); + expect(updatedInstance).toBeInstanceOf(MockInformation); expect(mockSend.mock.calls).toEqual([[{ name: "TransactWriteCommand" }]]); expect(mockGet.mock.calls).toEqual([]); expect(mockedGetCommand.mock.calls).toEqual([]); @@ -2924,7 +2928,7 @@ describe("Update", () => { }); it("will allow nullable attributes to be set to null", async () => { - expect.assertions(7); + expect.assertions(8); const now = new Date("2023-10-16T03:31:35.918Z"); jest.setSystemTime(now); @@ -2958,6 +2962,7 @@ describe("Update", () => { createdAt: new Date("2023-10-01"), updatedAt: now }); + expect(updatedInstance).toBeInstanceOf(MockInformation); expect(mockSend.mock.calls).toEqual([[{ name: "TransactWriteCommand" }]]); expect(mockGet.mock.calls).toEqual([]); expect(mockedGetCommand.mock.calls).toEqual([]); @@ -3027,7 +3032,7 @@ describe("Update", () => { }); it("will update the foreign key if the entity being associated with exists", async () => { - expect.assertions(7); + expect.assertions(8); const instance = createInstance(ContactInformation, { pk: "test-pk" as PartitionKey, @@ -3056,6 +3061,7 @@ describe("Update", () => { createdAt: new Date("2023-10-01"), updatedAt: now }); + expect(updatedInstance).toBeInstanceOf(ContactInformation); expect(mockSend.mock.calls).toEqual([ [{ name: "GetCommand" }], [{ name: "TransactWriteCommand" }] @@ -3482,7 +3488,7 @@ describe("Update", () => { }); it("will update the foreign key and delete the old BelongsToLink if the entity being associated with exists", async () => { - expect.assertions(7); + expect.assertions(8); const instance = createInstance(ContactInformation, { pk: "test-pk" as PartitionKey, @@ -3512,6 +3518,7 @@ describe("Update", () => { createdAt: new Date("2023-10-01"), updatedAt: now }); + expect(updatedInstance).toBeInstanceOf(ContactInformation); expect(mockSend.mock.calls).toEqual([ [{ name: "GetCommand" }], [{ name: "TransactWriteCommand" }] @@ -3992,7 +3999,7 @@ describe("Update", () => { }); it("will remove a nullable foreign key and delete the BelongsToLinks for the associated entity", async () => { - expect.assertions(7); + expect.assertions(8); const instance = createInstance(ContactInformation, { pk: "test-pk" as PartitionKey, @@ -4022,6 +4029,7 @@ describe("Update", () => { createdAt: new Date("2023-10-01"), updatedAt: now }); + expect(updatedInstance).toBeInstanceOf(ContactInformation); expect(mockSend.mock.calls).toEqual([ [{ name: "GetCommand" }], [{ name: "TransactWriteCommand" }] @@ -4111,7 +4119,7 @@ describe("Update", () => { }); it("will update the foreign key if the entity being associated with exists", async () => { - expect.assertions(7); + expect.assertions(8); const instance = createInstance(PaymentMethod, { pk: "test-pk" as PartitionKey, @@ -4139,6 +4147,7 @@ describe("Update", () => { createdAt: new Date("2023-10-01"), updatedAt: now }); + expect(updatedInstance).toBeInstanceOf(PaymentMethod); expect(mockSend.mock.calls).toEqual([ [{ name: "GetCommand" }], [{ name: "TransactWriteCommand" }] @@ -4430,7 +4439,7 @@ describe("Update", () => { }); it("will remove a nullable foreign key", async () => { - expect.assertions(7); + expect.assertions(8); const instance = createInstance(Pet, { pk: "test-pk" as PartitionKey, @@ -4468,6 +4477,7 @@ describe("Update", () => { createdAt: new Date("2023-10-01"), updatedAt: now }); + expect(updatedInstance).toBeInstanceOf(Pet); expect(mockSend.mock.calls).toEqual([ [{ name: "GetCommand" }], [{ name: "TransactWriteCommand" }] @@ -4547,7 +4557,7 @@ describe("Update", () => { }); it("will update the foreign key if the entity being associated with exists", async () => { - expect.assertions(7); + expect.assertions(8); const instance = createInstance(PaymentMethod, { pk: "test-pk" as PartitionKey, @@ -4575,6 +4585,7 @@ describe("Update", () => { createdAt: new Date("2023-10-01"), updatedAt: now }); + expect(updatedInstance).toBeInstanceOf(PaymentMethod); expect(mockSend.mock.calls).toEqual([ [{ name: "GetCommand" }], [{ name: "TransactWriteCommand" }] @@ -5012,7 +5023,7 @@ describe("Update", () => { }); it("will remove a nullable foreign key and delete the associated BelongsToLinks", async () => { - expect.assertions(7); + expect.assertions(8); const instance = createInstance(Pet, { pk: "test-pk" as PartitionKey, @@ -5050,6 +5061,7 @@ describe("Update", () => { createdAt: new Date("2023-10-01"), updatedAt: now }); + expect(updatedInstance).toBeInstanceOf(Pet); expect(mockSend.mock.calls).toEqual([ [{ name: "GetCommand" }], [{ name: "TransactWriteCommand" }] @@ -5153,7 +5165,7 @@ describe("Update", () => { }); it("can update foreign keys for an entity that includes both HasMany and Belongs to relationships", async () => { - expect.assertions(7); + expect.assertions(8); const instance = createInstance(OtherModel3, { pk: "test-pk" as PartitionKey, @@ -5196,6 +5208,7 @@ describe("Update", () => { createdAt: new Date("2023-10-01"), updatedAt: now }); + expect(updatedInstance).toBeInstanceOf(OtherModel3); expect(mockSend.mock.calls).toEqual([ [{ name: "GetCommand" }], [{ name: "TransactWriteCommand" }] @@ -5301,7 +5314,7 @@ describe("Update", () => { }); it("alternate table (different alias/keys) - can update foreign keys for an entity that includes both HasMany and Belongs to relationships", async () => { - expect.assertions(7); + expect.assertions(8); const instance = createInstance(Grade, { myPk: "Grade#123" as PartitionKey, @@ -5346,6 +5359,7 @@ describe("Update", () => { createdAt: new Date("2023-10-01"), updatedAt: now }); + expect(updatedInstance).toBeInstanceOf(Grade); expect(mockSend.mock.calls).toEqual([ [{ name: "GetCommand" }], [{ name: "TransactWriteCommand" }] From c4dcd96bae0a5db5b37d666849e22a06d66b304f Mon Sep 17 00:00:00 2001 From: Drew Date: Thu, 10 Oct 2024 00:57:46 -0600 Subject: [PATCH 05/11] address todos --- tests/integration/Update.test.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/tests/integration/Update.test.ts b/tests/integration/Update.test.ts index 1c37dd4d..0fd452f6 100644 --- a/tests/integration/Update.test.ts +++ b/tests/integration/Update.test.ts @@ -40,11 +40,6 @@ const mockTransact = jest.fn(); const mockedUuidv4 = jest.mocked(uuidv4); -// TODO start here... add tests. -// - Make a describe for the static tests -// - and make a describe for instance tests that copies/modefies all of them -// TODO do the same for types test - jest.mock("@aws-sdk/client-dynamodb", () => { return { TransactionCanceledException: jest.fn().mockImplementation((...params) => { From ed48451b137e70b81a2cf9bba0feb9681d2d5a2e Mon Sep 17 00:00:00 2001 From: Drew Date: Fri, 11 Oct 2024 13:28:06 -0600 Subject: [PATCH 06/11] resolves issue where reserved keys are not validated at runtime for update and create --- README.md | 2 + src/metadata/EntityMetadata.ts | 45 ++++++--- src/metadata/TableMetadata.ts | 29 ++++++ src/operations/Create/Create.ts | 13 ++- src/operations/Update/Update.ts | 7 +- tests/integration/Create.test.ts | 84 +++++++++++++++- tests/integration/Update.test.ts | 167 ++++++++++++++++++++++++++++++- 7 files changed, 317 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index c532e3b8..6f53b6fc 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # Dyna-Record + + [API Documentation](https://dyna-record.com/) Dyna-Record is a strongly typed ORM (Object-Relational Mapping) tool designed for modeling and interacting with data stored in DynamoDB in a structured and type-safe manner. It simplifies the process of defining data models (entities), performing CRUD operations, and handling complex queries. To support relational data, dyna-record implements a flavor of the [single-table design pattern](https://aws.amazon.com/blogs/compute/creating-a-single-table-design-with-amazon-dynamodb/) and the [adjacency list design pattern](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/bp-adjacency-graphs.html). All operations are [ACID compliant transactions\*](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/transaction-apis.html)\. diff --git a/src/metadata/EntityMetadata.ts b/src/metadata/EntityMetadata.ts index 352e62be..b7a6c97c 100644 --- a/src/metadata/EntityMetadata.ts +++ b/src/metadata/EntityMetadata.ts @@ -6,7 +6,8 @@ import { } from "."; import type DynaRecord from "../DynaRecord"; import { ValidationError } from "../errors"; -import { type EntityAttributes } from "../operations"; +import { type EntityDefinedAttributes } from "../operations"; +import Metadata from "."; type EntityClass = new (...args: any) => DynaRecord; @@ -79,47 +80,59 @@ class EntityMetadata { } /** - * Validate all an entities attributes (used on create) + * Parse raw entity defined attributes (not reserved/relationship attributes) from input and ensure they are entity defined attributes. + * Any reserved attributes such as primary key, sort key, id, type ,createdAt, updatedAt etc will be omitted. + * If any attributes do not match their schema, a ValidationError is thrown * @param attributes */ - public validateFull(attributes: EntityAttributes): void { + public parseRawEntityDefinedAttributes( + attributes: EntityDefinedAttributes + ): EntityDefinedAttributes { if (this.#schema === undefined) { - this.#schema = z.object(this.#zodAttributes); + const tableMeta = Metadata.getTable(this.tableClassName); + this.#schema = z.object(this.#zodAttributes).omit(tableMeta.reservedKeys); } try { - this.#schema.parse(attributes); + return this.#schema.parse(attributes); } catch (error) { - this.handleValidationError(error); + throw this.buildValidationError(error); } } - // In which case I think this needs to be renamed? or a new method and this validates pre input? - // Are changes needed to validate full as well? /** - * Validate partial entities attributes (used on update) + * Partial parse raw entity defined attributes (not reserved/relationship attributes) from input and ensure they are entity defined attributes. + * This is similar to `parseRawEntityDefinedAttributes` but will do a partial validation, only validating the entity defined attributes that are present and not rejected if fields are missing. + * Any reserved attributes such as primary key, sort key, id, type ,createdAt, updatedAt etc will be omitted. + * If any attributes do not match their schema, a ValidationError is thrown * @param attributes */ - public validatePartial(attributes: Partial): void { + public parseRawEntityDefinedAttributesPartial( + attributes: Partial> + ): Partial> { if (this.#schemaPartial === undefined) { - this.#schemaPartial = z.object(this.#zodAttributes).partial(); + const tableMeta = Metadata.getTable(this.tableClassName); + this.#schemaPartial = z + .object(this.#zodAttributes) + .omit(tableMeta.reservedKeys) + .partial(); } try { - this.#schemaPartial.parse(attributes); + return this.#schemaPartial.parse(attributes); } catch (error) { - this.handleValidationError(error); + throw this.buildValidationError(error); } } /** - * Throw validation errors with the cause + * Build validation errors with the cause * @param error */ - private handleValidationError(error: unknown): void { + private buildValidationError(error: unknown): ValidationError { const errorOptions = error instanceof ZodError ? { cause: error.issues } : undefined; - throw new ValidationError("Validation errors", errorOptions); + return new ValidationError("Validation errors", errorOptions); } } diff --git a/src/metadata/TableMetadata.ts b/src/metadata/TableMetadata.ts index a8f35e11..e45ec973 100644 --- a/src/metadata/TableMetadata.ts +++ b/src/metadata/TableMetadata.ts @@ -48,6 +48,25 @@ class TableMetadata { public partitionKeyAttribute: AttributeMetadata; public sortKeyAttribute: AttributeMetadata; + /** + * Represents the keys that should be excluded from schema validation. + * These keys are reserved by dyna-record and should be managed internally. + * + * While dyna-record employs type guards to prevent the setting of these keys, + * this ensures additional runtime validation. + * + * The reserved keys include: + * - pk + * - sk + * - id + * - type + * - createdAt + * - updatedAt + * - foreignKey + * - foreignEntityType + */ + public reservedKeys: Record; + constructor(options: TableMetadataOptions) { const defaultAttrMeta = this.buildDefaultAttributesMetadata(options); @@ -70,6 +89,12 @@ class TableMetadata { serializers: { toEntityAttribute: () => "", toTableAttribute: () => "" }, type: z.string() }; + + const defaultAttrNames = Object.keys(this.defaultAttributes); + // Set the default keys as reserved keys, the user defined primary and sort key are set later + this.reservedKeys = Object.fromEntries( + defaultAttrNames.map(key => [key, true]) + ); } /** @@ -116,6 +141,8 @@ class TableMetadata { public addPartitionKeyAttribute(options: KeysAttributeMetadataOptions): void { const opts = { ...options, nullable: false }; this.partitionKeyAttribute = new AttributeMetadata(opts); + // Set the user defined primary key as reserved key so that its managed by dyna-record + this.reservedKeys[options.attributeName] = true; } /** @@ -125,6 +152,8 @@ class TableMetadata { public addSortKeyAttribute(options: KeysAttributeMetadataOptions): void { const opts = { ...options, nullable: false }; this.sortKeyAttribute = new AttributeMetadata(opts); + // Set the user defined primary key as reserved key so that its managed by dyna-record + this.reservedKeys[options.attributeName] = true; } } diff --git a/src/operations/Create/Create.ts b/src/operations/Create/Create.ts index 6566f824..ea7d54ee 100644 --- a/src/operations/Create/Create.ts +++ b/src/operations/Create/Create.ts @@ -33,10 +33,11 @@ class Create extends OperationBase { * @returns */ public async run(attributes: CreateOptions): Promise> { - const entityData = this.buildEntityData(attributes); - const entityMeta = Metadata.getEntity(this.EntityClass.name); - entityMeta.validateFull(entityData); + const entityAttrs = entityMeta.parseRawEntityDefinedAttributes(attributes); + + const reservedAttrs = this.buildReservedAttributes(); + const entityData = { ...reservedAttrs, ...entityAttrs }; const tableItem = entityToTableItem(this.EntityClass, entityData); @@ -53,9 +54,7 @@ class Create extends OperationBase { * @param attributes * @returns */ - private buildEntityData( - attributes: CreateOptions - ): EntityAttributes { + private buildReservedAttributes(): EntityAttributes { const id = uuidv4(); const createdAt = new Date(); @@ -74,7 +73,7 @@ class Create extends OperationBase { updatedAt: createdAt }; - return { ...keys, ...attributes, ...defaultAttrs }; + return { ...keys, ...defaultAttrs }; } /** diff --git a/src/operations/Update/Update.ts b/src/operations/Update/Update.ts index ddcdabc5..dfbabad9 100644 --- a/src/operations/Update/Update.ts +++ b/src/operations/Update/Update.ts @@ -43,11 +43,12 @@ class Update extends OperationBase { attributes: UpdateOptions ): Promise> { const entityMeta = Metadata.getEntity(this.EntityClass.name); - entityMeta.validatePartial(attributes); + const entityAttrs = + entityMeta.parseRawEntityDefinedAttributesPartial(attributes); - const updatedAttrs = this.buildUpdateItemTransaction(id, attributes); + const updatedAttrs = this.buildUpdateItemTransaction(id, entityAttrs); - await this.buildRelationshipTransactions(id, attributes); + await this.buildRelationshipTransactions(id, entityAttrs); await this.#transactionBuilder.executeTransaction(); return updatedAttrs; diff --git a/tests/integration/Create.test.ts b/tests/integration/Create.test.ts index 7d1e0f27..33a2509a 100644 --- a/tests/integration/Create.test.ts +++ b/tests/integration/Create.test.ts @@ -124,6 +124,62 @@ describe("Create", () => { ]); }); + it("has runtime schema validation to ensure that reserved keys are not set on create. They will be omitted from create", async () => { + expect.assertions(4); + + jest.setSystemTime(new Date("2023-10-16T03:31:35.918Z")); + + mockedUuidv4.mockReturnValueOnce("uuid1"); + + const home = await Home.create({ + // Begin reserved keys + pk: "2", + sk: "3", + id: "4", + type: "bad type", + updatedAt: new Date(), + createdAt: new Date(), + update: () => {}, + // End reserved keys + mlsNum: "123" + } as any); // Use any to force bad type and allow runtime checks to be tested + + expect(home).toEqual({ + pk: "Home#uuid1", + sk: "Home", + type: "Home", + id: "uuid1", + mlsNum: "123", + createdAt: new Date("2023-10-16T03:31:35.918Z"), + updatedAt: new Date("2023-10-16T03:31:35.918Z") + }); + expect(home).toBeInstanceOf(Home); + expect(mockSend.mock.calls).toEqual([[{ name: "TransactWriteCommand" }]]); + expect(mockTransactWriteCommand.mock.calls).toEqual([ + [ + { + TransactItems: [ + { + Put: { + TableName: "mock-table", + ConditionExpression: "attribute_not_exists(PK)", + Item: { + PK: "Home#uuid1", + SK: "Home", + Type: "Home", + Id: "uuid1", + "MLS#": "123", + CreatedAt: "2023-10-16T03:31:35.918Z", + UpdatedAt: "2023-10-16T03:31:35.918Z" + } + } + } + ] + } + ] + ]); + }); + it("will error if any required attributes are missing", async () => { expect.assertions(5); @@ -851,9 +907,15 @@ describe("Create", () => { await MyModel.create({ myAttribute: "someVal", - // @ts-expect-error function attributes are not allowed + // @ts-expect-error custom function attributes are not allowed someMethod: () => "123" }); + + await MyModel.create({ + myAttribute: "someVal", + // @ts-expect-error built in function attributes are not allowed + update: () => "123" + }); }); it("optional attributes are not required", async () => { @@ -934,7 +996,7 @@ describe("Create", () => { }); }); - it("will not accept DefaultFields on create because they are managed by dyna-record", async () => { + it("will not accept DefaultFields (reserved) on create because they are managed by dyna-record", async () => { await Order.create({ customerId: "customerId", paymentMethodId: "paymentMethodId", @@ -968,6 +1030,24 @@ describe("Create", () => { }); }); + it("will not accept partition and sort keys on create because those are reserved and managed by dyna-record", async () => { + await Order.create({ + customerId: "customerId", + paymentMethodId: "paymentMethodId", + orderDate: new Date(), + // @ts-expect-error primary key fields are not accepted on create, they are managed by dyna-record + pk: "123" + }); + + await Order.create({ + customerId: "customerId", + paymentMethodId: "paymentMethodId", + orderDate: new Date(), + // @ts-expect-error sort key fields are not accepted on create, they are managed by dyna-record + sk: "123" + }); + }); + it("will not allow nullable attributes to be set to null (they should be left undefined)", async () => { await MyModelNullableAttribute.create({ // @ts-expect-error nullable fields cannot be set to null (they should be left undefined) diff --git a/tests/integration/Update.test.ts b/tests/integration/Update.test.ts index 0fd452f6..db630f80 100644 --- a/tests/integration/Update.test.ts +++ b/tests/integration/Update.test.ts @@ -169,6 +169,60 @@ describe("Update", () => { ]); }); + it("has runtime schema validation to ensure that reserved keys are not set on update. They will be omitted from update", async () => { + expect.assertions(6); + + jest.setSystemTime(new Date("2023-10-16T03:31:35.918Z")); + + expect( + // eslint-disable-next-line @typescript-eslint/no-confusing-void-expression + await Customer.update("123", { + // Begin reserved keys + pk: "2", + sk: "3", + id: "4", + type: "bad type", + updatedAt: new Date(), + createdAt: new Date(), + update: () => {}, + // End reserved keys + name: "New Name", + address: "new Address" + } as any) // Use any to force bad type and allow runtime checks to be tested + ).toBeUndefined(); + expect(mockSend.mock.calls).toEqual([[{ name: "TransactWriteCommand" }]]); + expect(mockGet.mock.calls).toEqual([]); + expect(mockedGetCommand.mock.calls).toEqual([]); + expect(mockTransact.mock.calls).toEqual([[]]); + expect(mockTransactWriteCommand.mock.calls).toEqual([ + [ + { + TransactItems: [ + { + Update: { + TableName: "mock-table", + Key: { PK: "Customer#123", SK: "Customer" }, + UpdateExpression: + "SET #Name = :Name, #Address = :Address, #UpdatedAt = :UpdatedAt", + ConditionExpression: "attribute_exists(PK)", + ExpressionAttributeNames: { + "#Address": "Address", + "#Name": "Name", + "#UpdatedAt": "UpdatedAt" + }, + ExpressionAttributeValues: { + ":Address": "new Address", + ":Name": "New Name", + ":UpdatedAt": "2023-10-16T03:31:35.918Z" + } + } + } + ] + } + ] + ]); + }); + it("will update an entity and remove attributes", async () => { expect.assertions(6); @@ -256,7 +310,7 @@ describe("Update", () => { Key: { PK: "MockInformation#123", SK: "MockInformation" }, TableName: "mock-table", UpdateExpression: - "SET #Address = :Address, #Email = :Email, #UpdatedAt = :UpdatedAt REMOVE #State, #Phone" + "SET #Address = :Address, #Email = :Email, #UpdatedAt = :UpdatedAt REMOVE #Phone, #State" } } ] @@ -2491,6 +2545,18 @@ describe("Update", () => { }); }); + it("will not accept partition and sort keys on update because they are managed by dyna-record", async () => { + await Order.update("123", { + // @ts-expect-error primary key fields are not accepted on update, they are managed by dyna-record + pk: "123" + }); + + await Order.update("123", { + // @ts-expect-error sort key fields are not accepted on update, they are managed by dyna-record + sk: "456" + }); + }); + it("does not require all of an entity attributes to be passed", async () => { await Order.update("123", { // @ts-expect-no-error ForeignKey is of type string so it can be passed as such without casing to ForeignKey @@ -2604,6 +2670,89 @@ describe("Update", () => { }); }); + it("has runtime schema validation to ensure that reserved keys are not set on update. They will be omitted from update", async () => { + expect.assertions(8); + + const now = new Date("2023-10-16T03:31:35.918Z"); + jest.setSystemTime(now); + + const instance = createInstance(Customer, { + pk: "test-pk" as PartitionKey, + sk: "test-sk" as SortKey, + id: "123", + name: "test-name", + address: "test-address", + type: "Customer", + createdAt: new Date("2023-10-01"), + updatedAt: new Date("2023-10-02") + }); + + const updatedInstance = await instance.update({ + // Begin reserved keys + pk: "2", + sk: "3", + id: "4", + type: "bad type", + updatedAt: new Date(), + createdAt: new Date(), + update: () => {}, + // End reserved keys + name: "newName" + } as any); // Use any to force bad type and allow runtime checks to be tested + + expect(updatedInstance).toEqual({ + pk: "test-pk", + sk: "test-sk", + id: "123", + name: "newName", // Updated name + type: "Customer", + address: "test-address", + createdAt: new Date("2023-10-01"), + updatedAt: now // Updated at gets updated + }); + expect(updatedInstance).toBeInstanceOf(Customer); + expect(mockSend.mock.calls).toEqual([[{ name: "TransactWriteCommand" }]]); + expect(mockGet.mock.calls).toEqual([]); + expect(mockedGetCommand.mock.calls).toEqual([]); + expect(mockTransact.mock.calls).toEqual([[]]); + expect(mockTransactWriteCommand.mock.calls).toEqual([ + [ + { + TransactItems: [ + { + Update: { + TableName: "mock-table", + Key: { PK: "Customer#123", SK: "Customer" }, + UpdateExpression: + "SET #Name = :Name, #UpdatedAt = :UpdatedAt", + ConditionExpression: "attribute_exists(PK)", + ExpressionAttributeNames: { + "#Name": "Name", + "#UpdatedAt": "UpdatedAt" + }, + ExpressionAttributeValues: { + ":Name": "newName", + ":UpdatedAt": now.toISOString() + } + } + } + ] + } + ] + ]); + // Original instance is not mutated + expect(instance).toEqual({ + pk: "test-pk", + sk: "test-sk", + id: "123", + name: "test-name", + type: "Customer", + address: "test-address", + createdAt: new Date("2023-10-01"), + updatedAt: new Date("2023-10-02") + }); + }); + it("will update an entity and remove attributes", async () => { expect.assertions(8); @@ -2748,7 +2897,7 @@ describe("Update", () => { Key: { PK: "MockInformation#123", SK: "MockInformation" }, TableName: "mock-table", UpdateExpression: - "SET #Address = :Address, #Email = :Email, #UpdatedAt = :UpdatedAt REMOVE #State, #Phone" + "SET #Address = :Address, #Email = :Email, #UpdatedAt = :UpdatedAt REMOVE #Phone, #State" } } ] @@ -5549,6 +5698,20 @@ describe("Update", () => { }); }); + it("will not accept partition and sort keys on update because they are managed by dyna-record", async () => { + const instance = new Order(); + + await instance.update({ + // @ts-expect-error primary key fields are not accepted on update, they are managed by dyna-record + pk: "123" + }); + + await instance.update({ + // @ts-expect-error sort key fields are not accepted on update, they are managed by dyna-record + sk: "456" + }); + }); + it("does not require all of an entity attributes to be passed", async () => { const instance = new Order(); From c2624a8df1a8a002f1a4ecb320bb7cb108186a97 Mon Sep 17 00:00:00 2001 From: Drew Date: Fri, 11 Oct 2024 16:48:41 -0600 Subject: [PATCH 07/11] add docs --- README.md | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 6f53b6fc..3b4fa40a 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,8 @@ # Dyna-Record - - [API Documentation](https://dyna-record.com/) -Dyna-Record is a strongly typed ORM (Object-Relational Mapping) tool designed for modeling and interacting with data stored in DynamoDB in a structured and type-safe manner. It simplifies the process of defining data models (entities), performing CRUD operations, and handling complex queries. To support relational data, dyna-record implements a flavor of the [single-table design pattern](https://aws.amazon.com/blogs/compute/creating-a-single-table-design-with-amazon-dynamodb/) and the [adjacency list design pattern](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/bp-adjacency-graphs.html). All operations are [ACID compliant transactions\*](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/transaction-apis.html)\. +Dyna-Record is a strongly typed ORM (Object-Relational Mapping) tool designed for modeling and interacting with data stored in DynamoDB in a structured and type-safe manner. It simplifies the process of defining data models (entities), performing CRUD operations, and handling complex queries. To support relational data, dyna-record implements a flavor of the [single-table design pattern](https://aws.amazon.com/blogs/compute/creating-a-single-table-design-with-amazon-dynamodb/) and the [adjacency list design pattern](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/bp-adjacency-graphs.html). All operations are [ACID compliant transactions\*](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/transaction-apis.html)\. To enforce data integrity beyond the type system, schema validation is performed at runtime. Note: ACID compliant according to DynamoDB [limitations](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/transaction-apis.html) @@ -312,7 +310,7 @@ class Student extends OtherTable { The create method is used to insert a new record into a DynamoDB table. This method automatically handles key generation (using UUIDs), timestamps for [createdAt](https://dyna-record.com/classes/default.html#createdAt) and [updatedAt](https://dyna-record.com/classes/default.html#updatedAt) fields, and the management of relationships between entities. It leverages AWS SDK's [TransactWriteCommand](https://www.google.com/search?q=aws+transact+write+command&oq=aws+transact+write+command&gs_lcrp=EgZjaHJvbWUyBggAEEUYOTIGCAEQRRg7MgYIAhBFGDvSAQgzMjAzajBqN6gCALACAA&sourceid=chrome&ie=UTF-8) for transactional integrity, ensuring either complete success or rollback in case of any failure. The method handles conditional checks to ensure data integrity and consistency during creation. If a foreignKey is set on create, dyna-record will de-normalize the data required in order to support the relationship -To use the create method, call it on the model class you wish to create a new record for. Pass the properties of the new record as an object argument to the method. +To use the create method, call it on the model class you wish to create a new record for. Pass the properties of the new record as an object argument to the method. Only attributes defined on the model can be configured, and will be enforced via types and runtime schema validation. #### Basic Usage @@ -471,7 +469,7 @@ const result = await Customer.query( [Docs](https://dyna-record.com/classes/default.html#update) -The update method enables modifications to existing items in a DynamoDB table. It supports updating simple attributes, handling nullable fields, and managing relationships between entities, including updating and removing foreign keys. +The update method enables modifications to existing items in a DynamoDB table. It supports updating simple attributes, handling nullable fields, and managing relationships between entities, including updating and removing foreign keys. Only attributes defined on the model can be updated, and will be enforced via types and runtime schema validation. #### Updating simple attributes From 33074074514fbf0e36940912b7e88a46b7709946 Mon Sep 17 00:00:00 2001 From: Drew Date: Fri, 11 Oct 2024 16:49:00 -0600 Subject: [PATCH 08/11] add docs --- src/operations/Create/Create.ts | 2 ++ src/operations/Update/Update.ts | 2 ++ 2 files changed, 4 insertions(+) diff --git a/src/operations/Create/Create.ts b/src/operations/Create/Create.ts index ea7d54ee..5d40e904 100644 --- a/src/operations/Create/Create.ts +++ b/src/operations/Create/Create.ts @@ -17,6 +17,8 @@ import Metadata from "../../metadata"; * * It encapsulates the logic required to translate entity attributes to a format suitable for DynamoDB, execute the creation transaction, and manage any relationships defined by the entity, such as "BelongsTo" or "HasMany" links. * + * Only attributes defined on the model can be configured, and will be enforced via types and runtime schema validation. + * * @template T - The type of the entity being created, extending `DynaRecord`. */ class Create extends OperationBase { diff --git a/src/operations/Update/Update.ts b/src/operations/Update/Update.ts index dfbabad9..509d9059 100644 --- a/src/operations/Update/Update.ts +++ b/src/operations/Update/Update.ts @@ -21,6 +21,8 @@ import Metadata from "../../metadata"; * * The `Update` operation supports updating entity attributes and ensures consistency in relationships, especially for "BelongsTo" relationships. It handles the complexity of managing foreign keys and associated "BelongsToLink" records, including creating new links for updated relationships and removing outdated links when necessary. * + * Only attributes defined on the model can be configured, and will be enforced via types and runtime schema validation. + * * @template T - The type of the entity being updated, extending `DynaRecord`. */ class Update extends OperationBase { From aa0d97ec5645c46896527480e4859c45839663f8 Mon Sep 17 00:00:00 2001 From: Drew Date: Fri, 11 Oct 2024 16:49:40 -0600 Subject: [PATCH 09/11] remove todo --- src/DynaRecord.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/DynaRecord.ts b/src/DynaRecord.ts index adb8f614..fe973015 100644 --- a/src/DynaRecord.ts +++ b/src/DynaRecord.ts @@ -260,13 +260,6 @@ abstract class DynaRecord implements DynaRecordBase { await op.run(id, attributes); } - // TODO test that only updatedable attributes can be updated. Meaning EntityDefinedAttributes - // TODO add unit test that only entity defined attributes can be set onthis - // - for example setting id is not allowed (for types and via schemas) - // - this should be tested via type tests AND runtime schema tests - // - This includes checking pk, sk, id, createdAt, updatedAt, type and instance methods like update - // - If there are not equivalent tests for the static method paramters then add those - /** * Same as the static `update` method but on an instance. Returns the full updated instance * From 6eee4000798c93156cb3794e54fdaea93c291b65 Mon Sep 17 00:00:00 2001 From: Drew Date: Fri, 11 Oct 2024 16:50:30 -0600 Subject: [PATCH 10/11] 0.1.3 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0881a115..41cdedf5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "dyna-record", - "version": "0.1.2", + "version": "0.1.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "dyna-record", - "version": "0.1.2", + "version": "0.1.3", "license": "MIT", "dependencies": { "@aws-sdk/client-dynamodb": "^3.502.0", diff --git a/package.json b/package.json index ec0b20be..ea23a08c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dyna-record", - "version": "0.1.2", + "version": "0.1.3", "description": "Typescript Object Relational Mapper (ORM) for Dynamo", "main": "dist/index.js", "types": "dist/index.d.ts", From 5d888d8e2e5dc3bdd2ff138a6726a9876eac9690 Mon Sep 17 00:00:00 2001 From: Drew Date: Fri, 11 Oct 2024 16:54:16 -0600 Subject: [PATCH 11/11] add docs --- src/utils.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/utils.ts b/src/utils.ts index 2412a2f0..5ef6b980 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -72,9 +72,9 @@ export const tableItemToEntity = ( return entity; }; -/* The line `const updatedInstanceAttrs = structuredClone(this);` is creating a deep copy of the -current instance object `this` using the `structuredClone` function. This is typically done to -ensure that any modifications made to the copied object do not affect the original object. */ +/** + * Create an instance of a dyna record class + */ export const createInstance = ( EntityClass: new () => T, attributes: EntityAttributes