Repository: typedorm/typedorm (ts)

Issue:
Allow Updating new attribute that are part of primary keys
DynamoDB by default doesn't allow updating primary key attributes, such as PK and SK. However, this can be achieved by deleting the previous items and creating a new ones.

# Tasks
- [x] At any update operation Identify if attributes being requested to update are part of primary key if the are: get full item and create a lazy transaction for updating item
- [x] add test coverage
- [x] document usage


Pull request:
Feature/126 allow updating primary key attributes
# Features 🚀

- support for the auto-updating primary key when one of the referenced attributes is updated

# Bug fixes 🐛
- apply missing class transformation metadata on the auto-generated attributes


closes #126 

Patch:
diff --git a/README.md b/README.md
index c566fe4..ab70473 100644
--- a/README.md
+++ b/README.md
@@ -37,7 +37,8 @@ TypeDORM borrows decorator based syntax from TypeORM and provides fully type saf
 - Batch manager
 - Multiple connections support
 - Code follows all possible best practices when modeling for dynamodb
-- Additional higher level features like, ability to declare non key attribute as unique, automatic retries
+- Supports specifying non key attribute as unique, built in pagination support for querying
+- Supports updating Primary key attributes
 - Auto Generated values for attributes
 - Auto update attribute values on UPDATE operations
 - Dynamic & Static default values for attributes
diff --git a/docs/api.md b/docs/api.md
index b11488e..1dd0acd 100644
--- a/docs/api.md
+++ b/docs/api.md
@@ -286,7 +286,15 @@ exists(
 
 ### EntityManager.update
 
-Update item with magically generated set operation
+Updates items and related items over document client's update API.
+
+Some additional features that TypeDORM provides on top of Document Client's update api features:
+
+- supports updating attributes that are referenced in primary key over transaction write API
+- supports updating attributes that are marked as unique, again using document client's transaction write API
+- handles updating all auto update attributes at the update time
+
+_Notes: Primary key attributes and non-primary key attributes can not be updated in same request._
 
 ```Typescript
 update(
diff --git a/packages/common/src/error/index.ts b/packages/common/src/error/index.ts
index 8e7499a..1340f92 100644
--- a/packages/common/src/error/index.ts
+++ b/packages/common/src/error/index.ts
@@ -16,3 +16,5 @@ export * from './invalid-select-input-error';
 export * from './no-such-entity-exists-error';
 export * from './missing-required-entity-physical-name-error';
 export * from './duplicate-entity-physical-name-error';
+export * from './invalid-unique-attribute-update-error';
+export * from './invalid-primary-key-attributes-update-error';
diff --git a/packages/common/src/error/invalid-primary-key-attributes-update-error.ts b/packages/common/src/error/invalid-primary-key-attributes-update-error.ts
new file mode 100644
index 0000000..97ce9cb
--- /dev/null
+++ b/packages/common/src/error/invalid-primary-key-attributes-update-error.ts
@@ -0,0 +1,17 @@
+export class InvalidPrimaryKeyAttributesUpdateError extends Error {
+  name = 'InvalidPrimaryKeyAttributesUpdateError';
+
+  constructor(
+    affectedPrimaryKeyAttributes: Record<string, string>,
+    nonKeyAttributes: string[]
+  ) {
+    super();
+    this.message = `Primary key attribute and non primary key attributes can not be
+    updated together. When updating attributes that references a primary key, it can not have
+    additional non key attributes.
+    \n Affected Primary key attributes: ${JSON.stringify(
+      affectedPrimaryKeyAttributes
+    )}
+    \n Tried to update non-key attributes: ${JSON.stringify(nonKeyAttributes)}`;
+  }
+}
diff --git a/packages/common/src/error/invalid-unique-attribute-update-error.ts b/packages/common/src/error/invalid-unique-attribute-update-error.ts
new file mode 100644
index 0000000..ed29a33
--- /dev/null
+++ b/packages/common/src/error/invalid-unique-attribute-update-error.ts
@@ -0,0 +1,12 @@
+export class InvalidUniqueAttributeUpdateError extends Error {
+  name = 'InvalidUniqueAttributeUpdateError';
+
+  constructor(primaryKey: Record<string, string>, attributes: string[]) {
+    super();
+    this.message = `Updates on a unique attributes is not supported when it is also referenced by 
+    primary key.
+    \n Affected primary key: ${JSON.stringify(primaryKey)}.
+    \n Unique Attributes tried to update: ${JSON.stringify(attributes)}.
+    \n If this is something you would like to have implemented, please file a new issue.`;
+  }
+}
diff --git a/packages/common/src/helpers/update-attributes-type.ts b/packages/common/src/helpers/update-attributes-type.ts
index 5c15f53..fbce28b 100644
--- a/packages/common/src/helpers/update-attributes-type.ts
+++ b/packages/common/src/helpers/update-attributes-type.ts
@@ -1,9 +1,9 @@
 import {NonKeyAttributes} from './non-key-attributes-type';
-export type UpdateAttributes<Entity, PrimaryKey> = NonKeyAttributes<
-  Entity,
-  PrimaryKey
-> & {
-  // this is required to support additional nested attributes that may exist on the entity
-  // i.e when trying to use nested attribute like this `user.name.firstName`.
-  [key: string]: any;
-};
+
+// additional `{}` is required to support additional nested attributes that may exist on the entity directly
+// i.e when trying to use nested attribute like this `user.name.firstName`.
+export type UpdateAttributes<Entity, PrimaryKey> =
+  | NonKeyAttributes<Entity, PrimaryKey>
+  // support updating primary key attributes
+  | Partial<PrimaryKey>;
+// | {};
diff --git a/packages/core/__mocks__/photo.ts b/packages/core/__mocks__/photo.ts
index a0be128..fc1a23d 100644
--- a/packages/core/__mocks__/photo.ts
+++ b/packages/core/__mocks__/photo.ts
@@ -3,6 +3,7 @@ import {
   AutoGenerateAttribute,
   AUTO_GENERATE_ATTRIBUTE_STRATEGY,
   Entity,
+  INDEX_TYPE,
   TransformFromDynamo,
   TransformToDynamo,
 } from '@typedorm/common';
@@ -20,6 +21,11 @@ export enum CATEGORY {
   KIDS = 'KIDS',
 }
 
+export interface PhotoPrimaryKey {
+  category: CATEGORY;
+  id: number;
+}
+
 @Entity({
   table,
   name: 'photo',
@@ -27,8 +33,15 @@ export enum CATEGORY {
     partitionKey: 'PHOTO#{{category}}',
     sortKey: 'PHOTO#{{id}}',
   },
+  indexes: {
+    GSI1: {
+      partitionKey: 'PHOTO#{{id}}',
+      sortKey: 'PHOTO#{{category}}',
+      type: INDEX_TYPE.GSI,
+    },
+  },
 })
-export class Photo {
+export class Photo implements PhotoPrimaryKey {
   @AutoGenerateAttribute({
     strategy: AUTO_GENERATE_ATTRIBUTE_STRATEGY.UUID4,
   })
@@ -59,6 +72,15 @@ export class Photo {
     this.category = category;
   }
 
+  @AutoGenerateAttribute({
+    strategy: AUTO_GENERATE_ATTRIBUTE_STRATEGY.EPOCH_DATE,
+    autoUpdate: true,
+  })
+  @TransformToDynamo(({value}) => {
+    return value.toString();
+  })
+  updatedAt: Date;
+
   createdDate() {
     return this.createdAt.format('MM-DD-YYYY');
   }
diff --git a/packages/core/src/classes/connection/connection.ts b/packages/core/src/classes/connection/connection.ts
index f7353d4..08b23f9 100644
--- a/packages/core/src/classes/connection/connection.ts
+++ b/packages/core/src/classes/connection/connection.ts
@@ -121,12 +121,6 @@ export class Connection {
   getUniqueAttributesForEntity<Entity>(entityClass: EntityTarget<Entity>) {
     const entityMetadata = this.getEntityByTarget(entityClass);
 
-    if (!entityMetadata) {
-      throw new Error(
-        'Could not get unique attributes for entity, every class to be used as entity must have @Entity decorator on it.'
-      );
-    }
-
     return this.getAttributesForEntity<Entity>(entityClass).filter(attr => {
       // only attributes that are not part of primary key should be included
       return (
@@ -140,6 +134,24 @@ export class Connection {
     >[];
   }
 
+  /**
+   * Returns a list of attribute names that are referenced in primary key
+   * @param entityClass Entity to get primary key attributes for
+   * @returns
+   */
+  getPrimaryKeyAttributeInterpolationsForEntity<Entity>(
+    entityClass: EntityTarget<Entity>
+  ) {
+    const entityMetadata = this.getEntityByTarget(entityClass);
+    return [
+      ...new Set(
+        Object.values(
+          entityMetadata.schema.primaryKey.metadata._interpolations ?? {}
+        ).flat()
+      ),
+    ];
+  }
+
   getEntityByTarget<Entity>(entityClass: EntityTarget<Entity>) {
     const metadata = this._entityMetadatas.get(entityClass.name);
     if (!metadata) {
diff --git a/packages/core/src/classes/transformer/base-transformer.ts b/packages/core/src/classes/transformer/base-transformer.ts
index 1075df1..655e1bb 100644
--- a/packages/core/src/classes/transformer/base-transformer.ts
+++ b/packages/core/src/classes/transformer/base-transformer.ts
@@ -37,6 +37,15 @@ export abstract class BaseTransformer {
     return entityMetadata.table.name;
   }
 
+  applyClassTransformerFormations<Entity>(entity: Entity) {
+    const transformedPlainEntity = classToPlain<Entity>(entity, {
+      enableImplicitConversion: true,
+      excludePrefixes: ['__'],
+    });
+
+    return transformedPlainEntity as Entity;
+  }
+
   /**
    * Transforms entity to dynamo db entity schema
    * @param entity Entity to transform to DynamoDB entity type
@@ -44,16 +53,7 @@ export abstract class BaseTransformer {
   toDynamoEntity<Entity>(entity: Entity) {
     const entityClass = getConstructorForInstance(entity);
 
-    // pass through entity to class transformer to have all the metadata applied
-    const transformedPlainEntity = classToPlain(entity, {
-      enableImplicitConversion: true,
-      excludePrefixes: ['__'],
-    });
-
-    let parsedEntity = {
-      ...entity,
-      ...transformedPlainEntity,
-    } as Entity;
+    let parsedEntity = entity;
 
     // retrieve metadata and parse it to schema
     const entityMetadata = this.connection.getEntityByTarget(entityClass);
@@ -61,13 +61,16 @@ export abstract class BaseTransformer {
     //  auto populate generated values
     this.connection.getAttributesForEntity(entityClass).forEach(attr => {
       if (IsAutoGeneratedAttributeMetadata(attr)) {
-        parsedEntity = Object.assign({
-          ...parsedEntity,
+        // object assign because, we want to preserve the existing prototype on instance
+        parsedEntity = Object.assign(parsedEntity, {
           [attr.name]: attr.value,
         });
       }
     });
 
+    // pass through entity to class transformer to have all the metadata applied
+    parsedEntity = this.applyClassTransformerFormations(parsedEntity);
+
     const parsedPrimaryKey = this.recursiveParseEntity(
       entityMetadata.schema.primaryKey.attributes,
       parsedEntity
@@ -124,6 +127,52 @@ export abstract class BaseTransformer {
     return {...parsedEntity, ...formattedSchema};
   }
 
+  getAffectedPrimaryKeyAttributes<Entity, PrimaryKey>(
+    entityClass: EntityTarget<Entity>,
+    attributes: UpdateAttributes<Entity, PrimaryKey>
+  ) {
+    const {
+      schema: {primaryKey},
+    } = this.connection.getEntityByTarget(entityClass);
+
+    const interpolations = primaryKey.metadata._interpolations;
+
+    // if none of partition or sort key has any referenced attributes, return
+    if (!interpolations || isEmptyObject(interpolations)) {
+      return;
+    }
+
+    const affectedKeyAttributes = Object.entries(attributes).reduce(
+      (acc, [attrKey, attrValue]) => {
+        // bail early if current attribute type is not of type scalar
+        if (!isScalarType(attrValue)) {
+          return acc;
+        }
+
+        // resolve all interpolations
+        Object.entries(interpolations).forEach(
+          ([primaryKeyAttrName, primaryKeyAttrRefs]) => {
+            // if no attributes are referenced for current primary key attribute, return
+            if (!primaryKeyAttrRefs.includes(attrKey)) {
+              return;
+            }
+
+            const parsedKey = parseKey(
+              primaryKey.attributes[primaryKeyAttrName],
+              attributes
+            );
+            acc[primaryKeyAttrName] = parsedKey;
+          }
+        );
+
+        return acc;
+      },
+      {} as Record<string, string>
+    );
+
+    return affectedKeyAttributes;
+  }
+
   /**
    * Returns all affected indexes for given attributes
    * @param entityClass Entity class
@@ -142,8 +191,8 @@ export abstract class BaseTransformer {
 
     const affectedIndexes = Object.keys(attributes).reduce(
       (acc, attrKey: string) => {
-        const currAttrValue = attributes[attrKey];
-        // if current value is not if scalar type skip checking index
+        const currAttrValue = (attributes as any)[attrKey];
+        // if current value is not of scalar type skip checking index
         if (
           attrKey.includes(nestedKeySeparator) ||
           !isScalarType(currAttrValue)
@@ -202,7 +251,7 @@ export abstract class BaseTransformer {
   getParsedPrimaryKey<Entity>(
     table: Table,
     primaryKey: DynamoEntitySchemaPrimaryKey,
-    attributes: {[key in keyof Entity]: any}
+    attributes: Partial<Entity>
   ) {
     return this.recursiveParseEntity(primaryKey.attributes, attributes);
   }
diff --git a/packages/core/src/classes/transformer/document-client-request-transformer.ts b/packages/core/src/classes/transformer/document-client-request-transformer.ts
index d743f8c..a076593 100644
--- a/packages/core/src/classes/transformer/document-client-request-transformer.ts
+++ b/packages/core/src/classes/transformer/document-client-request-transformer.ts
@@ -12,6 +12,8 @@ import {
   NoSuchIndexFoundError,
   InvalidFilterInputError,
   InvalidSelectInputError,
+  InvalidUniqueAttributeUpdateError,
+  InvalidPrimaryKeyAttributesUpdateError,
 } from '@typedorm/common';
 import {DynamoDB} from 'aws-sdk';
 import {dropProp} from '../../helpers/drop-prop';
@@ -425,7 +427,50 @@ export class DocumentClientRequestTransformer extends BaseTransformer {
       {} as {[key: string]: any}
     );
 
-    const attributesToUpdate = {...body, ...formattedAutoUpdateAttributes};
+    // we manually need to replace the constructor of the attributes to update
+    // with the entity class, so that we can pass it through to class-transformer
+    // to have all transformer metadata applied.
+    const attributesToUpdateRaw = {...body, ...formattedAutoUpdateAttributes};
+    attributesToUpdateRaw.constructor = entityClass;
+    const attributesToUpdate = this.applyClassTransformerFormations(
+      attributesToUpdateRaw
+    ) as typeof attributesToUpdateRaw;
+
+    // check if attributes to update references a primary key, if it does update must be done lazily
+    const affectedPrimaryKeyAttributes = this.getAffectedPrimaryKeyAttributes<
+      Entity,
+      PrimaryKey
+    >(entityClass, attributesToUpdate);
+
+    const uniqueAttributesToUpdate = this.connection
+      .getUniqueAttributesForEntity(entityClass)
+      .filter(attr => !!(body as any)[attr.name]);
+
+    // validate update attributes
+    if (!isEmptyObject(affectedPrimaryKeyAttributes)) {
+      const primaryKeyReferencedAttributes = this.connection.getPrimaryKeyAttributeInterpolationsForEntity(
+        entityClass
+      );
+      const nonKeyAttributesToUpdate = Object.keys(body).filter(
+        attr => !primaryKeyReferencedAttributes.includes(attr)
+      );
+
+      // updates are not allowed for attributes that unique and also references primary key.
+      if (uniqueAttributesToUpdate.length) {
+        throw new InvalidUniqueAttributeUpdateError(
+          affectedPrimaryKeyAttributes!,
+          uniqueAttributesToUpdate.map(attr => attr.name)
+        );
+      }
+
+      // primary key and non key attributes can not be updated together
+      if (nonKeyAttributesToUpdate.length) {
+        throw new InvalidPrimaryKeyAttributesUpdateError(
+          affectedPrimaryKeyAttributes!,
+          nonKeyAttributesToUpdate
+        );
+      }
+    }
 
     // get all affected indexes for attributes
     const affectedIndexes = this.getAffectedIndexesForAttributes<
@@ -435,30 +480,16 @@ export class DocumentClientRequestTransformer extends BaseTransformer {
       nestedKeySeparator,
     });
 
-    const {
-      UpdateExpression,
-      ExpressionAttributeNames,
-      ExpressionAttributeValues,
-    } = this.expressionBuilder.buildUpdateExpression({
-      ...attributesToUpdate,
-      ...affectedIndexes,
-    });
-
-    const uniqueAttributesToUpdate = this.connection
-      .getUniqueAttributesForEntity(entityClass)
-      .filter(attr => !!body[attr.name]);
-
-    const itemToUpdate: DynamoDB.DocumentClient.UpdateItemInput = {
+    const itemToUpdate:
+      | DynamoDB.DocumentClient.UpdateItemInput
+      | DynamoDB.DocumentClient.PutItemInput = {
       TableName: tableName,
       Key: {
         ...parsedPrimaryKey,
       },
       ReturnConsumedCapacity: metadataOptions?.returnConsumedCapacity,
-      UpdateExpression,
       // request all new attributes
       ReturnValues: RETURN_VALUES.ALL_NEW,
-      ExpressionAttributeNames,
-      ExpressionAttributeValues,
     };
 
     // if 'where' was provided, build condition expression
@@ -484,17 +515,94 @@ export class DocumentClientRequestTransformer extends BaseTransformer {
       // append condition expression if one was built
       itemToUpdate.ConditionExpression = ConditionExpression;
       itemToUpdate.ExpressionAttributeNames = {
-        ...itemToUpdate.ExpressionAttributeNames,
         ...ExpressionAttributeNames,
+        ...itemToUpdate.ExpressionAttributeNames,
       };
       itemToUpdate.ExpressionAttributeValues = {
-        ...itemToUpdate.ExpressionAttributeValues,
         ...ExpressionAttributeValues,
+        ...itemToUpdate.ExpressionAttributeValues,
+      };
+    }
+
+    // 1.1 update contains primary key attributes
+    // if tried updating attribute that is referenced in a primary key build lazy expression
+    if (!isEmptyObject(affectedPrimaryKeyAttributes)) {
+      const lazyLoadTransactionWriteItems = this.lazyToDynamoUpdatePrimaryKeyFactory(
+        metadata.table,
+        metadata.name,
+        metadata.schema.primaryKey,
+        {
+          Item: {
+            ...affectedPrimaryKeyAttributes,
+            ...affectedIndexes,
+            ...attributesToUpdate,
+          },
+          TableName: metadata.table.name,
+          ReturnConsumedCapacity: itemToUpdate.ReturnConsumedCapacity,
+          ReturnValues: itemToUpdate.ReturnValues,
+          ConditionExpression: itemToUpdate.ConditionExpression,
+          ExpressionAttributeNames: itemToUpdate.ExpressionAttributeNames,
+          ExpressionAttributeValues: itemToUpdate.ExpressionAttributeValues,
+        },
+        metadataOptions
+      );
+
+      return {
+        primaryKeyAttributes,
+        entityClass,
+        lazyLoadTransactionWriteItems,
       };
     }
 
-    // when item does not have any unique attributes to update, return putItemInput
-    if (!uniqueAttributesToUpdate.length) {
+    // for any other non key attribute updates, build update expression
+    const {
+      UpdateExpression,
+      ExpressionAttributeNames,
+      ExpressionAttributeValues,
+    } = this.expressionBuilder.buildUpdateExpression(
+      {
+        ...attributesToUpdate,
+        ...affectedIndexes,
+      },
+      {
+        nestedKeySeparator,
+      }
+    );
+    itemToUpdate.UpdateExpression = UpdateExpression;
+    itemToUpdate.ExpressionAttributeNames = {
+      ...ExpressionAttributeNames,
+      ...itemToUpdate.ExpressionAttributeNames,
+    };
+    itemToUpdate.ExpressionAttributeValues = {
+      ...ExpressionAttributeValues,
+      ...itemToUpdate.ExpressionAttributeValues,
+    };
+
+    // 1.2 update contains unique attributes
+    // if tried updating a unique attribute, build a lazy expression
+    if (uniqueAttributesToUpdate.length) {
+      // if there are unique attributes, return a lazy loader, which will return write item list
+      const lazyLoadTransactionWriteItems = this.lazyToDynamoUpdateUniqueItemFactory<
+        Entity,
+        PrimaryKey
+      >(
+        metadata.table,
+        metadata.name,
+        uniqueAttributesToUpdate,
+        dropProp(itemToUpdate, 'ReturnValues'),
+        body,
+        metadataOptions
+      );
+
+      return {
+        primaryKeyAttributes,
+        entityClass,
+        lazyLoadTransactionWriteItems,
+      };
+
+      // 1.3 update contains simple attribute updates
+    } else {
+      // return default transform
       this.connection.logger.logTransform({
         requestId: metadataOptions?.requestId,
         operation: TRANSFORM_TYPE.UPDATE,
@@ -505,25 +613,6 @@ export class DocumentClientRequestTransformer extends BaseTransformer {
       });
       return itemToUpdate;
     }
-
-    // if there are unique attributes, return a lazy loader, which will return write item list
-    const lazyLoadTransactionWriteItems = this.lazyToDynamoUpdateItemFactory<
-      Entity,
-      PrimaryKey
-    >(
-      metadata.table,
-      metadata.name,
-      uniqueAttributesToUpdate,
-      dropProp(itemToUpdate, 'ReturnValues'),
-      body,
-      metadataOptions
-    );
-
-    return {
-      primaryKeyAttributes,
-      entityClass,
-      lazyLoadTransactionWriteItems,
-    };
   }
 
   toDynamoDeleteItem<Entity, PrimaryKey>(
@@ -859,13 +948,60 @@ export class DocumentClientRequestTransformer extends BaseTransformer {
     return queryInputParams;
   }
 
+  private lazyToDynamoUpdatePrimaryKeyFactory(
+    table: Table,
+    entityName: string,
+    primaryKeySchema: DynamoEntitySchemaPrimaryKey,
+    newItemBody: DynamoDB.DocumentClient.PutItemInput,
+    metadataOptions?: MetadataOptions
+  ) {
+    return (previousItemBody: any) => {
+      const updateTransactionItems: DynamoDB.DocumentClient.TransactWriteItemList = [
+        {
+          Put: {
+            ...newItemBody,
+            // import existing current item
+            Item: {...previousItemBody, ...newItemBody.Item},
+          },
+        },
+      ] as DynamoDB.DocumentClient.TransactWriteItem[];
+
+      // if there was a previous existing item, basically remove it as part of this transaction
+      if (previousItemBody && !isEmptyObject(previousItemBody)) {
+        updateTransactionItems.push({
+          Delete: {
+            TableName: table.name,
+            Key: {
+              ...this.getParsedPrimaryKey(
+                table,
+                primaryKeySchema,
+                previousItemBody
+              ),
+            },
+          },
+        });
+      }
+
+      this.connection.logger.logTransform({
+        requestId: metadataOptions?.requestId,
+        operation: TRANSFORM_TYPE.UPDATE,
+        prefix: 'After',
+        entityName,
+        primaryKey: null,
+        body: updateTransactionItems,
+      });
+
+      return updateTransactionItems;
+    };
+  }
+
   /**
    * Lazy build update item input
    * This is helpful in cases where we don't you have all the attributes to build item input, and the caller will need to
    * to perform some sort of async call in order to fetch attributes and proceed with build
    *
    */
-  private lazyToDynamoUpdateItemFactory<Entity, PrimaryKey>(
+  private lazyToDynamoUpdateUniqueItemFactory<Entity, PrimaryKey>(
     table: Table,
     entityName: string,
     uniqueAttributesToUpdate: Replace<
@@ -894,7 +1030,11 @@ export class DocumentClientRequestTransformer extends BaseTransformer {
               Put: {
                 TableName: table.name,
                 Item: {
-                  ...this.getParsedPrimaryKey(table, attr.unique, newBody),
+                  ...this.getParsedPrimaryKey(
+                    table,
+                    attr.unique,
+                    newBody as Partial<Entity>
+                  ),
                 },
                 ...uniqueRecordConditionExpression,
               },


Test patch:
diff --git a/packages/core/src/classes/transformer/__test__/dc-request-transformer.spec.ts b/packages/core/src/classes/transformer/__test__/dc-request-transformer.spec.ts
index 7c8b04f..3adacb7 100644
--- a/packages/core/src/classes/transformer/__test__/dc-request-transformer.spec.ts
+++ b/packages/core/src/classes/transformer/__test__/dc-request-transformer.spec.ts
@@ -1,4 +1,10 @@
-import {Attribute, Entity, QUERY_ORDER} from '@typedorm/common';
+import {
+  Attribute,
+  Entity,
+  InvalidPrimaryKeyAttributesUpdateError,
+  InvalidUniqueAttributeUpdateError,
+  QUERY_ORDER,
+} from '@typedorm/common';
 import {Customer} from '../../../../__mocks__/inherited-customer';
 import {table} from '../../../../__mocks__/table';
 import {User, UserGSI1} from '../../../../__mocks__/user';
@@ -9,11 +15,15 @@ import {
   UserUniqueEmail,
   UserUniqueEmailPrimaryKey,
 } from '../../../../__mocks__/user-unique-email';
+import {CATEGORY, Photo, PhotoPrimaryKey} from '@typedorm/core/__mocks__/photo';
+// eslint-disable-next-line node/no-extraneous-import
+import moment from 'moment';
+jest.useFakeTimers('modern').setSystemTime(1622530750000);
 
 let transformer: DocumentClientRequestTransformer;
 beforeEach(async () => {
   const connection = createTestConnection({
-    entities: [User, Customer, UserUniqueEmail],
+    entities: [User, Customer, UserUniqueEmail, Photo],
   });
   transformer = new DocumentClientRequestTransformer(connection);
 });
@@ -646,6 +656,150 @@ test('transforms update item record with unique attributes and condition options
   ]);
 });
 
+test('transforms update item with primary key changes', () => {
+  const updateItem = transformer.toDynamoUpdateItem<
+    UserUniqueEmail,
+    UserPrimaryKey
+  >(
+    UserUniqueEmail,
+    {
+      id: '1',
+    },
+    {
+      id: '1a',
+    }
+  );
+
+  const lazyWriteItemListLoader = (updateItem as any)
+    .lazyLoadTransactionWriteItems;
+  expect(typeof lazyWriteItemListLoader).toEqual('function');
+
+  // with following existing item
+  const writeItemList = lazyWriteItemListLoader({
+    id: '1',
+    PK: 'USER#1',
+    SK: 'USER#1',
+    name: 'new name',
+    email: 'user@email.com',
+    __en: 'user',
+  });
+
+  expect(writeItemList).toEqual([
+    {
+      Put: {
+        Item: {
+          email: 'user@email.com',
+          id: '1a',
+          name: 'new name',
+          PK: 'USER#1a',
+          SK: 'USER#1a',
+          __en: 'user',
+        },
+        ReturnValues: 'ALL_NEW',
+        TableName: 'test-table',
+      },
+    },
+    {
+      Delete: {
+        Key: {
+          PK: 'USER#1',
+          SK: 'USER#1',
+        },
+        TableName: 'test-table',
+      },
+    },
+  ]);
+});
+
+test('transforms update item with attributes that reference primary key and indexes', () => {
+  const updateItem = transformer.toDynamoUpdateItem<Photo, PhotoPrimaryKey>(
+    Photo,
+    {
+      category: CATEGORY.PETS,
+      id: 1,
+    },
+    {
+      id: 2,
+    }
+  );
+
+  const lazyWriteItemListLoader = (updateItem as any)
+    .lazyLoadTransactionWriteItems;
+  expect(typeof lazyWriteItemListLoader).toEqual('function');
+
+  // with following existing item
+  const writeItemList = lazyWriteItemListLoader({
+    id: 1,
+    PK: 'PHOTO#PETS',
+    SK: 'PHOTO#1',
+    category: 'PETS',
+    GSI1PK: 'PHOTO#1',
+    GSI1SK: 'PHOTO#PETS',
+    __en: 'photo',
+  });
+
+  expect(writeItemList).toEqual([
+    {
+      Put: {
+        Item: {
+          GSI1PK: 'PHOTO#2',
+          GSI1SK: 'PHOTO#PETS',
+          PK: 'PHOTO#PETS',
+          SK: 'PHOTO#2',
+          __en: 'photo',
+          category: 'PETS',
+          id: 2,
+          updatedAt: '1622530750',
+        },
+        ReturnValues: 'ALL_NEW',
+        TableName: 'test-table',
+      },
+    },
+    {
+      Delete: {
+        Key: {
+          PK: 'PHOTO#PETS',
+          SK: 'PHOTO#1',
+        },
+        TableName: 'test-table',
+      },
+    },
+  ]);
+});
+
+test('throws when trying to update primary key attribute and unique attribute in the same request', () => {
+  const updateItem = () =>
+    transformer.toDynamoUpdateItem<UserUniqueEmail, UserUniqueEmailPrimaryKey>(
+      UserUniqueEmail,
+      {
+        id: 'OLD_ID',
+      },
+      {
+        id: 'NEW_ID',
+        email: 'new@email.com',
+      }
+    );
+
+  expect(updateItem).toThrow(InvalidUniqueAttributeUpdateError);
+});
+
+test('throws when trying to update primary key attribute and non key attribute in the same request', () => {
+  const updateItem = () =>
+    transformer.toDynamoUpdateItem<Photo, PhotoPrimaryKey>(
+      Photo,
+      {
+        category: CATEGORY.PETS,
+        id: 1,
+      },
+      {
+        id: 2,
+        createdAt: moment(),
+      }
+    );
+
+  expect(updateItem).toThrow(InvalidPrimaryKeyAttributesUpdateError);
+});
+
 test('transforms update item request with complex condition input', () => {
   const updatedItem = transformer.toDynamoUpdateItem<User, UserPrimaryKey>(
     User,
diff --git a/packages/core/src/classes/transformer/__test__/entity-transformer.spec.ts b/packages/core/src/classes/transformer/__test__/entity-transformer.spec.ts
index 294317f..62ebc24 100644
--- a/packages/core/src/classes/transformer/__test__/entity-transformer.spec.ts
+++ b/packages/core/src/classes/transformer/__test__/entity-transformer.spec.ts
@@ -22,6 +22,7 @@ import moment from 'moment';
 jest.mock('uuid', () => ({
   v4: () => 'c0ac5395-ba7c-41bf-bbc3-09a6087bcca2',
 }));
+jest.useFakeTimers('modern').setSystemTime(1622530750000);
 
 let transformer: EntityTransformer;
 beforeEach(() => {
@@ -170,13 +171,15 @@ test('excludes hidden props from returned response', () => {
   });
 });
 
-test('transforms dynamo entity to entity model instance ', () => {
+test('transforms photo dynamo item to entity model instance ', () => {
   const dynamoEntity = {
     PK: 'PHOTO#PETS',
     SK: 'PHOTO#1',
     id: 1,
     category: 'PETS',
     name: 'my cute pet billy',
+    GSI1PK: 'PHOTO#c0ac5395-ba7c-41bf-bbc3-09a6087bcca2',
+    GSI1SK: 'PHOTO#kids-new',
     createdAt: '2020-03-21T03:26:34.781Z',
   };
   const transformed = transformer.fromDynamoEntity(Photo, dynamoEntity);
@@ -211,7 +214,7 @@ test('transforms simple model to dynamo entity', () => {
   });
 });
 
-test('transforms dynamo entity to entity model instance ', () => {
+test('transforms photo entity to valid dynamo item ', () => {
   const photo = new Photo(CATEGORY.KIDS, 'my baby');
 
   const response = transformer.toDynamoEntity(photo);
@@ -219,8 +222,11 @@ test('transforms dynamo entity to entity model instance ', () => {
   expect(response).toEqual({
     PK: 'PHOTO#kids-new',
     SK: 'PHOTO#c0ac5395-ba7c-41bf-bbc3-09a6087bcca2',
+    GSI1PK: 'PHOTO#c0ac5395-ba7c-41bf-bbc3-09a6087bcca2',
+    GSI1SK: 'PHOTO#kids-new',
     category: 'kids-new',
     id: 'c0ac5395-ba7c-41bf-bbc3-09a6087bcca2',
+    updatedAt: '1622530750',
     name: 'my baby',
   });
 });