From 46df060c0eac09fb459441c414aa7e47dcedd6f5 Mon Sep 17 00:00:00 2001 From: Christopher Allford Date: Thu, 5 Nov 2020 12:37:40 -0800 Subject: [PATCH] Revised the ModelTransformer so that it can support more complicated transformations. --- tests/e2e/api/package.json | 3 +- .../e2e/api/src/__test_data__/dummy-model.ts | 13 ++ .../__tests__/model-repository.spec.ts | 9 +- .../__tests__/model-transformer.spec.ts | 144 +++++++++--------- .../api/src/framework/model-transformer.ts | 118 +++++++------- .../key-change-transformation.spec.ts | 28 ++++ .../key-change-transformation.ts | 74 +++++++++ .../rest/products/simple-product.ts | 26 ++-- .../rest/settings/setting-group.ts | 13 +- .../src/repositories/rest/settings/setting.ts | 3 +- 10 files changed, 264 insertions(+), 167 deletions(-) create mode 100644 tests/e2e/api/src/__test_data__/dummy-model.ts create mode 100644 tests/e2e/api/src/framework/transformations/__tests__/key-change-transformation.spec.ts create mode 100644 tests/e2e/api/src/framework/transformations/key-change-transformation.ts diff --git a/tests/e2e/api/package.json b/tests/e2e/api/package.json index d54057a6f8d..2bb61f3fc14 100644 --- a/tests/e2e/api/package.json +++ b/tests/e2e/api/package.json @@ -21,7 +21,8 @@ "!*.tsbuildinfo", "!/dist/**/__tests__/", "!/dist/**/__mocks__/", - "!/dist/**/__snapshops__/" + "!/dist/**/__snapshops__/", + "!/dist/**/__test_data__/" ], "sideEffects": false, "scripts": { diff --git a/tests/e2e/api/src/__test_data__/dummy-model.ts b/tests/e2e/api/src/__test_data__/dummy-model.ts new file mode 100644 index 00000000000..d19da412a98 --- /dev/null +++ b/tests/e2e/api/src/__test_data__/dummy-model.ts @@ -0,0 +1,13 @@ +import { Model } from '../models/model'; + +/** + * A dummy model that can be used in test files. + */ +export class DummyModel extends Model { + public name: string = ''; + + public constructor( partial?: Partial< DummyModel > ) { + super(); + Object.assign( this, partial ); + } +} diff --git a/tests/e2e/api/src/framework/__tests__/model-repository.spec.ts b/tests/e2e/api/src/framework/__tests__/model-repository.spec.ts index 650cd17ad43..67aadf1b4ea 100644 --- a/tests/e2e/api/src/framework/__tests__/model-repository.spec.ts +++ b/tests/e2e/api/src/framework/__tests__/model-repository.spec.ts @@ -12,15 +12,8 @@ import { UpdatesChildModels, UpdatesModels, } from '../model-repository'; +import { DummyModel } from '../../__test_data__/dummy-model'; -class DummyModel extends Model { - public name: string = ''; - - public constructor( partial?: Partial< DummyModel > ) { - super(); - Object.assign( this, partial ); - } -} type DummyModelParams = ModelRepositoryParams< DummyModel, never, { search: string }, 'name' > class DummyChildModel extends Model { diff --git a/tests/e2e/api/src/framework/__tests__/model-transformer.spec.ts b/tests/e2e/api/src/framework/__tests__/model-transformer.spec.ts index fc6ef690448..67c58f00268 100644 --- a/tests/e2e/api/src/framework/__tests__/model-transformer.spec.ts +++ b/tests/e2e/api/src/framework/__tests__/model-transformer.spec.ts @@ -1,90 +1,90 @@ -import { Model } from '../../models/model'; -import { ModelTransformer } from '../model-transformer'; +import { ModelTransformation, ModelTransformer } from '../model-transformer'; +import { DummyModel } from '../../__test_data__/dummy-model'; -class DummyModel extends Model { - public name: string = ''; +class DummyTransformation implements ModelTransformation { + public readonly priority: number; - public constructor( partial?: Partial< DummyModel > ) { - super(); - Object.assign( this, partial ); + private readonly fn: ( ( p: any ) => any ) | null; + + public constructor( priority: number, fn: ( ( p: any ) => any ) | null ) { + this.priority = priority; + this.fn = fn; + } + + public fromModel( properties: any ): any { + if ( ! this.fn ) { + return properties; + } + + return this.fn( properties ); + } + + public toModel( properties: any ): any { + if ( ! this.fn ) { + return properties; + } + + return this.fn( properties ); } } describe( 'ModelTransformer', () => { - describe( 'fromModel', () => { - it( 'should convert models plainly', () => { - const model = new DummyModel( { name: 'Test' } ); + it( 'should prioritize transformers correctly', () => { + const fn1 = jest.fn(); + fn1.mockReturnValue( { name: 'fn1' } ); + const fn2 = jest.fn(); + fn2.mockReturnValue( { name: 'fn2' } ); - const transformed = ModelTransformer.fromModel( model ); + const transformer = new ModelTransformer< DummyModel >( + [ + // Ensure the priorities are backwards so sorting is tested. + new DummyTransformation( 1, fn2 ), + new DummyTransformation( 0, fn1 ), + ], + ); - expect( transformed ).toMatchObject( - { - name: 'Test', - }, - ); - } ); + const transformed = transformer.toModel( DummyModel, { name: 'fn0' } ); - it( 'should convert models with key changes', () => { - const model = new DummyModel( { name: 'Test' } ); - - const transformed = ModelTransformer.fromModel( - model, - { name: 'new-test' }, - ); - - expect( transformed ).toMatchObject( - { - 'new-test': 'Test', - }, - ); - } ); - - it( 'should convert models with transformations', () => { - const model = new DummyModel( { name: 'Test' } ); - - const transformed = ModelTransformer.fromModel( - model, - undefined, - { name: ( val ) => 'Transform-' + val }, - ); - - expect( transformed ).toMatchObject( - { - name: 'Transform-Test', - }, - ); - } ); + expect( fn1 ).toHaveBeenCalledWith( { name: 'fn0' } ); + expect( fn2 ).toHaveBeenCalledWith( { name: 'fn1' } ); + expect( transformed ).toMatchObject( { name: 'fn2' } ); } ); - describe( 'toModel', () => { - it( 'should create models plainly', () => { - const transformed = ModelTransformer.toModel( - DummyModel, - { name: 'Test' }, - ); + it( 'should transform to model', () => { + const transformer = new ModelTransformer< DummyModel >( + [ + new DummyTransformation( + 0, + ( p: any ) => { + p.name = 'Transformed-' + p.name; + return p; + }, + ), + ], + ); - expect( transformed ).toMatchObject( new DummyModel( { name: 'Test' } ) ); - } ); + const model = transformer.toModel( DummyModel, { name: 'Test' } ); - it( 'should create models with key changes', () => { - const transformed = ModelTransformer.toModel( - DummyModel, - { test: 'Test' }, - { test: 'name' }, - ); + expect( model ).toBeInstanceOf( DummyModel ); + expect( model.name ).toEqual( 'Transformed-Test' ); + } ); - expect( transformed ).toMatchObject( new DummyModel( { name: 'Test' } ) ); - } ); + it( 'should transform from model', () => { + const transformer = new ModelTransformer< DummyModel >( + [ + new DummyTransformation( + 0, + ( p: any ) => { + p.name = 'Transformed-' + p.name; + return p; + }, + ), + ], + ); - it( 'should create models with transformations', () => { - const transformed = ModelTransformer.toModel( - DummyModel, - { name: 'Test' }, - undefined, - { name: ( val ) => 'Transform-' + val }, - ); + const transformed = transformer.fromModel( new DummyModel( { name: 'Test' } ) ); - expect( transformed ).toMatchObject( new DummyModel( { name: 'Transform-Test' } ) ); - } ); + expect( transformed ).not.toBeInstanceOf( DummyModel ); + expect( transformed.name ).toEqual( 'Transformed-Test' ); } ); } ); diff --git a/tests/e2e/api/src/framework/model-transformer.ts b/tests/e2e/api/src/framework/model-transformer.ts index b5dca16da82..d73d744c9db 100644 --- a/tests/e2e/api/src/framework/model-transformer.ts +++ b/tests/e2e/api/src/framework/model-transformer.ts @@ -1,92 +1,76 @@ import { Model } from '../models/model'; /** - * A map for converting between API/Model keys. + * An interface for an object that can perform transformations both to and from a representation + * and return the input data after performing the desired transformation. * - * @typedef KeyChangeMap - * @alias Object. + * @interface ModelTransformation */ -type KeyChangeMap< F, T > = { - [ key in keyof F ]?: keyof T; +export interface ModelTransformation { + readonly priority: number; + toModel( properties: any ): any; + fromModel( properties: any ): any; } /** - * A map for transformations between API/Model data. - * - * @typedef TransformationMap - * @alias Object. + * A class for transforming models to/from a generic representation. */ -type TransformationMap< T > = { - [ key in keyof T ]?: ( val: any ) => any; -} - -export namespace ModelTransformer { +export class ModelTransformer< T extends Model > { /** - * Given an object, this function will transform it using the described key changes and transformations. + * An array of transformations to use when converting data to/from models. * - * @param {*} data - * @param {KeyChangeMap} keyChanges A map of keys to change into another key. - * @param {TransformationMap} transformations A map of transformations to perform on values keyed by the key. - * @return {*} The transformed data. + * @type {Array.} * @private */ - function transform< F, T >( - data: F, - keyChanges?: KeyChangeMap< F, T >, - transformations?: TransformationMap< F >, - ): any { - const transformed: any = {}; - - for ( const inputKey in data ) { - let key: string = inputKey; - if ( keyChanges && keyChanges.hasOwnProperty( inputKey ) ) { - key = keyChanges[ inputKey ] as string; - } - - let value = data[ inputKey ]; - if ( transformations && transformations.hasOwnProperty( inputKey ) ) { - value = ( transformations[ inputKey ] as ( val: any ) => any )( value ); - } - - transformed[ key ] = value; - } - - return transformed; - } + private transformations: readonly ModelTransformation[]; /** - * Given a model this will transform it into a standard object. + * Creates a new model transformer instance. * - * @param {F} data The model data to transform. - * @param {KeyChangeMap} keyChanges A map of keys to change into another key. - * @param {TransformationMap} transformations A map of transformations to perform on values keyed by the key. - * @return {*} The transformed data. - * @template F + * @param {Array.} transformations The transformations to use. */ - export function fromModel< F extends Partial< Model > >( - data: F, - keyChanges?: KeyChangeMap< F, any >, - transformations?: TransformationMap< F >, - ): any { - return transform( data, keyChanges, transformations ); + public constructor( transformations: ModelTransformation[] ) { + // Ensure that the transformations are sorted by priority. + transformations.sort( ( a, b ) => ( a.priority > b.priority ) ? 1 : -1 ); + + this.transformations = transformations; } /** - * Given an object this will transform it into a model object. + * Takes the input data and runs all of the transformations on it before returning the created model. * - * @param {Function} modelClass The model class we want to instantiate. - * @param {*} data The raw data to transform. - * @param {KeyChangeMap} keyChanges A map of keys to change into another key. - * @param {TransformationMap} transformations A map of transformations to perform on values keyed by the key. - * @return {T} The created model. + * @param {Function.} modelClass The model class we're trying to create. + * @param {*} data The data we're transforming. + * @return {T} The transformed model. * @template T */ - export function toModel< T extends Model, F >( - modelClass: new ( properties: Partial< T > ) => T, - data: F, - keyChanges?: KeyChangeMap< F, T >, - transformations?: TransformationMap< F >, - ): T { - return new modelClass( transform( data, keyChanges, transformations ) ); + public toModel( modelClass: new( properties: Partial< T > ) => T, data: any ): T { + const transformed: any = this.transformations.reduce( + ( properties: any, transformer: ModelTransformation ) => { + return transformer.toModel( properties ); + }, + data, + ); + + return new modelClass( transformed ); + } + + /** + * Takes the input model and runs all of the transformations on it before returning the data. + * + * @param {Partial.} model The model to transform. + * @return {*} The transformed data. + * @template T + */ + public fromModel( model: Partial< T > ): any { + // Convert the model class to raw properties so that the transformations can be simple. + const raw = JSON.parse( JSON.stringify( model ) ); + + return this.transformations.reduce( + ( properties: any, transformer: ModelTransformation ) => { + return transformer.fromModel( properties ); + }, + raw, + ); } } diff --git a/tests/e2e/api/src/framework/transformations/__tests__/key-change-transformation.spec.ts b/tests/e2e/api/src/framework/transformations/__tests__/key-change-transformation.spec.ts new file mode 100644 index 00000000000..a7596af7de4 --- /dev/null +++ b/tests/e2e/api/src/framework/transformations/__tests__/key-change-transformation.spec.ts @@ -0,0 +1,28 @@ +import { KeyChangeTransformation } from '../key-change-transformation'; +import { DummyModel } from '../../../__test_data__/dummy-model'; + +describe( 'KeyChangeTransformation', () => { + let transformation: KeyChangeTransformation< DummyModel >; + + beforeEach( () => { + transformation = new KeyChangeTransformation( + { + name: 'new-name', + }, + ); + } ); + + it( 'should transform to model', () => { + const transformed = transformation.toModel( { 'new-name': 'Test Name' } ); + + expect( transformed ).toHaveProperty( 'name', 'Test Name' ); + expect( transformed ).not.toHaveProperty( 'new-name' ); + } ); + + it( 'should transform from model', () => { + const transformed = transformation.fromModel( { name: 'Test Name' } ); + + expect( transformed ).toHaveProperty( 'new-name', 'Test Name' ); + expect( transformed ).not.toHaveProperty( 'name' ); + } ); +} ); diff --git a/tests/e2e/api/src/framework/transformations/key-change-transformation.ts b/tests/e2e/api/src/framework/transformations/key-change-transformation.ts new file mode 100644 index 00000000000..4b1ea05f1b3 --- /dev/null +++ b/tests/e2e/api/src/framework/transformations/key-change-transformation.ts @@ -0,0 +1,74 @@ +import { ModelTransformation } from '../model-transformer'; +import { Model } from '../../models/model'; + +/** + * @typedef KeyChanges + * @alias Object. + */ +type KeyChanges< T extends Model > = { readonly [ key in keyof Partial< T > ]: string }; + +/** + * A model transformation that can be used to change property keys between two formats. + * This transformation has a very high priority so that it will be executed after all + * other transformations to prevent the changed key from causing problems. + */ +export class KeyChangeTransformation< T extends Model > implements ModelTransformation { + /** + * Ensure that this transformation always happens at the very end since it changes the keys + * in the transformed object. + */ + public readonly priority = 999999; + + /** + * The key change transformations that this object should perform. + * This is structured with the model's property key as the key + * of the object and the raw property key as the value. + * + * @type {KeyChanges} + * @private + */ + private readonly changes: KeyChanges< T >; + + /** + * Creates a new transformation. + * + * @param {KeyChanges} changes The changes we want the transformation to make. + */ + public constructor( changes: KeyChanges< T > ) { + this.changes = changes; + } + + /** + * Performs a transformation from model properties to raw properties. + * + * @param {*} properties The properties to transform. + * @return {*} The transformed properties. + */ + public fromModel( properties: any ): any { + for ( const key in this.changes ) { + const value = this.changes[ key ]; + + properties[ value ] = properties[ key ]; + delete properties[ key ]; + } + + return properties; + } + + /** + * Performs a transformation from raw properties to model properties. + * + * @param {*} properties The properties to transform. + * @return {*} The transformed properties. + */ + public toModel( properties: any ): any { + for ( const key in this.changes ) { + const value = this.changes[ key ]; + + properties[ key ] = properties[ value ]; + delete properties[ value ]; + } + + return properties; + } +} diff --git a/tests/e2e/api/src/repositories/rest/products/simple-product.ts b/tests/e2e/api/src/repositories/rest/products/simple-product.ts index 4f13542bc71..f5486e8ea7e 100644 --- a/tests/e2e/api/src/repositories/rest/products/simple-product.ts +++ b/tests/e2e/api/src/repositories/rest/products/simple-product.ts @@ -3,30 +3,32 @@ import { CreateFn, ModelRepository } from '../../../framework/model-repository'; import { SimpleProduct } from '../../../models'; import { CreatesSimpleProducts, SimpleProductRepositoryParams } from '../../../models/products/simple-product'; import { ModelTransformer } from '../../../framework/model-transformer'; +import { KeyChangeTransformation } from '../../../framework/transformations/key-change-transformation'; function fromServer( data: any ): SimpleProduct { if ( ! data.id ) { throw new Error( 'An invalid response was received.' ); } - return ModelTransformer.toModel( - SimpleProduct, - data, - { - regular_price: 'regularPrice', - }, + const t = new ModelTransformer< SimpleProduct >( + [ + new KeyChangeTransformation< SimpleProduct >( { regularPrice: 'regular_price' } ), + ], ); + + return t.toModel( SimpleProduct, data ); } function toServer( model: Partial< SimpleProduct > ): any { + const t = new ModelTransformer< SimpleProduct >( + [ + new KeyChangeTransformation< SimpleProduct >( { regularPrice: 'regular_price' } ), + ], + ); + return Object.assign( { type: 'simple' }, - ModelTransformer.fromModel( - model, - { - regularPrice: 'regular_price', - }, - ), + t.fromModel( model ), ); } diff --git a/tests/e2e/api/src/repositories/rest/settings/setting-group.ts b/tests/e2e/api/src/repositories/rest/settings/setting-group.ts index 1d70e33c60a..0f991b74ddd 100644 --- a/tests/e2e/api/src/repositories/rest/settings/setting-group.ts +++ b/tests/e2e/api/src/repositories/rest/settings/setting-group.ts @@ -3,19 +3,20 @@ import { ListFn, ModelRepository } from '../../../framework/model-repository'; import { SettingGroup } from '../../../models'; import { ListsSettingGroups, SettingGroupRepositoryParams } from '../../../models/settings/setting-group'; import { ModelTransformer } from '../../../framework/model-transformer'; +import { KeyChangeTransformation } from '../../../framework/transformations/key-change-transformation'; function fromServer( data: any ): SettingGroup { if ( ! data.id ) { throw new Error( 'An invalid response was received.' ); } - return ModelTransformer.toModel( - SettingGroup, - data, - { - parent_id: 'parentID', - }, + const t = new ModelTransformer< SettingGroup >( + [ + new KeyChangeTransformation< SettingGroup >( { parentID: 'parent_id' } ), + ], ); + + return t.toModel( SettingGroup, data ); } function restList( httpClient: HTTPClient ): ListFn< SettingGroupRepositoryParams > { diff --git a/tests/e2e/api/src/repositories/rest/settings/setting.ts b/tests/e2e/api/src/repositories/rest/settings/setting.ts index 13a2dd3949e..1e5171b45e2 100644 --- a/tests/e2e/api/src/repositories/rest/settings/setting.ts +++ b/tests/e2e/api/src/repositories/rest/settings/setting.ts @@ -19,7 +19,8 @@ function fromServer( data: any ): Setting { throw new Error( 'An invalid response was received.' ); } - return ModelTransformer.toModel( Setting, data ); + const t = new ModelTransformer< Setting >( [] ); + return t.toModel( Setting, data ); } function restList( httpClient: HTTPClient ): ListChildFn< SettingRepositoryParams > {