Repository: typedorm/typedorm (ts)

Issue:
Add support for class-transformer versioning
We recently began introducing some changes to our data model/schema, and it would make life a whole lot simpler if we could leverage `class-transformer`'s built-in support for [versioning attributes](https://github.com/typestack/class-transformer#using-versioning-to-control-exposed-and-excluded-properties) (using `since` and `until`) when using TypeDorm.  The idea would be to designate an attribute as the schema version identifier, and then pass along that version number to `class-transformer` when doing serialization/deserialization.  That would enable us to gracefully deprecate older schema versions and seamlessly "upgrade" to newer schema versions over time.  An example of what this might look like: 


```ts
@Entity({
  table: testTable,
  name: 'item',
  primaryKey: {
    partitionKey: 'ITEM#{{id}}',
  },
  schemaVersionAttribute: 'schemaVersion'
})
export class TestEntity {
  @Attribute()
  id: string;

  @Expose({ since: 2, until: 3 })
  @Attribute()
  attributeInVersion2And3: string;

  @Expose({ since: 3 })
  @Attribute()
  attributeSinceVersion3: string;

  @Attribute()
  schemaVersion: number;
}
``` 

Pull request description:
feat: add schemaVersionAttribute option to enable schema versioning
Add a new option to EntityRawMetadataOptions, `schemaVersionAttribute`, to enable leveraging class-transformer's built-in support for [versioning attributes](https://github.com/typestack/class-transformer#using-versioning-to-control-exposed-and-excluded-properties).  If provided, the respective transformers will look at the provided property path for a version number and pass it along to class-transformer as the version.

Closes #325 

Patch:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index bcb4df8..0b2ee75 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -1,5 +1,11 @@
 name: Continuous Integration
 on:
+  pull_request:
+    branches:
+      - 'main'
+      - 'develop'
+      - 'next'
+      - 'alpha'
   push:
     # run on all branches except release branches
     branches-ignore:
diff --git a/packages/common/src/decorators/entity.decorator.ts b/packages/common/src/decorators/entity.decorator.ts
index d3a5802..fa39894 100644
--- a/packages/common/src/decorators/entity.decorator.ts
+++ b/packages/common/src/decorators/entity.decorator.ts
@@ -8,9 +8,10 @@ export function Entity<E>({
   primaryKey,
   indexes,
   name,
+  schemaVersionAttribute,
 }: Pick<
   EntityRawMetadataOptions<E>,
-  'name' | 'table' | 'indexes' | 'primaryKey'
+  'name' | 'table' | 'indexes' | 'primaryKey' | 'schemaVersionAttribute'
 >) {
   return function <E extends Constructor>(target: E) {
     const originalTarget = target;
@@ -21,6 +22,7 @@ export function Entity<E>({
       target,
       primaryKey,
       indexes,
+      schemaVersionAttribute,
     });
 
     return originalTarget;
diff --git a/packages/common/src/metadata/metadata-storage.ts b/packages/common/src/metadata/metadata-storage.ts
index 32e4454..3604fdd 100644
--- a/packages/common/src/metadata/metadata-storage.ts
+++ b/packages/common/src/metadata/metadata-storage.ts
@@ -31,6 +31,12 @@ export interface EntityRawMetadataOptions<Entity = any> {
   primaryKey: PrimaryKey<Entity>;
   indexes?: Indexes<Entity>;
   table?: Table;
+  /**
+   * Optional name of attribute on the entity that is used to denote schema version.
+   * If provided, the value of this attribute will  be passed to class-transformer to
+   * be used as the version during serialization/deserialization
+   */
+  schemaVersionAttribute?: Extract<keyof Entity, string>;
 }
 
 interface BaseAttributeRawMetadataOptions {
diff --git a/packages/core/src/classes/connection/entity-metadata-builder.ts b/packages/core/src/classes/connection/entity-metadata-builder.ts
index fe6d016..2e57d41 100644
--- a/packages/core/src/classes/connection/entity-metadata-builder.ts
+++ b/packages/core/src/classes/connection/entity-metadata-builder.ts
@@ -18,10 +18,16 @@ export class EntityMetadataBuilder {
   }
   build(entityClasses: Function[]): EntityMetadata[] {
     return entityClasses.map(decoratedEntityClass => {
-      const {target, table, name, primaryKey, indexes} =
-        MetadataManager.metadataStorage.getRawEntityByTarget(
-          decoratedEntityClass
-        );
+      const {
+        target,
+        table,
+        name,
+        primaryKey,
+        indexes,
+        schemaVersionAttribute,
+      } = MetadataManager.metadataStorage.getRawEntityByTarget(
+        decoratedEntityClass
+      );
 
       if (table) {
         // if no entity level table is defined fallback to global connection table
@@ -73,6 +79,7 @@ export class EntityMetadataBuilder {
         name,
         primaryKey,
         indexes,
+        schemaVersionAttribute,
       });
     });
   }
diff --git a/packages/core/src/classes/metadata/entity-metadata.ts b/packages/core/src/classes/metadata/entity-metadata.ts
index db6517c..06ad62a 100644
--- a/packages/core/src/classes/metadata/entity-metadata.ts
+++ b/packages/core/src/classes/metadata/entity-metadata.ts
@@ -40,6 +40,7 @@ export type DynamoEntityIndexSchema = {
 export interface DynamoEntitySchema {
   primaryKey: DynamoEntitySchemaPrimaryKey;
   indexes?: DynamoEntityIndexesSchema;
+  schemaVersionAttribute?: string;
 }
 
 export type AttributeMetadataType =
@@ -67,6 +68,7 @@ export class EntityMetadata extends BaseMetadata {
     primaryKey,
     indexes,
     attributes,
+    schemaVersionAttribute,
   }: EntityMetadataOptions) {
     super(connection);
     this.name = name;
@@ -101,6 +103,7 @@ export class EntityMetadata extends BaseMetadata {
         indexes: {...indexes},
         attributes: attributesKeyTypePair,
       }),
+      schemaVersionAttribute,
     };
   }
 
diff --git a/packages/core/src/classes/transformer/base-transformer.ts b/packages/core/src/classes/transformer/base-transformer.ts
index b4cf4c9..9e28b97 100644
--- a/packages/core/src/classes/transformer/base-transformer.ts
+++ b/packages/core/src/classes/transformer/base-transformer.ts
@@ -48,10 +48,17 @@ export abstract class BaseTransformer {
     return entityMetadata.table.name;
   }
 
-  applyClassTransformerFormations<Entity>(entity: Entity) {
+  applyClassTransformerFormations<Entity>(
+    entity: Entity,
+    schemaVersionAttribute?: string
+  ) {
+    const version = schemaVersionAttribute
+      ? (entity as any)[schemaVersionAttribute]
+      : undefined;
     const transformedPlainEntity = classToPlain<Entity>(entity, {
       enableImplicitConversion: true,
       excludePrefixes: ['__'], // exclude internal attributes
+      version,
     });
 
     return transformedPlainEntity as Entity;
@@ -93,7 +100,10 @@ export abstract class BaseTransformer {
     });
 
     // pass through entity to class transformer to have all the metadata applied
-    const parsedEntity = this.applyClassTransformerFormations(entity);
+    const parsedEntity = this.applyClassTransformerFormations(
+      entity,
+      entityMetadata.schema.schemaVersionAttribute
+    );
 
     const parsedPrimaryKey = this.recursiveParseEntity(
       entityMetadata.schema.primaryKey.attributes,
diff --git a/packages/core/src/classes/transformer/entity-transformer.ts b/packages/core/src/classes/transformer/entity-transformer.ts
index 15a8497..d1b4dec 100644
--- a/packages/core/src/classes/transformer/entity-transformer.ts
+++ b/packages/core/src/classes/transformer/entity-transformer.ts
@@ -87,9 +87,15 @@ export class EntityTransformer extends BaseTransformer {
       JSON.stringify(plainEntityAttributes)
     );
 
-    const transformedEntity = plainToClassFromExist(
+    const schemaVersion = entityMetadata.schema.schemaVersionAttribute
+      ? deserializedEntityAttributes[
+          entityMetadata.schema.schemaVersionAttribute
+        ]
+      : undefined;
+    const transformedEntity: any = plainToClassFromExist(
       reflectedConstructor,
-      deserializedEntityAttributes
+      deserializedEntityAttributes,
+      {version: schemaVersion}
     );
 
     this.connection.logger.logTransform({


Test patch:
diff --git a/__test__/github-issues/325/issue-325.spec.ts b/__test__/github-issues/325/issue-325.spec.ts
new file mode 100644
index 0000000..b30c900
--- /dev/null
+++ b/__test__/github-issues/325/issue-325.spec.ts
@@ -0,0 +1,81 @@
+import 'reflect-metadata';
+
+import {createTestConnection, resetTestConnection} from '@typedorm/testing';
+import {EntityManager} from '@typedorm/core';
+import {TestEntity} from './test-entity';
+
+let entityManager: EntityManager;
+
+const dcMock = {
+  get: jest.fn(),
+  put: jest.fn(),
+};
+beforeEach(() => {
+  const connection = createTestConnection({
+    entities: [TestEntity],
+    documentClient: dcMock,
+  });
+  entityManager = new EntityManager(connection);
+});
+
+afterEach(() => {
+  resetTestConnection();
+});
+
+describe('when schema version attribute is provided', () => {
+  it('applies versioning to attributes on deserialization', async () => {
+    dcMock.get.mockReturnValue({
+      promise: () => ({
+        Item: {
+          PK: 'ITEM#1',
+          id: '1',
+          unversionedAttribute: 'should always be here',
+          attributeInVersion2And3: 'should be omitted',
+          schemaVersion: 1,
+        },
+      }),
+    });
+
+    const item = await entityManager.findOne(TestEntity, {
+      id: '1',
+    });
+
+    expect(item).toEqual(
+      Object.assign(new TestEntity(), {
+        id: '1',
+        unversionedAttribute: 'should always be here',
+        schemaVersion: 1,
+      })
+    );
+  });
+
+  it('applies versioning to attributes on serialization', async () => {
+    dcMock.put.mockReturnValue({promise: () => ({})});
+    const item = Object.assign(new TestEntity(), {
+      id: '1',
+      unversionedAttribute: 'should always be here',
+      attributeInVersion2And3: 'should be omitted',
+      attributeSinceVersion3: 'should be present',
+      schemaVersion: 4,
+    });
+
+    await entityManager.create(item);
+
+    expect(dcMock.put).toHaveBeenCalledWith({
+      ConditionExpression: 'attribute_not_exists(#CE_PK)',
+      ExpressionAttributeNames: {
+        '#CE_PK': 'PK',
+      },
+      Item: {
+        PK: 'ITEM#1',
+        __en: 'item',
+        id: '1',
+        unversionedAttribute: 'should always be here',
+        attributeSinceVersion3: 'should be present',
+        schemaVersion: 4,
+      },
+      ReturnConsumedCapacity: undefined,
+      TableName: 'test-table',
+    });
+  });
+});
diff --git a/__test__/github-issues/325/test-entity.ts b/__test__/github-issues/325/test-entity.ts
new file mode 100644
index 0000000..c0f0371
--- /dev/null
+++ b/__test__/github-issues/325/test-entity.ts
@@ -0,0 +1,30 @@
+import {Attribute, Entity} from '@typedorm/common';
+import {testTable} from './test-table';
+import {Expose} from 'class-transformer';
+
+@Entity({
+  table: testTable,
+  name: 'item',
+  primaryKey: {
+    partitionKey: 'ITEM#{{id}}',
+  },
+  schemaVersionAttribute: 'schemaVersion',
+})
+export class TestEntity {
+  @Attribute()
+  id: string;
+
+  @Attribute()
+  unversionedAttribute: string;
+
+  @Expose({since: 2, until: 3})
+  @Attribute()
+  attributeInVersion2And3: string;
+
+  @Expose({since: 3})
+  @Attribute()
+  attributeSinceVersion3: string;
+
+  @Attribute()
+  schemaVersion: number;
+}
diff --git a/__test__/github-issues/325/test-table.ts b/__test__/github-issues/325/test-table.ts
new file mode 100644
index 0000000..59b0995
--- /dev/null
+++ b/__test__/github-issues/325/test-table.ts
@@ -0,0 +1,6 @@
+import {Table} from '@typedorm/common';
+
+export const testTable = new Table({
+  name: 'test-table',
+  partitionKey: 'PK',
+});