diff --git a/README.md b/README.md index 862a89a3..3b4fa40a 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [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) @@ -310,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 @@ -469,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 @@ -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/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", diff --git a/src/DynaRecord.ts b/src/DynaRecord.ts index 446dcb58..fe973015 100644 --- a/src/DynaRecord.ts +++ b/src/DynaRecord.ts @@ -13,9 +13,11 @@ import { type CreateOptions, Update, type UpdateOptions, - Delete + Delete, + type EntityAttributes } from "./operations"; import type { EntityClass, Optional } from "./types"; +import { createInstance } from "./utils"; interface DynaRecordBase { id: string; @@ -258,6 +260,40 @@ abstract class DynaRecord implements DynaRecordBase { await op.run(id, attributes); } + /** + * 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 { + const InstanceClass = this.constructor as EntityClass; + const op = new Update(InstanceClass); + const updatedAttributes = await op.run(this.id, attributes); + + const clone = structuredClone(this); + + // Update the current instance with new attributes + Object.assign(clone, updatedAttributes); + + 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, updatedInstance); + } + /** * Delete an entity by ID * - Delete all BelongsToLinks diff --git a/src/metadata/EntityMetadata.ts b/src/metadata/EntityMetadata.ts index a5baad7f..b7a6c97c 100644 --- a/src/metadata/EntityMetadata.ts +++ b/src/metadata/EntityMetadata.ts @@ -6,6 +6,8 @@ import { } from "."; import type DynaRecord from "../DynaRecord"; import { ValidationError } from "../errors"; +import { type EntityDefinedAttributes } from "../operations"; +import Metadata from "."; type EntityClass = new (...args: any) => DynaRecord; @@ -78,45 +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: DynaRecord): 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); } } /** - * 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/metadata/types.ts b/src/metadata/types.ts index 4d0cacaf..febc100a 100644 --- a/src/metadata/types.ts +++ b/src/metadata/types.ts @@ -50,9 +50,15 @@ export type JoinTableMetadataStorage = Record; export type DefaultDateFields = "createdAt" | "updatedAt"; /** - * 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..5d40e904 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 EntityAttributes } from "../types"; +import { + type EntityAttributeDefaultFields, + type EntityAttributes +} from "../types"; import Metadata from "../../metadata"; /** @@ -14,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 { @@ -30,10 +35,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); @@ -50,7 +56,7 @@ class Create extends OperationBase { * @param attributes * @returns */ - private buildEntityData(attributes: CreateOptions): DynaRecord { + private buildReservedAttributes(): EntityAttributes { const id = uuidv4(); const createdAt = new Date(); @@ -62,14 +68,14 @@ class Create extends OperationBase { [sk]: this.EntityClass.name }; - const defaultAttrs: DynaRecord = { + const defaultAttrs: EntityAttributeDefaultFields = { id, type: this.EntityClass.name, createdAt, updatedAt: createdAt }; - return { ...keys, ...attributes, ...defaultAttrs }; + return { ...keys, ...defaultAttrs }; } /** @@ -92,7 +98,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..3f450688 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"; /** @@ -73,5 +77,5 @@ export type FindByIdIncludesRes< Opts extends FindByIdOptions > = EntityKeysWithIncludedAssociations< T, - keyof EntityAttributes | IncludedKeys + keyof EntityAttributes | IncludedKeys | FunctionFields >; diff --git a/src/operations/Update/Update.ts b/src/operations/Update/Update.ts index af80176c..509d9059 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"; @@ -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 { @@ -38,13 +40,20 @@ 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); + const entityAttrs = + entityMeta.parseRawEntityDefinedAttributesPartial(attributes); + + const updatedAttrs = this.buildUpdateItemTransaction(id, entityAttrs); - this.buildUpdateItemTransaction(id, attributes); - await this.buildRelationshipTransactions(id, attributes); + await this.buildRelationshipTransactions(id, entityAttrs); await this.#transactionBuilder.executeTransaction(); + + return updatedAttrs; } /** @@ -55,7 +64,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 +75,7 @@ class Update extends OperationBase { [sk]: this.EntityClass.name }; - const updatedAttrs: Partial = { + const updatedAttrs: UpdatedAttributes = { ...attributes, updatedAt: new Date() }; @@ -84,6 +93,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/src/operations/types.ts b/src/operations/types.ts index 4dac6cfe..d5dcaa6e 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,15 @@ export type RelationshipAttributeNames = { */ export type EntityAttributes = Omit< T, - RelationshipAttributeNames + RelationshipAttributeNames | FunctionFields +>; + +/** + * 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..5ef6b980 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; }; +/** + * Create an instance of a dyna record class + */ +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 >( 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/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 e1e34f4c..db630f80 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"); @@ -117,401 +123,357 @@ 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("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")); + 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 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 and remove multiple 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 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 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 error if any attributes are the wrong type", async () => { - expect.assertions(5); + jest.setSystemTime(new Date("2023-10-16T03:31:35.918Z")); - 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( + // 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 #Phone, #State" + } + } + ] + } + ] ]); - 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 error if any attributes are the wrong type", async () => { + expect.assertions(5); - await MockInformation.update("123", { - someDate: null + 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([]); + } }); - 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([[{ 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 allow nullable attributes to be set to null", async () => { + expect.assertions(5); - 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 + 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" + } + } + ] } + ] + ]); + }); + + 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 +544,305 @@ describe("Update", () => { } ] ]); - } - }); + }); + + 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 being associated with 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: "None" }, - { Code: "ConditionalCheckFailed" }, - { Code: "None" } - ], - $metadata: {} + 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 +893,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 +923,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 + 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: {} + }); + }); + + 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)" + } + }, + { + // 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" + } + } + } + ] + } + ] + ]); } }); - }); - afterEach(() => { - mockedUuidv4.mockReset(); - }); + it("will throw an error if the entity is already associated with the requested entity", 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); + 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 + } + }); - 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: [ + 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([ + [ { - 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" - } - } - }, - { - // 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" + { + // 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 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" }, - { 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" - ) - ]); + 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 +1310,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 +1377,7 @@ describe("Update", () => { [ { TableName: "mock-table", - Key: { PK: "ContactInformation#123", SK: "ContactInformation" }, + Key: { PK: "PaymentMethod#123", SK: "PaymentMethod" }, ConsistentRead: true } ] @@ -1011,54 +1390,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 +1433,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 +1657,7 @@ describe("Update", () => { [ { TableName: "mock-table", - Key: { PK: "ContactInformation#123", SK: "ContactInformation" }, + Key: { PK: "Pet#123", SK: "Pet" }, ConsistentRead: true } ] @@ -1134,257 +1670,57 @@ describe("Update", () => { { 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 + Key: { PK: "Pet#123", SK: "Pet" }, ConditionExpression: "attribute_exists(PK)", ExpressionAttributeNames: { - "#CustomerId": "CustomerId", - "#Email": "Email", + "#Name": "Name", + "#OwnerId": "OwnerId", "#UpdatedAt": "UpdatedAt" }, ExpressionAttributeValues: { - ":CustomerId": "456", - ":Email": "new-email@example.com", + ":Name": "New Name", ":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" - } + }, + UpdateExpression: + "SET #Name = :Name, #UpdatedAt = :UpdatedAt REMOVE #OwnerId" } } ] } ] ]); - } + }); }); - 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" }] - ]); - 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" } - } - } - ] + 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 } - ] - ]); - }); - }); - }); - - 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 - } + }); }); - }); - - 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", - "#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); - - 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: {} - }); + afterEach(() => { + mockedUuidv4.mockReset(); }); - 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" - ) - ]); + 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" }] @@ -1430,6 +1766,16 @@ describe("Update", () => { ConditionExpression: "attribute_exists(PK)" } }, + { + // Delete old BelongsToLink + Delete: { + TableName: "mock-table", + Key: { + PK: "Customer#789", + SK: "PaymentMethod#123" + } + } + }, { Put: { TableName: "mock-table", @@ -1450,37 +1796,352 @@ describe("Update", () => { } ] ]); - } - }); + }); + + 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" }, + { 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)" + } + }, + // 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); + 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: {} + 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" + } + } + }, + { + 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" + } + } + } + ] + } + ] + ]); + } }); - try { - await PaymentMethod.update("123", { - lastFour: "5678", - customerId: "456" + 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 + } }); - } catch (e: any) { - expect(e.constructor.name).toEqual("TransactionWriteFailedError"); - expect(e.errors).toEqual([ - new ConditionalCheckFailedError( - "ConditionalCheckFailed: Customer with ID '456' does not exist" - ) - ]); + + 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([ + [ + { + 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" }] @@ -1490,7 +2151,7 @@ describe("Update", () => { [ { TableName: "mock-table", - Key: { PK: "PaymentMethod#123", SK: "PaymentMethod" }, + Key: { PK: "Pet#123", SK: "Pet" }, ConsistentRead: true } ] @@ -1503,70 +2164,94 @@ 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" } }, { - Put: { + Delete: { 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" - } + Key: { PK: "Person#456", SK: "Pet#123" } } } ] } ] ]); - } + }); }); + }); - it("will remove a nullable foreign key", async () => { - expect.assertions(6); + describe("A model is updating multiple ForeignKeys of different relationship types", () => { + @Entity + class Model1 extends MockTable { + @HasOne(() => Model3, { foreignKey: "model1Id" }) + public model3: Model3; + } - mockGet.mockResolvedValueOnce({ - Item: { - PK: "Pet#123", - SK: "Pet", - Id: "123", - name: "Fido", - OwnerId: undefined // Does not already belong an owner - } - }); + @Entity + class Model2 extends MockTable { + @HasMany(() => Model3, { foreignKey: "model2Id" }) + public model3: Model3[]; + } - expect( + @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 Pet.update("123", { - name: "New Name", - ownerId: null + await Model3.update("123", { + name: "newName", + model1Id: "model1-ID", + model2Id: "model2-ID" }) ).toBeUndefined(); expect(mockSend.mock.calls).toEqual([ @@ -1578,7 +2263,7 @@ describe("Update", () => { [ { TableName: "mock-table", - Key: { PK: "Pet#123", SK: "Pet" }, + Key: { PK: "Model3#123", SK: "Model3" }, ConsistentRead: true } ] @@ -1591,19 +2276,68 @@ describe("Update", () => { { Update: { TableName: "mock-table", - Key: { PK: "Pet#123", SK: "Pet" }, + 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", - "#OwnerId": "OwnerId", "#UpdatedAt": "UpdatedAt" }, ExpressionAttributeValues: { - ":Name": "New Name", + ":Model1Id": "model1-ID", + ":Model2Id": "model2-ID", + ":Name": "newName", ":UpdatedAt": "2023-10-16T03:31:35.918Z" - }, - UpdateExpression: - "SET #Name = :Name, #UpdatedAt = :UpdatedAt REMOVE #OwnerId" + } + } + }, + { + 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" + } } } ] @@ -1611,35 +2345,30 @@ describe("Update", () => { ] ]); }); - }); - 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({ + 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: { - PK: "PaymentMethod#123", - SK: "PaymentMethod", - Id: "123", - lastFour: "1234", - CustomerId: "789" // Already belongs to customer + 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" } }); - }); - - 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 Grade.update("123", { + gradeValue: "B", + assignmentId: "111", + studentId: "222" }) ).toBeUndefined(); expect(mockSend.mock.calls).toEqual([ @@ -1650,8 +2379,8 @@ describe("Update", () => { expect(mockedGetCommand.mock.calls).toEqual([ [ { - TableName: "mock-table", - Key: { PK: "PaymentMethod#123", SK: "PaymentMethod" }, + TableName: "other-table", + Key: { myPk: "Grade|123", mySk: "Grade" }, ConsistentRead: true } ] @@ -1663,53 +2392,80 @@ describe("Update", () => { TransactItems: [ { Update: { - TableName: "mock-table", - Key: { PK: "PaymentMethod#123", SK: "PaymentMethod" }, + TableName: "other-table", + Key: { myPk: "Grade|123", mySk: "Grade" }, UpdateExpression: - "SET #LastFour = :LastFour, #CustomerId = :CustomerId, #UpdatedAt = :UpdatedAt", - ConditionExpression: "attribute_exists(PK)", + "SET #LetterValue = :LetterValue, #assignmentId = :assignmentId, #studentId = :studentId, #updatedAt = :updatedAt", + ConditionExpression: "attribute_exists(myPk)", ExpressionAttributeNames: { - "#CustomerId": "CustomerId", - "#LastFour": "LastFour", - "#UpdatedAt": "UpdatedAt" + "#LetterValue": "LetterValue", + "#assignmentId": "assignmentId", + "#studentId": "studentId", + "#updatedAt": "updatedAt" }, ExpressionAttributeValues: { - ":CustomerId": "456", - ":LastFour": "5678", - ":UpdatedAt": "2023-10-16T03:31:35.918Z" + ":LetterValue": "B", + ":assignmentId": "111", + ":studentId": "222", + ":updatedAt": "2023-10-16T03:31:35.918Z" } } }, { ConditionCheck: { - TableName: "mock-table", - Key: { PK: "Customer#456", SK: "Customer" }, - ConditionExpression: "attribute_exists(PK)" + TableName: "other-table", + Key: { myPk: "Assignment|111", mySk: "Assignment" }, + ConditionExpression: "attribute_exists(myPk)" } }, { - // Delete old BelongsToLink Delete: { - TableName: "mock-table", - Key: { - PK: "Customer#789", - SK: "PaymentMethod#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: "mock-table", - ConditionExpression: "attribute_not_exists(PK)", + TableName: "other-table", + ConditionExpression: "attribute_not_exists(myPk)", 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" + 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" } } } @@ -1718,148 +2474,2274 @@ describe("Update", () => { ] ]); }); + }); - it("will throw an error if the entity being updated does not exist", async () => { - expect.assertions(7); + 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() + }); + }); - mockGet.mockResolvedValueOnce({}); // Entity does not exist but will fail in transaction + it("will not accept function attributes on update", async () => { + @Entity + class MyModel extends MockTable { + @StringAttribute({ alias: "MyAttribute" }) + public myAttribute: string; - mockSend.mockReturnValueOnce(undefined).mockImplementationOnce(() => { - mockTransact(); - throw new TransactionCanceledException({ - message: "MockMessage", - CancellationReasons: [ - { Code: "ConditionalCheckFailed" }, - { Code: "None" }, - { Code: "None" }, - { Code: "None" } - ], - $metadata: {} - }); + 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" }); - 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 - } - ] + // 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("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 + 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" + } ]); - expect(mockTransact.mock.calls).toEqual([[]]); - expect(mockTransactWriteCommand.mock.calls).toEqual([ - [ + }); + }); + + 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(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({ 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(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: [ { - 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)" - } + 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" }, - // 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" - } - } + 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 throw an error if the entity being associated with does not exist", async () => { - expect.assertions(7); + 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); - mockSend.mockReturnValueOnce(undefined).mockImplementationOnce(() => { - mockTransact(); - throw new TransactionCanceledException({ - message: "MockMessage", - CancellationReasons: [ - { Code: "None" }, - { Code: "ConditionalCheckFailed" }, - { Code: "None" }, - { Code: "None" } - ], - $metadata: {} - }); - }); + const now = new Date("2023-10-16T03:31:35.918Z"); + jest.setSystemTime(now); - 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 - } + 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); + + 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(updatedInstance).toBeInstanceOf(ContactInformation); + 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(8); + + 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(updatedInstance).toBeInstanceOf(MockInformation); + 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 #Phone, #State" + } + } + ] + } + ] + ]); + // 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(8); + + 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(updatedInstance).toBeInstanceOf(MockInformation); + 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(8); + + 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(updatedInstance).toBeInstanceOf(MockInformation); + 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(8); + + 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(updatedInstance).toBeInstanceOf(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)" + } + }, + { + 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(8); + + 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(updatedInstance).toBeInstanceOf(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#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" + } + } + } + ] + } + ] + ]); + // 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(8); + + 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(updatedInstance).toBeInstanceOf(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" + }, + 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") + }); + }); + }); + }); + + 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"); + + 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 + } + }); + }); + + afterEach(() => { + mockedUuidv4.mockReset(); + }); + + it("will update the foreign key if the entity being associated with exists", async () => { + expect.assertions(8); + + 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" + }); + + 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(updatedInstance).toBeInstanceOf(PaymentMethod); + 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" + } + } + } + ] + } + ] + ]); + // 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 throw an error if the entity being updated 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: "111" as ForeignKey, + 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({ 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" + } + } + } + ] + } + ] + ]); + } + }); + + 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: "111" as ForeignKey, + 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({ + 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" + } + } + } + ] + } + ] + ]); + } + }); + + it("will remove a nullable foreign key", async () => { + expect.assertions(8); + + 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") + }); + + mockGet.mockResolvedValueOnce({ + Item: { + PK: "Pet#123", + SK: "Pet", + Id: "123", + name: "Fido", + OwnerId: undefined // Does not already belong an owner + } + }); + + 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 + }); + expect(updatedInstance).toBeInstanceOf(Pet); + 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" + } + } + ] + } + ] + ]); + // 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") + }); + }); + }); + + 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"); + + 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 + } + }); + }); + + afterEach(() => { + mockedUuidv4.mockReset(); + }); + + it("will update the foreign key if the entity being associated with exists", async () => { + expect.assertions(8); + + 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" + }); + + 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(updatedInstance).toBeInstanceOf(PaymentMethod); + 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([[]]); @@ -1923,48 +4805,407 @@ 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(8); + + 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") }); - } 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'" - ) - ]); + + 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 + }); + expect(updatedInstance).toBeInstanceOf(Pet); expect(mockSend.mock.calls).toEqual([ [{ name: "GetCommand" }], [{ name: "TransactWriteCommand" }] @@ -1974,7 +5215,7 @@ describe("Update", () => { [ { TableName: "mock-table", - Key: { PK: "PaymentMethod#123", SK: "PaymentMethod" }, + Key: { PK: "Pet#123", SK: "Pet" }, ConsistentRead: true } ] @@ -1987,82 +5228,282 @@ 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(8); + + 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(updatedInstance).toBeInstanceOf(OtherModel3); + 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(8); + + 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(updatedInstance).toBeInstanceOf(Grade); expect(mockSend.mock.calls).toEqual([ [{ name: "GetCommand" }], [{ name: "TransactWriteCommand" }] @@ -2071,8 +5512,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 +5525,237 @@ 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" + } } } ] } ] ]); + // Assert original instance not mutated + expect(instance).toEqual({ + myPk: "Grade#123", + mySk: "Grade", + id: "123", + type: "Grade", + gradeValue: "A+", + assignmentId: "456", + studentId: "789", + createdAt: new Date("2023-10-01"), + updatedAt: new Date("2023-10-02") + }); }); }); - }); - - 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[]; - } + describe("types", () => { + it("will not accept relationship attributes on update", async () => { + const instance = new Order(); - @Entity - class Model3 extends MockTable { - @StringAttribute({ alias: "Name" }) - public name: string; - - @ForeignKeyAttribute({ alias: "Model1Id" }) - public model1Id: ForeignKey; - - @ForeignKeyAttribute({ alias: "Model2Id" }) - public model2Id: ForeignKey; + await instance.update({ + orderDate: new Date(), + paymentMethodId: "123", + customerId: "456", + // @ts-expect-error relationship attributes are not allowed + customer: new Customer() + }); + }); - @BelongsTo(() => Model1, { foreignKey: "model1Id" }) - public model1: Model1; + it("will not accept function attributes on update", async () => { + @Entity + class MyModel extends MockTable { + @StringAttribute({ alias: "MyAttribute" }) + public myAttribute: string; - @BelongsTo(() => Model2, { foreignKey: "model2Id" }) - public model2: Model2; - } + public someMethod(): string { + return "abc123"; + } + } - beforeEach(() => { - jest.setSystemTime(new Date("2023-10-16T03:31:35.918Z")); - mockedUuidv4 - .mockReturnValueOnce("belongsToLinkId1") - .mockReturnValueOnce("belongsToLinkId2"); - }); + const instance = new MyModel(); - it("can update foreign keys for an entity that includes both HasMany and Belongs to relationships", async () => { - expect.assertions(6); + // check that built in instance method is not allowed + await instance.update({ + myAttribute: "someVal", + // @ts-expect-error function attributes are not allowed + update: () => "123" + }); - mockGet.mockResolvedValue({ - Item: { - PK: "Model3#123", - SK: "Model3", - Id: "123", - Name: "originalName", - Phone: "555-555-5555", - Model1Id: undefined, - Model2Id: undefined - } + // check that custom instance method is not allowed + await instance.update({ + myAttribute: "someVal", + // @ts-expect-error function attributes are not allowed + someMethod: () => "123" + }); }); - 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); + it("will allow ForeignKey attributes to be passed at their inferred type without casting to type ForeignKey", async () => { + const instance = new Order(); - 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" - } + 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" + }); }); - 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" - } - } - } - ] - } - ] - ]); - }); - }); + it("will not accept DefaultFields on update because they are managed by dyna-record", async () => { + const instance = new Order(); - 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() - }); - }); + await instance.update({ + // @ts-expect-error default fields are not accepted on update, they are managed by dyna-record + id: "123" + }); - it("will not accept function attributes on update", async () => { - @Entity - class MyModel extends MockTable { - @StringAttribute({ alias: "MyAttribute" }) - public myAttribute: string; + await instance.update({ + // @ts-expect-error default fields are not accepted on update, they are managed by dyna-record + type: "456" + }); - public someMethod(): string { - return "abc123"; - } - } + await instance.update({ + // @ts-expect-error default fields are not accepted on update, they are managed by dyna-record + createdAt: new Date() + }); - await MyModel.update("123", { - myAttribute: "someVal", - // @ts-expect-error function attributes are not allowed - someMethod: () => "123" + await instance.update({ + // @ts-expect-error default fields are not accepted on update, they are managed by dyna-record + updatedAt: new Date() + }); }); - }); - 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 partition and sort keys on update because they are managed by dyna-record", async () => { + const instance = new Order(); - 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 instance.update({ + // @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 default fields are not accepted on update, they are managed by dyna-record - type: "456" + await instance.update({ + // @ts-expect-error sort key fields are not accepted on update, they are managed by dyna-record + sk: "456" + }); }); - await Order.update("123", { - // @ts-expect-error default fields are not accepted on update, they are managed by dyna-record - createdAt: new Date() - }); + it("does not require all of an entity attributes to be passed", 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 - updatedAt: new Date() + 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("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); - it("will not allow non nullable attributes to be removed (set to null)", async () => { - expect.assertions(3); + 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 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" - } - ]); + // 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 + }); }); }); });