123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385 |
- /*
- 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<any>;
- 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<any>) {
- this.mapping = mapping;
- }
- public forwardMap = (
- model: StringMap<any>,
- mapping: StringMap<any> = this.mapping,
- mapMissing = false,
- res = {},
- stack = []
- ): StringMap<any> => {
- // 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<any>,
- mapping: StringMap<any> = this.mapping
- ): StringMap<any> => this.forwardMap(model, mapping, true)
- public reverseMap = (
- model: StringMap<any>,
- mapping: StringMap<any> = this.mapping,
- mapMissing = false,
- pathsToDelete = [],
- stack = []
- ): StringMap<any> => {
- // 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<any>,
- mapping: StringMap<any> = this.mapping
- ): StringMap<any> => 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 = [];
- }
- }
|