Revised the ModelTransformer so that it can support more complicated transformations.

This commit is contained in:
Christopher Allford 2020-11-05 12:37:40 -08:00
parent 67f57abe26
commit 46df060c0e
10 changed files with 264 additions and 167 deletions

View File

@ -21,7 +21,8 @@
"!*.tsbuildinfo", "!*.tsbuildinfo",
"!/dist/**/__tests__/", "!/dist/**/__tests__/",
"!/dist/**/__mocks__/", "!/dist/**/__mocks__/",
"!/dist/**/__snapshops__/" "!/dist/**/__snapshops__/",
"!/dist/**/__test_data__/"
], ],
"sideEffects": false, "sideEffects": false,
"scripts": { "scripts": {

View File

@ -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 );
}
}

View File

@ -12,15 +12,8 @@ import {
UpdatesChildModels, UpdatesChildModels,
UpdatesModels, UpdatesModels,
} from '../model-repository'; } 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' > type DummyModelParams = ModelRepositoryParams< DummyModel, never, { search: string }, 'name' >
class DummyChildModel extends Model { class DummyChildModel extends Model {

View File

@ -1,90 +1,90 @@
import { Model } from '../../models/model'; import { ModelTransformation, ModelTransformer } from '../model-transformer';
import { ModelTransformer } from '../model-transformer'; import { DummyModel } from '../../__test_data__/dummy-model';
class DummyModel extends Model { class DummyTransformation implements ModelTransformation {
public name: string = ''; public readonly priority: number;
public constructor( partial?: Partial< DummyModel > ) { private readonly fn: ( ( p: any ) => any ) | null;
super();
Object.assign( this, partial ); 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( 'ModelTransformer', () => {
describe( 'fromModel', () => { it( 'should prioritize transformers correctly', () => {
it( 'should convert models plainly', () => { const fn1 = jest.fn();
const model = new DummyModel( { name: 'Test' } ); 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( const transformed = transformer.toModel( DummyModel, { name: 'fn0' } );
{
name: 'Test',
},
);
} );
it( 'should convert models with key changes', () => { expect( fn1 ).toHaveBeenCalledWith( { name: 'fn0' } );
const model = new DummyModel( { name: 'Test' } ); expect( fn2 ).toHaveBeenCalledWith( { name: 'fn1' } );
expect( transformed ).toMatchObject( { name: 'fn2' } );
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',
},
);
} );
} ); } );
describe( 'toModel', () => { it( 'should transform to model', () => {
it( 'should create models plainly', () => { const transformer = new ModelTransformer< DummyModel >(
const transformed = ModelTransformer.toModel( [
DummyModel, new DummyTransformation(
{ name: 'Test' }, 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', () => { expect( model ).toBeInstanceOf( DummyModel );
const transformed = ModelTransformer.toModel( expect( model.name ).toEqual( 'Transformed-Test' );
DummyModel, } );
{ test: 'Test' },
{ test: 'name' },
);
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 = transformer.fromModel( new DummyModel( { name: 'Test' } ) );
const transformed = ModelTransformer.toModel(
DummyModel,
{ name: 'Test' },
undefined,
{ name: ( val ) => 'Transform-' + val },
);
expect( transformed ).toMatchObject( new DummyModel( { name: 'Transform-Test' } ) ); expect( transformed ).not.toBeInstanceOf( DummyModel );
} ); expect( transformed.name ).toEqual( 'Transformed-Test' );
} ); } );
} ); } );

View File

@ -1,92 +1,76 @@
import { Model } from '../models/model'; 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 * @interface ModelTransformation
* @alias Object.<string,string>
*/ */
type KeyChangeMap< F, T > = { export interface ModelTransformation {
[ key in keyof F ]?: keyof T; readonly priority: number;
toModel( properties: any ): any;
fromModel( properties: any ): any;
} }
/** /**
* A map for transformations between API/Model data. * A class for transforming models to/from a generic representation.
*
* @typedef TransformationMap
* @alias Object.<string,Function>
*/ */
type TransformationMap< T > = { export class ModelTransformer< T extends Model > {
[ key in keyof T ]?: ( val: any ) => any;
}
export namespace ModelTransformer {
/** /**
* 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 * @type {Array.<ModelTransformation>}
* @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.
* @private * @private
*/ */
function transform< F, T >( private transformations: readonly ModelTransformation[];
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;
}
/** /**
* 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 {Array.<ModelTransformation>} transformations The transformations to use.
* @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
*/ */
export function fromModel< F extends Partial< Model > >( public constructor( transformations: ModelTransformation[] ) {
data: F, // Ensure that the transformations are sorted by priority.
keyChanges?: KeyChangeMap< F, any >, transformations.sort( ( a, b ) => ( a.priority > b.priority ) ? 1 : -1 );
transformations?: TransformationMap< F >,
): any { this.transformations = transformations;
return transform( data, keyChanges, 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 {Function.<T>} modelClass The model class we're trying to create.
* @param {*} data The raw data to transform. * @param {*} data The data we're transforming.
* @param {KeyChangeMap} keyChanges A map of keys to change into another key. * @return {T} The transformed model.
* @param {TransformationMap} transformations A map of transformations to perform on values keyed by the key.
* @return {T} The created model.
* @template T * @template T
*/ */
export function toModel< T extends Model, F >( public toModel( modelClass: new( properties: Partial< T > ) => T, data: any ): T {
modelClass: new ( properties: Partial< T > ) => T, const transformed: any = this.transformations.reduce(
data: F, ( properties: any, transformer: ModelTransformation ) => {
keyChanges?: KeyChangeMap< F, T >, return transformer.toModel( properties );
transformations?: TransformationMap< F >, },
): T { data,
return new modelClass( transform( data, keyChanges, transformations ) ); );
return new modelClass( transformed );
}
/**
* Takes the input model and runs all of the transformations on it before returning the data.
*
* @param {Partial.<T>} 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,
);
} }
} }

View File

@ -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<DummyModel>(
{
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' );
} );
} );

View File

@ -0,0 +1,74 @@
import { ModelTransformation } from '../model-transformer';
import { Model } from '../../models/model';
/**
* @typedef KeyChanges
* @alias Object.<string,string>
*/
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;
}
}

View File

@ -3,30 +3,32 @@ import { CreateFn, ModelRepository } from '../../../framework/model-repository';
import { SimpleProduct } from '../../../models'; import { SimpleProduct } from '../../../models';
import { CreatesSimpleProducts, SimpleProductRepositoryParams } from '../../../models/products/simple-product'; import { CreatesSimpleProducts, SimpleProductRepositoryParams } from '../../../models/products/simple-product';
import { ModelTransformer } from '../../../framework/model-transformer'; import { ModelTransformer } from '../../../framework/model-transformer';
import { KeyChangeTransformation } from '../../../framework/transformations/key-change-transformation';
function fromServer( data: any ): SimpleProduct { function fromServer( data: any ): SimpleProduct {
if ( ! data.id ) { if ( ! data.id ) {
throw new Error( 'An invalid response was received.' ); throw new Error( 'An invalid response was received.' );
} }
return ModelTransformer.toModel( const t = new ModelTransformer< SimpleProduct >(
SimpleProduct, [
data, new KeyChangeTransformation< SimpleProduct >( { regularPrice: 'regular_price' } ),
{ ],
regular_price: 'regularPrice',
},
); );
return t.toModel( SimpleProduct, data );
} }
function toServer( model: Partial< SimpleProduct > ): any { function toServer( model: Partial< SimpleProduct > ): any {
const t = new ModelTransformer< SimpleProduct >(
[
new KeyChangeTransformation< SimpleProduct >( { regularPrice: 'regular_price' } ),
],
);
return Object.assign( return Object.assign(
{ type: 'simple' }, { type: 'simple' },
ModelTransformer.fromModel( t.fromModel( model ),
model,
{
regularPrice: 'regular_price',
},
),
); );
} }

View File

@ -3,19 +3,20 @@ import { ListFn, ModelRepository } from '../../../framework/model-repository';
import { SettingGroup } from '../../../models'; import { SettingGroup } from '../../../models';
import { ListsSettingGroups, SettingGroupRepositoryParams } from '../../../models/settings/setting-group'; import { ListsSettingGroups, SettingGroupRepositoryParams } from '../../../models/settings/setting-group';
import { ModelTransformer } from '../../../framework/model-transformer'; import { ModelTransformer } from '../../../framework/model-transformer';
import { KeyChangeTransformation } from '../../../framework/transformations/key-change-transformation';
function fromServer( data: any ): SettingGroup { function fromServer( data: any ): SettingGroup {
if ( ! data.id ) { if ( ! data.id ) {
throw new Error( 'An invalid response was received.' ); throw new Error( 'An invalid response was received.' );
} }
return ModelTransformer.toModel( const t = new ModelTransformer< SettingGroup >(
SettingGroup, [
data, new KeyChangeTransformation< SettingGroup >( { parentID: 'parent_id' } ),
{ ],
parent_id: 'parentID',
},
); );
return t.toModel( SettingGroup, data );
} }
function restList( httpClient: HTTPClient ): ListFn< SettingGroupRepositoryParams > { function restList( httpClient: HTTPClient ): ListFn< SettingGroupRepositoryParams > {

View File

@ -19,7 +19,8 @@ function fromServer( data: any ): Setting {
throw new Error( 'An invalid response was received.' ); 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 > { function restList( httpClient: HTTPClient ): ListChildFn< SettingRepositoryParams > {