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",
"!/dist/**/__tests__/",
"!/dist/**/__mocks__/",
"!/dist/**/__snapshops__/"
"!/dist/**/__snapshops__/",
"!/dist/**/__test_data__/"
],
"sideEffects": false,
"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,
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 {

View File

@ -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' } );
expect( fn1 ).toHaveBeenCalledWith( { name: 'fn0' } );
expect( fn2 ).toHaveBeenCalledWith( { name: 'fn1' } );
expect( transformed ).toMatchObject( { name: 'fn2' } );
} );
it( 'should transform to model', () => {
const transformer = new ModelTransformer< DummyModel >(
[
new DummyTransformation(
0,
( p: any ) => {
p.name = 'Transformed-' + p.name;
return p;
},
),
],
);
const model = transformer.toModel( DummyModel, { name: 'Test' } );
expect( model ).toBeInstanceOf( DummyModel );
expect( model.name ).toEqual( 'Transformed-Test' );
} );
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 transform from model', () => {
const transformer = new ModelTransformer< DummyModel >(
[
new DummyTransformation(
0,
( p: any ) => {
p.name = 'Transformed-' + p.name;
return p;
},
);
} );
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',
},
);
} );
} );
const transformed = transformer.fromModel( new DummyModel( { name: 'Test' } ) );
describe( 'toModel', () => {
it( 'should create models plainly', () => {
const transformed = ModelTransformer.toModel(
DummyModel,
{ name: 'Test' },
);
expect( transformed ).toMatchObject( new DummyModel( { name: 'Test' } ) );
} );
it( 'should create models with key changes', () => {
const transformed = ModelTransformer.toModel(
DummyModel,
{ test: 'Test' },
{ test: 'name' },
);
expect( transformed ).toMatchObject( new DummyModel( { name: 'Test' } ) );
} );
it( 'should create models with transformations', () => {
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';
/**
* 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.<string,string>
* @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.<string,Function>
* 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.<ModelTransformation>}
* @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.<ModelTransformation>} 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.<T>} 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.<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 { 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 ),
);
}

View File

@ -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 > {

View File

@ -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 > {