Repository: typedorm/typedorm (ts)

Issue:
Auto deserialise response items to relevant entities
Currently, TypeDORM not deserialize js objects into valid TypeDORM entity.

One of the popular ways to deserialize js to a valid class instance using class-transformer, we might also be able to reuse that here.

# Tasks 
- [x] implement class transformer based entity deserialization


Pull request description:
Advanced attribute transformation support
# Changes

- Add support for advanced per attribute transformation (enabled via class-transformer)

closes #124 #113

Patch:
diff --git a/README.md b/README.md
index ec1451d..c566fe4 100644
--- a/README.md
+++ b/README.md
@@ -27,8 +27,9 @@ TypeDORM borrows decorator based syntax from TypeORM and provides fully type saf
 
 ## Features
 
-- Single table design first class support
+- Single-Table design pattern first class support
 - DataMapper development pattern
+- Attribute level per entity transformation, enabled via [class-transformer](https://github.com/typestack/class-transformer)
 - Full type safety
 - Declarative relational schema
 - Entity manager
diff --git a/docs/how-to-recipes.md b/docs/how-to-recipes.md
index 005f045..b54c50d 100644
--- a/docs/how-to-recipes.md
+++ b/docs/how-to-recipes.md
@@ -6,6 +6,9 @@ This page will walk you through some of the unique recipes to enhance your devel
   - [Entity configuration](#entity-configuration)
     - [Define a simple entity](#define-a-simple-entity)
     - [Define an entity with index](#define-an-entity-with-index)
+  - [Attribute Transformation](#attribute-transformation)
+    - [Using TransformToDynamo](#using-transformtodynamo)
+    - [Using TransformFromDynamo](#using-transformfromdynamo)
   - [Define default values](#define-default-values)
     - [Static Default values](#static-default-values)
     - [Dynamic Default values](#dynamic-default-values)
@@ -78,6 +81,53 @@ class User {
 }
 ```
 
+## Attribute Transformation
+
+Attribute transformation in TypeDORM is enabled via third party package: class-transformer.
+Most of the decorators provided by class-transformer should work by default, if there are any not behaving
+the way they should be, please consider opening an issue.
+
+All entities are passed through to class-transformer two times form any given read operation:
+
+- First, when class instance is transformed to JSON to insert into DynamoDB.
+
+  - `TransformToDynamo`: used to overwrite attribute at insertion time (serialization)
+
+- Second, when receiving JSON object from DynamoDB and returning to client.
+
+  - `TransformFromDynamo`: used to overwrite attribute at retrieval time (de-serialization)
+
+### Using TransformToDynamo
+
+```Typescript
+@Entity({
+  ...entity options
+})
+export class Photo {
+  @Attribute()
+  @Type(() => Date)
+  @TransformToDynamo(({value}: {value: Moment}) => value.toString())
+  createdAt: Moment;
+}
+
+```
+
+### Using TransformFromDynamo
+
+```Typescript
+@Entity({
+  ...entity options
+})
+export class Photo {
+
+  @Attribute()
+  @Type(() => Date)
+  @TransformFromDynamo(({value}) => moment(value))
+  createdAt: Moment;
+}
+
+```
+
 ## Define default values
 
 `@Attribute` supports specifying default values by providing default values to add to entity at the creation time.
diff --git a/package-lock.json b/package-lock.json
index 8ce211b..a9b72c2 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -2079,6 +2079,11 @@
       "integrity": "sha512-uc2Vix1frTfnuzxxu1Hp4ktSvM3QaI4oXl4ZUqL1wjTu/BGki9TrCWoqLTg/drR1KwAEarXuRFCG2Svr1GxPFw==",
       "dev": true
     },
+    "class-transformer": {
+      "version": "0.4.0",
+      "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.4.0.tgz",
+      "integrity": "sha512-ETWD/H2TbWbKEi7m9N4Km5+cw1hNcqJSxlSYhsLsNjQzWWiZIYA1zafxpK9PwVfaZ6AqR5rrjPVUBGESm5tQUA=="
+    },
     "class-utils": {
       "version": "0.3.6",
       "resolved": "https://registry.npmjs.org/class-utils/-/class-utils-0.3.6.tgz",
@@ -5750,6 +5755,11 @@
       "integrity": "sha512-xV2bxeN6F7oYjZWTe/YPAy6MN2M+sL4u/Rlm2AHCIVGfo2p1yGmBHQ6vHehl4bRTZBdHu3TSkWdYgkwpYzAGSw==",
       "dev": true
     },
+    "moment": {
+      "version": "2.29.1",
+      "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.1.tgz",
+      "integrity": "sha512-kHmoybcPV8Sqy59DwNDY3Jefr64lK/by/da0ViFcuA4DH0vQg5Q6Ze5VimxkfQNSC+Mls/Kx53s7TjP1RhFEDQ=="
+    },
     "ms": {
       "version": "2.1.2",
       "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
diff --git a/package.json b/package.json
index 7ec7c7a..832b312 100644
--- a/package.json
+++ b/package.json
@@ -22,9 +22,11 @@
   "dependencies": {
     "aws-sdk": "^2.799.0",
     "chalk": "^4.1.0",
+    "class-transformer": "^0.4.0",
     "debug": "^4.3.1",
     "fast-glob": "^3.2.4",
     "ksuid": "^2.0.0",
+    "moment": "^2.29.1",
     "p-limit": "^3.1.0",
     "reflect-metadata": "^0.1.13",
     "uuid": "^8.3.1"
diff --git a/packages/common/BUILD b/packages/common/BUILD
index 22e6538..b87b94d 100644
--- a/packages/common/BUILD
+++ b/packages/common/BUILD
@@ -10,7 +10,8 @@ setup_ts_build(
         "@npm//@types/uuid",
         "@npm//debug",
         "@npm//@types/debug",
-        "@npm//chalk"
+        "@npm//chalk",
+        "@npm//class-transformer"
     ],
     visibility = ["//packages:__subpackages__"]
 )
diff --git a/packages/common/package.json b/packages/common/package.json
index 64a767d..8442afc 100644
--- a/packages/common/package.json
+++ b/packages/common/package.json
@@ -14,7 +14,8 @@
   "dependencies": {
     "debug": "^4.3.1",
     "chalk": "^4.1.0",
-    "uuid": "^8.3.1"
+    "uuid": "^8.3.1",
+    "class-transformer": "^0.4.0"
   },
   "publishConfig": {
     "tag": "TAG-PLACEHOLDER",
diff --git a/packages/common/public-api.ts b/packages/common/public-api.ts
index 8923d6f..1a8f103 100644
--- a/packages/common/public-api.ts
+++ b/packages/common/public-api.ts
@@ -2,6 +2,8 @@
 export * from './src/decorators/attribute.decorator';
 export * from './src/decorators/auto-generate-attribute.decorator';
 export * from './src/decorators/entity.decorator';
+export * from './src/decorators/transform-from-dynamo.decorator';
+export * from './src/decorators/transform-to-dynamo.decorator';
 
 // utils
 export * from './src/helpers/common-types';
diff --git a/packages/common/src/decorators/transform-from-dynamo.decorator.ts b/packages/common/src/decorators/transform-from-dynamo.decorator.ts
new file mode 100644
index 0000000..f95f496
--- /dev/null
+++ b/packages/common/src/decorators/transform-from-dynamo.decorator.ts
@@ -0,0 +1,17 @@
+import {Transform} from 'class-transformer';
+import {HeadType} from '../helpers/head-type';
+
+/**
+ * Simple @Transform Wrapper to only run when transforming items from DynamoDB
+ * @param transformFn Transform function to run, this is passed to class-transformer without any modifications
+ *
+ * @see https://github.com/typestack/class-transformer#basic-usage for usage examples
+ */
+export const TransformFromDynamo = (
+  transformFn: HeadType<Parameters<typeof Transform>>
+) => {
+  return Transform(transformFn, {
+    // run only for when transforming object literals to class type
+    toClassOnly: true,
+  });
+};
diff --git a/packages/common/src/decorators/transform-to-dynamo.decorator.ts b/packages/common/src/decorators/transform-to-dynamo.decorator.ts
new file mode 100644
index 0000000..0591154
--- /dev/null
+++ b/packages/common/src/decorators/transform-to-dynamo.decorator.ts
@@ -0,0 +1,17 @@
+import {Transform} from 'class-transformer';
+import {HeadType} from '../helpers/head-type';
+
+/**
+ * Simple @Transform wrapper to run when transforming items for to insert into DynamoDB
+ * @param transformFn Transform function to run, this is passed to class-transformer without any modifications
+ *
+ * @see https://github.com/typestack/class-transformer#basic-usage for usage examples
+ */
+export const TransformToDynamo = (
+  transformFn: HeadType<Parameters<typeof Transform>>
+) => {
+  return Transform(transformFn, {
+    // run only for when transforming class instance to object literal
+    toPlainOnly: true,
+  });
+};
diff --git a/packages/common/src/helpers/head-type.ts b/packages/common/src/helpers/head-type.ts
new file mode 100644
index 0000000..1b19ce1
--- /dev/null
+++ b/packages/common/src/helpers/head-type.ts
@@ -0,0 +1,3 @@
+export type HeadType<T extends any[]> = T extends [any, ...any[]]
+  ? T[0]
+  : never;
diff --git a/packages/core/BUILD b/packages/core/BUILD
index f8fc8c8..e7ad213 100644
--- a/packages/core/BUILD
+++ b/packages/core/BUILD
@@ -11,7 +11,8 @@ setup_ts_build(
         "@npm//ksuid",
         "@npm//fast-glob",
         "@npm//@types/uuid",
-        "@npm//p-limit"
+        "@npm//p-limit",
+        "@npm//class-transformer"
     ],
     visibility = ["//packages:__subpackages__"]
 
diff --git a/packages/core/__mocks__/photo.ts b/packages/core/__mocks__/photo.ts
new file mode 100644
index 0000000..a0be128
--- /dev/null
+++ b/packages/core/__mocks__/photo.ts
@@ -0,0 +1,65 @@
+import {
+  Attribute,
+  AutoGenerateAttribute,
+  AUTO_GENERATE_ATTRIBUTE_STRATEGY,
+  Entity,
+  TransformFromDynamo,
+  TransformToDynamo,
+} from '@typedorm/common';
+import {Type} from 'class-transformer';
+import {table} from './table';
+
+// Moment is only being used here to display the usage of @transform utility
+// eslint-disable-next-line node/no-extraneous-import
+import moment from 'moment';
+// eslint-disable-next-line node/no-extraneous-import
+import {Moment} from 'moment';
+
+export enum CATEGORY {
+  PETS = 'PETS',
+  KIDS = 'KIDS',
+}
+
+@Entity({
+  table,
+  name: 'photo',
+  primaryKey: {
+    partitionKey: 'PHOTO#{{category}}',
+    sortKey: 'PHOTO#{{id}}',
+  },
+})
+export class Photo {
+  @AutoGenerateAttribute({
+    strategy: AUTO_GENERATE_ATTRIBUTE_STRATEGY.UUID4,
+  })
+  id: number;
+
+  @Attribute()
+  @TransformToDynamo(({value}) => {
+    if (value === CATEGORY.KIDS) {
+      return 'kids-new';
+    } else {
+      return value;
+    }
+  })
+  category: CATEGORY;
+
+  @Attribute()
+  name: string;
+
+  @Attribute({
+    default: () => new Date().toString(),
+  })
+  @Type(() => Date)
+  @TransformFromDynamo(({value}) => moment(value))
+  createdAt: Moment;
+
+  constructor(category: CATEGORY, name: string) {
+    this.name = name;
+    this.category = category;
+  }
+
+  createdDate() {
+    return this.createdAt.format('MM-DD-YYYY');
+  }
+}
diff --git a/packages/core/package.json b/packages/core/package.json
index 57c00dc..f02bb5d 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -17,7 +17,8 @@
     "uuid": "^8.3.1",
     "ksuid": "^2.0.0",
     "fast-glob": "^3.2.4",
-    "p-limit": "^3.1.0"
+    "p-limit": "^3.1.0",
+    "class-transformer": "^0.4.0"
   },
   "publishConfig": {
     "tag": "TAG-PLACEHOLDER",
diff --git a/packages/core/src/classes/transformer/base-transformer.ts b/packages/core/src/classes/transformer/base-transformer.ts
index 46d4a44..1075df1 100644
--- a/packages/core/src/classes/transformer/base-transformer.ts
+++ b/packages/core/src/classes/transformer/base-transformer.ts
@@ -18,6 +18,7 @@ import {IsAutoGeneratedAttributeMetadata} from '../metadata/auto-generated-attri
 import {DynamoEntitySchemaPrimaryKey} from '../metadata/entity-metadata';
 import {isDynamoEntityKeySchema} from '../../helpers/is-dynamo-entity-key-schema';
 import {isKeyOfTypeAliasSchema} from '../../helpers/is-key-of-type-alias-schema';
+import {classToPlain} from 'class-transformer';
 
 export interface MetadataOptions {
   requestId?: string;
@@ -42,23 +43,41 @@ 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;
+
     // retrieve metadata and parse it to schema
     const entityMetadata = this.connection.getEntityByTarget(entityClass);
 
     //  auto populate generated values
     this.connection.getAttributesForEntity(entityClass).forEach(attr => {
       if (IsAutoGeneratedAttributeMetadata(attr)) {
-        entity = Object.assign({...entity, [attr.name]: attr.value});
+        parsedEntity = Object.assign({
+          ...parsedEntity,
+          [attr.name]: attr.value,
+        });
       }
     });
 
     const parsedPrimaryKey = this.recursiveParseEntity(
       entityMetadata.schema.primaryKey.attributes,
-      entity
+      parsedEntity
     );
 
     const indexesToParse = entityMetadata.schema.indexes ?? {};
-    const rawParsedIndexes = this.recursiveParseEntity(indexesToParse, entity);
+    const rawParsedIndexes = this.recursiveParseEntity(
+      indexesToParse,
+      parsedEntity
+    );
 
     const parsedIndexes = Object.keys(rawParsedIndexes).reduce(
       (acc, currIndexKey) => {
@@ -102,7 +121,7 @@ export abstract class BaseTransformer {
       ...parsedIndexes,
     };
 
-    return {...entity, ...formattedSchema};
+    return {...parsedEntity, ...formattedSchema};
   }
 
   /**
diff --git a/packages/core/src/classes/transformer/entity-transformer.ts b/packages/core/src/classes/transformer/entity-transformer.ts
index 8db1c28..848c0e3 100644
--- a/packages/core/src/classes/transformer/entity-transformer.ts
+++ b/packages/core/src/classes/transformer/entity-transformer.ts
@@ -1,5 +1,6 @@
 import {DynamoEntity, EntityTarget, TRANSFORM_TYPE} from '@typedorm/common';
 import {DocumentClient} from 'aws-sdk/clients/dynamodb';
+import {ClassConstructor, plainToClass} from 'class-transformer';
 import {unParseKey} from '../../helpers/unparse-key';
 import {Connection} from '../connection/connection';
 import {BaseTransformer, MetadataOptions} from './base-transformer';
@@ -49,19 +50,27 @@ export class EntityTransformer extends BaseTransformer {
       })
       .flat();
 
-    const transformedEntity = Object.keys(dynamoEntity).reduce((acc, key) => {
-      // if any of the below conditions are true, skip adding given attribute from returning response
-      if (
-        entityPrimaryKeys.includes(key) ||
-        entityIndexes.includes(key) ||
-        entityInternalAttributeKeys.includes(key) ||
-        entityHiddenAttributeKeys.includes(key)
-      ) {
+    const plainEntityAttributes = Object.keys(dynamoEntity).reduce(
+      (acc, key) => {
+        // if any of the below conditions are true, skip adding given attribute from returning response
+        if (
+          entityPrimaryKeys.includes(key) ||
+          entityIndexes.includes(key) ||
+          entityInternalAttributeKeys.includes(key) ||
+          entityHiddenAttributeKeys.includes(key)
+        ) {
+          return acc;
+        }
+        (acc as any)[key] = (dynamoEntity as any)[key];
         return acc;
-      }
-      (acc as any)[key] = (dynamoEntity as any)[key];
-      return acc;
-    }, {} as Entity);
+      },
+      {} as Object
+    );
+
+    const transformedEntity = plainToClass(
+      entityClass as ClassConstructor<Entity>,
+      plainEntityAttributes
+    );
 
     this.connection.logger.logTransform({
       requestId: metadataOptions?.requestId,


Test patch:
diff --git a/packages/core/src/classes/connection/__test__/connection-metadata-builder.spec.ts b/packages/core/src/classes/connection/__test__/connection-metadata-builder.spec.ts
index 74d13ab..9025958 100644
--- a/packages/core/src/classes/connection/__test__/connection-metadata-builder.spec.ts
+++ b/packages/core/src/classes/connection/__test__/connection-metadata-builder.spec.ts
@@ -41,7 +41,7 @@ test('builds entity metadata with path match', () => {
     path.resolve(__dirname, '../../../../__mocks__/**/*.ts')
   );
 
-  expect(entities.length).toEqual(7);
+  expect(entities.length).toEqual(8);
 });
 
 test('throws when trying to register duplicated entities', () => {
diff --git a/packages/core/src/classes/manager/__test__/entity-manager.spec.ts b/packages/core/src/classes/manager/__test__/entity-manager.spec.ts
index d8e9be8..3d49bf7 100644
--- a/packages/core/src/classes/manager/__test__/entity-manager.spec.ts
+++ b/packages/core/src/classes/manager/__test__/entity-manager.spec.ts
@@ -234,6 +234,7 @@ test('finds one entity by given primary key', async () => {
     name: 'Me',
     status: 'active',
   });
+  expect(userEntity).toBeInstanceOf(User);
 });
 
 // issue: 110
@@ -960,6 +961,10 @@ test('finds items matching given query params', async () => {
       },
     ],
   });
+
+  users.items.forEach(user => {
+    expect(user).toBeInstanceOf(User);
+  });
 });
 
 test('finds items matching given query params and options', async () => {
@@ -1038,6 +1043,7 @@ test('finds items matching given query params and options', async () => {
       },
     ],
   });
+  expect(users.items[0]).toBeInstanceOf(User);
 });
 
 test('finds items with alternate syntax', async () => {
diff --git a/packages/core/src/classes/manager/__test__/scan-manager.spec.ts b/packages/core/src/classes/manager/__test__/scan-manager.spec.ts
index a34af00..44d530d 100644
--- a/packages/core/src/classes/manager/__test__/scan-manager.spec.ts
+++ b/packages/core/src/classes/manager/__test__/scan-manager.spec.ts
@@ -70,6 +70,11 @@ test('scans table with given options', async () => {
       },
     ],
   });
+  expect(
+    response.items?.forEach(item => {
+      expect(item).toBeInstanceOf(User);
+    })
+  );
 });
 
 test('scans table with multiple requests', async () => {
@@ -170,6 +175,13 @@ test('scans table with given options and returns deserialized and unknown items'
       },
     ],
   });
+
+  response.items?.forEach(item => {
+    expect(item).toBeInstanceOf(User);
+  });
+  response.unknownItems?.forEach(item => {
+    expect(item).not.toBeInstanceOf(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 c621545..294317f 100644
--- a/packages/core/src/classes/transformer/__test__/entity-transformer.spec.ts
+++ b/packages/core/src/classes/transformer/__test__/entity-transformer.spec.ts
@@ -14,6 +14,14 @@ import {EntityTransformer} from '../entity-transformer';
 import {UserSparseIndexes} from '../../../../__mocks__/user-sparse-indexes';
 import {table} from '@typedorm/core/__mocks__/table';
 import {UserAttrAlias} from '@typedorm/core/__mocks__/user-with-attribute-alias';
+import {CATEGORY, Photo} from '@typedorm/core/__mocks__/photo';
+// Moment is only being used here to display the usage of @transform utility
+// eslint-disable-next-line node/no-extraneous-import
+import moment from 'moment';
+
+jest.mock('uuid', () => ({
+  v4: () => 'c0ac5395-ba7c-41bf-bbc3-09a6087bcca2',
+}));
 
 let transformer: EntityTransformer;
 beforeEach(() => {
@@ -24,6 +32,7 @@ beforeEach(() => {
       UserAutoGenerateAttributes,
       UserSparseIndexes,
       UserAttrAlias,
+      Photo,
     ],
   });
   transformer = new EntityTransformer(connection);
@@ -161,6 +170,26 @@ test('excludes hidden props from returned response', () => {
   });
 });
 
+test('transforms dynamo entity to entity model instance ', () => {
+  const dynamoEntity = {
+    PK: 'PHOTO#PETS',
+    SK: 'PHOTO#1',
+    id: 1,
+    category: 'PETS',
+    name: 'my cute pet billy',
+    createdAt: '2020-03-21T03:26:34.781Z',
+  };
+  const transformed = transformer.fromDynamoEntity(Photo, dynamoEntity);
+  expect(transformed).toMatchObject({
+    category: 'PETS',
+    createdAt: expect.any(moment),
+    id: 1,
+    name: 'my cute pet billy',
+  });
+  expect(transformed).toBeInstanceOf(Photo);
+  expect(transformed.createdDate()).toEqual('03-21-2020');
+});
+
 /**
  * @group toDynamoEntity
  */
@@ -182,6 +211,20 @@ test('transforms simple model to dynamo entity', () => {
   });
 });
 
+test('transforms dynamo entity to entity model instance ', () => {
+  const photo = new Photo(CATEGORY.KIDS, 'my baby');
+
+  const response = transformer.toDynamoEntity(photo);
+
+  expect(response).toEqual({
+    PK: 'PHOTO#kids-new',
+    SK: 'PHOTO#c0ac5395-ba7c-41bf-bbc3-09a6087bcca2',
+    category: 'kids-new',
+    id: 'c0ac5395-ba7c-41bf-bbc3-09a6087bcca2',
+    name: 'my baby',
+  });
+});
+
 test('transforms entity with attribute alias to dynamo entity', () => {
   const user = new UserAttrAlias();
   user.id = '111';