|
@@ -0,0 +1,303 @@
|
|
|
+/*
|
|
|
+
|
|
|
+Model mapping class, exposing six public methods
|
|
|
+================================================
|
|
|
+
|
|
|
+setMapping(mapping)
|
|
|
+forwardMap(model, mapping?)
|
|
|
+lazyForwardMap(model, mapping?)
|
|
|
+reverseMap(model, mapping?)
|
|
|
+lazyReversMap(model, mapping?)
|
|
|
+getErrors()
|
|
|
+
|
|
|
+EXAMPLES
|
|
|
+--------
|
|
|
+
|
|
|
+Given the function 'isEven',
|
|
|
+and the following model and mapping:
|
|
|
+
|
|
|
+const isEven = v => { return { value: v, isEven: !(v % 2) } }
|
|
|
+
|
|
|
+const model = {
|
|
|
+ a: 555,
|
|
|
+ b: 777,
|
|
|
+ c: { r: 1000, s: 2000, t: 3000, lazy: "everybody's lazy" },
|
|
|
+ d: 22,
|
|
|
+ e: 33,
|
|
|
+ f: 2,
|
|
|
+ g: 3,
|
|
|
+ h: 100000,
|
|
|
+ i: 1,
|
|
|
+ y: { 'name' : 'lost in action' },
|
|
|
+ z: 'hello'
|
|
|
+}
|
|
|
+
|
|
|
+const mapping = {
|
|
|
+ a: 'b.c',
|
|
|
+ b: 'e.f.g',
|
|
|
+ c: {
|
|
|
+ r: 'r',
|
|
|
+ s: 's',
|
|
|
+ t: 't.u'
|
|
|
+ },
|
|
|
+ d: [ v => v * 2, v => v / 2 ],
|
|
|
+ e: [ 'b.e', v => v * 3, v => v / 3 ],
|
|
|
+ f: [ isEven, v => v.value ],
|
|
|
+ g: [ isEven, v => v.value ],
|
|
|
+ h: [ 'y.hhh', isEven, v => v.value ],
|
|
|
+ i: v => ['y.iii', isEven(v)] // Deliberately malformed mapping
|
|
|
+}
|
|
|
+
|
|
|
+(1) forwardMap gives:
|
|
|
+
|
|
|
+{ b: { c: 555, e: 99 },
|
|
|
+ e: { f: { g: 777 } },
|
|
|
+ r: 1000,
|
|
|
+ s: 2000,
|
|
|
+ t: { u: 3000 },
|
|
|
+ d: 44,
|
|
|
+ f: { value: 2, isEven: true },
|
|
|
+ g: { value: 3, isEven: false },
|
|
|
+ y: { hhh: { value: 100000, isEven: true } },
|
|
|
+ i: 'MAPPING ERROR: Unknown mapping type in mapping at i' }
|
|
|
+
|
|
|
+(2) lazyForwardMap (which also copies properties not explicitly specified in the mapping) gives:
|
|
|
+
|
|
|
+{ b: { c: 555, e: 99 },
|
|
|
+ e: { f: { g: 777 } },
|
|
|
+ r: 1000,
|
|
|
+ s: 2000,
|
|
|
+ t: { u: 3000 },
|
|
|
+ c: { lazy: 'everybody\'s lazy' },
|
|
|
+ d: 44,
|
|
|
+ f: { value: 2, isEven: true },
|
|
|
+ g: { value: 3, isEven: false },
|
|
|
+ y: { hhh: { value: 100000, isEven: true }, name: 'lost in action' },
|
|
|
+ i: 'MAPPING ERROR: Unknown mapping type in mapping at i',
|
|
|
+ z: 'hello' }
|
|
|
+
|
|
|
+(3) reverseMap (on either of the forwardMap results) regenerates the explicitly mapped parts of the original model:
|
|
|
+
|
|
|
+{ a: 555,
|
|
|
+ b: 777,
|
|
|
+ c: { r: 1000, s: 2000, t: 3000 },
|
|
|
+ d: 22,
|
|
|
+ e: 33,
|
|
|
+ f: 2,
|
|
|
+ g: 3,
|
|
|
+ h: 100000 }
|
|
|
+
|
|
|
+(4) lazyReverseMap also copies additional properties, if it can
|
|
|
+ (it will merge objects without overwriting exissting properties, and won't overwrite scalar properties)
|
|
|
+
|
|
|
+{ a: 555,
|
|
|
+ b: 777,
|
|
|
+ c: { r: 1000, s: 2000, t: 3000, lazy: 'everybody\'s lazy' },
|
|
|
+ d: 22,
|
|
|
+ e: 33,
|
|
|
+ f: 2,
|
|
|
+ g: 3,
|
|
|
+ h: 100000,
|
|
|
+ y: { hhh: { value: 100000, isEven: true }, name: 'lost in action' },
|
|
|
+ i: 'MAPPING ERROR: Unknown mapping type in mapping at i',
|
|
|
+ z: 'hello' }
|
|
|
+
|
|
|
+*/
|
|
|
+
|
|
|
+import { Injectable } from '@angular/core';
|
|
|
+import * as _ from 'lodash';
|
|
|
+
|
|
|
+@Injectable()
|
|
|
+export class ModelMapperService {
|
|
|
+
|
|
|
+ mapping: StringMap;
|
|
|
+ errors: string[] = [];
|
|
|
+
|
|
|
+ constructor() { }
|
|
|
+
|
|
|
+ public setMapping(mapping: StringMap) {
|
|
|
+ this.mapping = mapping;
|
|
|
+ }
|
|
|
+
|
|
|
+ public forwardMap = (model: StringMap, mapping: StringMap = this.mapping, mapMissing = false, res = {}, stack = []): StringMap => {
|
|
|
+ Object.keys(model).forEach(key => {
|
|
|
+ let mappingPath = _.get(mapping, key, mapMissing ? [...stack, key].join('.') : false);
|
|
|
+ if (mappingPath) {
|
|
|
+ let mappingType = this.resolveMappingType(mappingPath);
|
|
|
+ switch(mappingType) {
|
|
|
+ case 'simple':
|
|
|
+ this.deepSet(res, mappingPath, model[key]);
|
|
|
+ break;
|
|
|
+ case 'nested':
|
|
|
+ this.forwardMap(model[key], mappingPath, mapMissing, res, [...stack, key]);
|
|
|
+ break;
|
|
|
+ case 'functional':
|
|
|
+ try {
|
|
|
+ let result = mappingPath.find(m => typeof m === 'function')(model[key]);
|
|
|
+ key = mappingPath.length === 3 ? mappingPath[0] : key;
|
|
|
+ this.deepSet(res, key, result);
|
|
|
+ } catch (e) {
|
|
|
+ this.errors.push('Error in mapping function at', mappingPath, ':::', e.message);
|
|
|
+ }
|
|
|
+ break;
|
|
|
+ default:
|
|
|
+ this.deepSet(res, key, `MAPPING ERROR: Unknown mapping type in mapping at ${key}`);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ });
|
|
|
+ return res;
|
|
|
+ }
|
|
|
+
|
|
|
+ public lazyForwardMap = (model: StringMap, mapping: StringMap = this.mapping): StringMap => this.forwardMap(model, mapping, true);
|
|
|
+
|
|
|
+ public reverseMap = (model: StringMap, mapping: StringMap = this.mapping, mapMissing = false, pathsToDelete = [], stack = []): StringMap => {
|
|
|
+ // pathToDelete contains a list of source paths to delete from the model, leaving the missing to be straight-mapped
|
|
|
+ let res = {};
|
|
|
+ let modelClone = stack.length ? model : _.cloneDeep(model); // Clone the model unless inside a recursive call
|
|
|
+ Object.keys(mapping).forEach(key => {
|
|
|
+ let mappingType = this.resolveMappingType(mapping[key]);
|
|
|
+ let destinationPath = mapping[key];
|
|
|
+ let value;
|
|
|
+ switch(mappingType) {
|
|
|
+ case 'simple':
|
|
|
+ value = _.get(modelClone, mapping[key]);
|
|
|
+ if (typeof value !== 'undefined') {
|
|
|
+ this.deepSet(res, key, value);
|
|
|
+ }
|
|
|
+ break;
|
|
|
+ case 'nested':
|
|
|
+ value = this.reverseMap(modelClone, mapping[key], mapMissing, pathsToDelete, [...stack, key]);
|
|
|
+ if (Object.keys(value).length) {
|
|
|
+ this.deepSet(res, key, value);
|
|
|
+ }
|
|
|
+ break;
|
|
|
+ case 'functional':
|
|
|
+ if (typeof mapping[key][0] === 'string') {
|
|
|
+ destinationPath = mapping[key][0];
|
|
|
+ } else {
|
|
|
+ destinationPath = key;
|
|
|
+ }
|
|
|
+ let arg = _.get(modelClone, destinationPath);
|
|
|
+ if (typeof arg !== 'undefined') {
|
|
|
+ let func = _.findLast(mapping[key], m => typeof m === 'function');
|
|
|
+ try {
|
|
|
+ value = func(arg);
|
|
|
+ this.deepSet(res, key, value);
|
|
|
+ } catch(e) {
|
|
|
+ this.errors.push('Error in mapping function at', key, ':::', e.message);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ break;
|
|
|
+ default:
|
|
|
+ this.errors.push(`MAPPING ERROR: Unknown mapping type in mapping at ${key}`);
|
|
|
+ }
|
|
|
+ if (mappingType && mappingType !== 'nested') {
|
|
|
+ pathsToDelete.push(destinationPath);
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ if (mapMissing && !stack.length) {
|
|
|
+ // Delete the paths we have already reverse mapped (if scalar value - not objects), to leave the rest
|
|
|
+ // Sort pathsToDelete by depth, shallowest to deepest
|
|
|
+ const deepestPathsLast = this.sortByPathDepth(pathsToDelete);
|
|
|
+ while(deepestPathsLast.length) {
|
|
|
+ const path = deepestPathsLast.pop();
|
|
|
+ const t = typeof _.get(modelClone, path);
|
|
|
+ if (t === 'number' || t === 'string' || t === 'boolean') {
|
|
|
+ _.unset(modelClone, path);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ const modelRemainder = this.deepCleanse(modelClone);
|
|
|
+ Object.keys(modelRemainder).forEach(key => {
|
|
|
+ this.deepSetIfEmpty(res, key, modelRemainder[key]);
|
|
|
+ })
|
|
|
+ }
|
|
|
+ return res;
|
|
|
+ }
|
|
|
+
|
|
|
+ public lazyReverseMap = (model: StringMap, mapping: StringMap = this.mapping): StringMap => this.reverseMap(model, mapping, true);
|
|
|
+
|
|
|
+ public getErrors() {
|
|
|
+ return this.errors;
|
|
|
+ }
|
|
|
+
|
|
|
+ // -----------------------------------------------------------------------------------------------------------------
|
|
|
+
|
|
|
+ private resolveMappingType = mappingPath => {
|
|
|
+ let mappingType;
|
|
|
+ let t = typeof mappingPath;
|
|
|
+ if (t === 'number' || t === 'string') { // CHECK WHAT HAPPENS with number types
|
|
|
+ mappingType = 'simple';
|
|
|
+ } else if (t === 'object') {
|
|
|
+ if (
|
|
|
+ Array.isArray(mappingPath)
|
|
|
+ && mappingPath.length === 2
|
|
|
+ && _.every(mappingPath, m => typeof m === 'function')
|
|
|
+ ||
|
|
|
+ Array.isArray(mappingPath)
|
|
|
+ && mappingPath.length === 3
|
|
|
+ && (typeof mappingPath[0] === 'number' || typeof mappingPath[0] === 'string')
|
|
|
+ && _.every(mappingPath.slice(1), m => typeof m === 'function')
|
|
|
+ ) {
|
|
|
+ mappingType = 'functional';
|
|
|
+ } else {
|
|
|
+ mappingType = 'nested';
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return mappingType;
|
|
|
+ }
|
|
|
+
|
|
|
+ private deepSet = (obj, mappingPath, valueToSet, overwrite = true) => {
|
|
|
+ // NOTE: This mutates the incoming object at the moment, so doesn't reed to return a value
|
|
|
+ // Maybe rewrite with a more functional approach?
|
|
|
+ // Will deep merge where possible
|
|
|
+ let currentVal = _.get(obj, mappingPath);
|
|
|
+ let t = typeof currentVal;
|
|
|
+ if (t === 'undefined') {
|
|
|
+ _.set(obj, mappingPath, valueToSet);
|
|
|
+ } else if (t === 'number' || t === 'string' || t === 'boolean') {
|
|
|
+ // We can only overwrite existing scalar values, not deep merge
|
|
|
+ if (overwrite) {
|
|
|
+ this.errors.push('WARNING: Overwriting scalar value at', mappingPath);
|
|
|
+ _.set(obj, mappingPath, valueToSet);
|
|
|
+ } else {
|
|
|
+ this.errors.push('WARNING: Discarding scalar value at', mappingPath, 'as exisiting vale would be overwritten');
|
|
|
+ }
|
|
|
+ } else if (t === 'object' && typeof valueToSet === 'object') {
|
|
|
+ // Deep merge
|
|
|
+ let merged = _.merge(currentVal, valueToSet);
|
|
|
+ if (!overwrite) {
|
|
|
+ merged = _.merge(merged, currentVal); // Is there a better way?
|
|
|
+ }
|
|
|
+ _.set(obj, mappingPath, merged);
|
|
|
+ } else {
|
|
|
+ this.errors.push('WARNING: Could not merge', typeof valueToSet, 'with object')
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private deepSetIfEmpty = (obj, mappingPath, valueToSet) => this.deepSet(obj, mappingPath, valueToSet, false);
|
|
|
+
|
|
|
+ private deepCleanse = obj => {
|
|
|
+ let cleansedObj = Object.keys(obj)
|
|
|
+ .filter(k => obj[k] !== null && obj[k] !== undefined) // Remove undefined and null
|
|
|
+ .reduce((newObj, k) => {
|
|
|
+ typeof obj[k] === 'object' ?
|
|
|
+ Object.assign(newObj, { [k]: this.deepCleanse(obj[k]) }) : // Recurse if value is non-empty object
|
|
|
+ Object.assign(newObj, { [k]: obj[k] }) // Copy value
|
|
|
+ return newObj;
|
|
|
+ }, {});
|
|
|
+ Object.keys(cleansedObj).forEach(k => {
|
|
|
+ if (typeof cleansedObj[k] === 'object' && !Object.keys(cleansedObj[k]).length) {
|
|
|
+ delete cleansedObj[k];
|
|
|
+ }
|
|
|
+ });
|
|
|
+ return cleansedObj;
|
|
|
+ }
|
|
|
+
|
|
|
+ private sortByPathDepth = pathArr => _.sortBy(pathArr, p => p.split('.').length);
|
|
|
+
|
|
|
+ private clearErrors() {
|
|
|
+ this.errors = [];
|
|
|
+ }
|
|
|
+
|
|
|
+}
|