/* 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 { get, set, unset, cloneDeep, merge, every, findLast, sortBy } from 'lodash'; @Injectable() export class ModelMapperService { mapping: StringMap; errors: string[] = []; debug = false; private clog = (...args) => this.debug ? console.log(...args) : {}; // Wrapper for console log - silent if debug = false constructor() { } public setMapping(mapping: StringMap) { this.mapping = mapping; } public forwardMap = ( model: StringMap, mapping: StringMap = this.mapping, mapMissing = false, res = {}, stack = [] ): StringMap => { // Map the input model onto res using the supplied mapping Object.keys(model).forEach(key => { const absPath = [...stack, key].join('.'); const _mapping = get(mapping, key, mapMissing ? key : false); if (_mapping) { const mappingType = this.resolveMappingType(_mapping); switch(mappingType) { case 'simple': this.deepSet(res, _mapping, model[key]); break; case 'nested': // Container if (_mapping.__) { // Container with functional mapping (stored in __) // of the form [fn,fn] or [source, fn, fn] this.clog('-------------------------------------------'); this.clog('CONTAINER WITH STORED FUNCTIONAL MAPPING', absPath); try { const storedContainerMapping = _mapping.__; let targetPath = typeof storedContainerMapping[0] === 'string' ? storedContainerMapping[0] : absPath; if (targetPath[0] === '/') { targetPath = targetPath.slice(1); } this.clog('Target Path', targetPath); const func = storedContainerMapping.find(m => typeof m === 'function'); const funcRes = func(model[key]); const fullRes = targetPath ? set({}, targetPath, funcRes) : funcRes; // Construct an update object from the model's root this.forwardMap(fullRes, {}, true, res); } catch (e) { this.clog(e); this.errors.push('Error in *container* mapping function at', _mapping, ':::', e.message); } this.clog('-------------------------------------------'); } else { this.forwardMap(model[key], _mapping, mapMissing, res, [...stack, key]); } break; case 'functional': try { const result = _mapping.find(m => typeof m === 'function')(model[key]); key = _mapping.length === 3 ? _mapping[0] : key; this.deepSet(res, key, result); } catch (e) { this.errors.push('Error in mapping function at', _mapping, ':::', 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 if (!mapping) { throw new Error('Attempting to use Model Mapper without mapping'); } const res = {}; const modelClone = stack.length ? model : cloneDeep(model); // Clone the model unless inside a recursive call Object.keys(mapping).filter(key => key !== '__').forEach(key => { const dataMapping = mapping[key]; const mappingType = this.resolveMappingType(dataMapping); switch(mappingType) { case 'simple': { // A simple path const value = get(modelClone, dataMapping); if (typeof value !== 'undefined') { this.deepSet(res, key, value); } } break; case 'nested': { let alternativeModel = null; if (dataMapping.__) { // Evaluate the functional mapping stored in __ and use the result as an alternative model for the container const absKey = [...stack, key].join('.'); alternativeModel = this.evaluateReverseFunctionalMapping(model, absKey, dataMapping.__); this.clog('Setting alternative model for container', alternativeModel); } const value = this.reverseMap(alternativeModel || modelClone, dataMapping, mapMissing, pathsToDelete, [...stack, key]); if (Object.keys(value).length) { this.deepSet(res, key, value); } } break; case 'functional': { // Evaluate the functional mapping stored in dataMapping const absKey = [...stack, key].join('.'); const value = this.evaluateReverseFunctionalMapping(model, absKey, dataMapping); this.deepSet(res, key, value); } break; default: this.errors.push(`MAPPING ERROR: Unknown mapping type in mapping at ${key}`); } if (mappingType && mappingType !== 'nested') { pathsToDelete.push(dataMapping); } }); 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; const t = typeof mappingPath; if (t === 'number' || t === 'string') { // CHECK WHAT HAPPENS with number types mappingType = 'simple'; } else if (t === 'object') { // tslint:disable-next-line:prefer-conditional-expression 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 evaluateReverseFunctionalMapping = (model, absKey, fnMapping) => { this.clog('evaluateReverseFunctionalMapping'); this.clog(model, absKey, fnMapping); let arg; let result; const path = typeof fnMapping[0] === 'string' ? fnMapping[0] : absKey; if (path === '/') { arg = model; // '/' indicates use the entire model } else { arg = get(model, path.replace(/^\//, '')); } const func = findLast(fnMapping, m => typeof m === 'function'); try { result = func(arg); } catch(e) { this.errors.push('Error in mapping function at', absKey, ':::', e.message); } return result; } 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 const currentVal = get(obj, mappingPath); const 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 non-scalar value 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 => { const 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 = []; } }