model-mapper.service.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385
  1. /*
  2. Model mapping class, exposing six public methods
  3. ================================================
  4. setMapping(mapping)
  5. forwardMap(model, mapping?)
  6. lazyForwardMap(model, mapping?)
  7. reverseMap(model, mapping?)
  8. lazyReversMap(model, mapping?)
  9. getErrors()
  10. EXAMPLES
  11. --------
  12. Given the function 'isEven',
  13. and the following model and mapping:
  14. const isEven = v => { return { value: v, isEven: !(v % 2) } }
  15. const model = {
  16. a: 555,
  17. b: 777,
  18. c: { r: 1000, s: 2000, t: 3000, lazy: "everybody's lazy" },
  19. d: 22,
  20. e: 33,
  21. f: 2,
  22. g: 3,
  23. h: 100000,
  24. i: 1,
  25. y: { 'name' : 'lost in action' },
  26. z: 'hello'
  27. }
  28. const mapping = {
  29. a: 'b.c',
  30. b: 'e.f.g',
  31. c: {
  32. r: 'r',
  33. s: 's',
  34. t: 't.u'
  35. },
  36. d: [ v => v * 2, v => v / 2 ],
  37. e: [ 'b.e', v => v * 3, v => v / 3 ],
  38. f: [ isEven, v => v.value ],
  39. g: [ isEven, v => v.value ],
  40. h: [ 'y.hhh', isEven, v => v.value ],
  41. i: v => ['y.iii', isEven(v)] // Deliberately malformed mapping
  42. }
  43. (1) forwardMap gives:
  44. {
  45. b: { c: 555, e: 99 },
  46. e: { f: { g: 777 } },
  47. r: 1000,
  48. s: 2000,
  49. t: { u: 3000 },
  50. d: 44,
  51. f: { value: 2, isEven: true },
  52. g: { value: 3, isEven: false },
  53. y: { hhh: { value: 100000, isEven: true } },
  54. i: 'MAPPING ERROR: Unknown mapping type in mapping at i' }
  55. }
  56. (2) lazyForwardMap (which also copies properties not explicitly specified in the mapping) gives:
  57. {
  58. b: { c: 555, e: 99 },
  59. e: { f: { g: 777 } },
  60. r: 1000,
  61. s: 2000,
  62. t: { u: 3000 },
  63. c: { lazy: 'everybody\'s lazy' },
  64. d: 44,
  65. f: { value: 2, isEven: true },
  66. g: { value: 3, isEven: false },
  67. y: { hhh: { value: 100000, isEven: true }, name: 'lost in action' },
  68. i: 'MAPPING ERROR: Unknown mapping type in mapping at i',
  69. z: 'hello'
  70. }
  71. (3) reverseMap (on either of the forwardMap results) regenerates the explicitly mapped parts of the original model:
  72. {
  73. a: 555,
  74. b: 777,
  75. c: { r: 1000, s: 2000, t: 3000 },
  76. d: 22,
  77. e: 33,
  78. f: 2,
  79. g: 3,
  80. h: 100000
  81. }
  82. (4) lazyReverseMap also copies additional properties, if it can
  83. (it will merge objects without overwriting exissting properties, and won't overwrite scalar properties)
  84. {
  85. a: 555,
  86. b: 777,
  87. c: { r: 1000, s: 2000, t: 3000, lazy: 'everybody\'s lazy' },
  88. d: 22,
  89. e: 33,
  90. f: 2,
  91. g: 3,
  92. h: 100000,
  93. y: { hhh: { value: 100000, isEven: true }, name: 'lost in action' },
  94. i: 'MAPPING ERROR: Unknown mapping type in mapping at i',
  95. z: 'hello'
  96. }
  97. */
  98. import { Injectable } from '@angular/core';
  99. import { get, set, unset, cloneDeep, merge, every, findLast, sortBy } from 'lodash';
  100. @Injectable()
  101. export class ModelMapperService {
  102. mapping: StringMap<any>;
  103. errors: string[] = [];
  104. debug = false;
  105. private clog = (...args) => this.debug ? console.log(...args) : {}; // Wrapper for console log - silent if debug = false
  106. constructor() { }
  107. public setMapping(mapping: StringMap<any>) {
  108. this.mapping = mapping;
  109. }
  110. public forwardMap = (
  111. model: StringMap<any>,
  112. mapping: StringMap<any> = this.mapping,
  113. mapMissing = false,
  114. res = {},
  115. stack = []
  116. ): StringMap<any> => {
  117. // Map the input model onto res using the supplied mapping
  118. Object.keys(model).forEach(key => {
  119. const absPath = [...stack, key].join('.');
  120. const _mapping = get(mapping, key, mapMissing ? key : false);
  121. if (_mapping) {
  122. const mappingType = this.resolveMappingType(_mapping);
  123. switch(mappingType) {
  124. case 'simple':
  125. this.deepSet(res, _mapping, model[key]);
  126. break;
  127. case 'nested':
  128. // Container
  129. if (_mapping.__) {
  130. // Container with functional mapping (stored in __)
  131. // of the form [fn,fn] or [source, fn, fn]
  132. this.clog('-------------------------------------------');
  133. this.clog('CONTAINER WITH STORED FUNCTIONAL MAPPING', absPath);
  134. try {
  135. const storedContainerMapping = _mapping.__;
  136. let targetPath = typeof storedContainerMapping[0] === 'string' ? storedContainerMapping[0] : absPath;
  137. if (targetPath[0] === '/') {
  138. targetPath = targetPath.slice(1);
  139. }
  140. this.clog('Target Path', targetPath);
  141. const func = storedContainerMapping.find(m => typeof m === 'function');
  142. const funcRes = func(model[key]);
  143. const fullRes = targetPath ? set({}, targetPath, funcRes) : funcRes; // Construct an update object from the model's root
  144. this.forwardMap(fullRes, {}, true, res);
  145. } catch (e) {
  146. this.clog(e);
  147. this.errors.push('Error in *container* mapping function at', _mapping, ':::', e.message);
  148. }
  149. this.clog('-------------------------------------------');
  150. } else {
  151. this.forwardMap(model[key], _mapping, mapMissing, res, [...stack, key]);
  152. }
  153. break;
  154. case 'functional':
  155. try {
  156. const result = _mapping.find(m => typeof m === 'function')(model[key]);
  157. key = _mapping.length === 3 ? _mapping[0] : key;
  158. this.deepSet(res, key, result);
  159. } catch (e) {
  160. this.errors.push('Error in mapping function at', _mapping, ':::', e.message);
  161. }
  162. break;
  163. default:
  164. this.deepSet(res, key, `MAPPING ERROR: Unknown mapping type in mapping at ${key}`);
  165. }
  166. }
  167. });
  168. return res;
  169. }
  170. public lazyForwardMap = (
  171. model: StringMap<any>,
  172. mapping: StringMap<any> = this.mapping
  173. ): StringMap<any> => this.forwardMap(model, mapping, true)
  174. public reverseMap = (
  175. model: StringMap<any>,
  176. mapping: StringMap<any> = this.mapping,
  177. mapMissing = false,
  178. pathsToDelete = [],
  179. stack = []
  180. ): StringMap<any> => {
  181. // pathToDelete contains a list of source paths to delete from the model, leaving the missing to be straight-mapped
  182. if (!mapping) {
  183. throw new Error('Attempting to use Model Mapper without mapping');
  184. }
  185. const res = {};
  186. const modelClone = stack.length ? model : cloneDeep(model); // Clone the model unless inside a recursive call
  187. Object.keys(mapping).filter(key => key !== '__').forEach(key => {
  188. const dataMapping = mapping[key];
  189. const mappingType = this.resolveMappingType(dataMapping);
  190. switch(mappingType) {
  191. case 'simple':
  192. {
  193. // A simple path
  194. const value = get(modelClone, dataMapping);
  195. if (typeof value !== 'undefined') {
  196. this.deepSet(res, key, value);
  197. }
  198. }
  199. break;
  200. case 'nested':
  201. {
  202. let alternativeModel = null;
  203. if (dataMapping.__) {
  204. // Evaluate the functional mapping stored in __ and use the result as an alternative model for the container
  205. const absKey = [...stack, key].join('.');
  206. alternativeModel = this.evaluateReverseFunctionalMapping(model, absKey, dataMapping.__);
  207. this.clog('Setting alternative model for container', alternativeModel);
  208. }
  209. const value = this.reverseMap(alternativeModel || modelClone, dataMapping, mapMissing, pathsToDelete, [...stack, key]);
  210. if (Object.keys(value).length) {
  211. this.deepSet(res, key, value);
  212. }
  213. }
  214. break;
  215. case 'functional':
  216. {
  217. // Evaluate the functional mapping stored in dataMapping
  218. const absKey = [...stack, key].join('.');
  219. const value = this.evaluateReverseFunctionalMapping(model, absKey, dataMapping);
  220. this.deepSet(res, key, value);
  221. }
  222. break;
  223. default:
  224. this.errors.push(`MAPPING ERROR: Unknown mapping type in mapping at ${key}`);
  225. }
  226. if (mappingType && mappingType !== 'nested') {
  227. pathsToDelete.push(dataMapping);
  228. }
  229. });
  230. if (mapMissing && !stack.length) {
  231. // Delete the paths we have already reverse mapped (if scalar value - not objects), to leave the rest
  232. // Sort pathsToDelete by depth, shallowest to deepest
  233. const deepestPathsLast = this.sortByPathDepth(pathsToDelete);
  234. while(deepestPathsLast.length) {
  235. const path = deepestPathsLast.pop();
  236. const t = typeof get(modelClone, path);
  237. if (t === 'number' || t === 'string' || t === 'boolean') {
  238. unset(modelClone, path);
  239. }
  240. }
  241. const modelRemainder = this.deepCleanse(modelClone);
  242. Object.keys(modelRemainder).forEach(key => {
  243. this.deepSetIfEmpty(res, key, modelRemainder[key]);
  244. });
  245. }
  246. return res;
  247. }
  248. public lazyReverseMap = (
  249. model: StringMap<any>,
  250. mapping: StringMap<any> = this.mapping
  251. ): StringMap<any> => this.reverseMap(model, mapping, true)
  252. public getErrors() {
  253. return this.errors;
  254. }
  255. // -----------------------------------------------------------------------------------------------------------------
  256. private resolveMappingType = mappingPath => {
  257. let mappingType;
  258. const t = typeof mappingPath;
  259. if (t === 'number' || t === 'string') { // CHECK WHAT HAPPENS with number types
  260. mappingType = 'simple';
  261. } else if (t === 'object') {
  262. // tslint:disable-next-line:prefer-conditional-expression
  263. if (
  264. Array.isArray(mappingPath)
  265. && mappingPath.length === 2
  266. && every(mappingPath, m => typeof m === 'function')
  267. ||
  268. Array.isArray(mappingPath)
  269. && mappingPath.length === 3
  270. && (typeof mappingPath[0] === 'number' || typeof mappingPath[0] === 'string')
  271. && every(mappingPath.slice(1), m => typeof m === 'function')
  272. ) {
  273. mappingType = 'functional';
  274. } else {
  275. mappingType = 'nested';
  276. }
  277. }
  278. return mappingType;
  279. }
  280. private evaluateReverseFunctionalMapping = (model, absKey, fnMapping) => {
  281. this.clog('evaluateReverseFunctionalMapping');
  282. this.clog(model, absKey, fnMapping);
  283. let arg;
  284. let result;
  285. const path = typeof fnMapping[0] === 'string' ? fnMapping[0] : absKey;
  286. if (path === '/') {
  287. arg = model; // '/' indicates use the entire model
  288. } else {
  289. arg = get(model, path.replace(/^\//, ''));
  290. }
  291. const func = findLast(fnMapping, m => typeof m === 'function');
  292. try {
  293. result = func(arg);
  294. } catch(e) {
  295. this.errors.push('Error in mapping function at', absKey, ':::', e.message);
  296. }
  297. return result;
  298. }
  299. private deepSet = (obj, mappingPath, valueToSet, overwrite = true) => {
  300. // NOTE: This mutates the incoming object at the moment, so doesn't reed to return a value
  301. // Maybe rewrite with a more functional approach?
  302. // Will deep merge where possible
  303. const currentVal = get(obj, mappingPath);
  304. const t = typeof currentVal;
  305. if (t === 'undefined') {
  306. set(obj, mappingPath, valueToSet);
  307. } else if (t === 'number' || t === 'string' || t === 'boolean') {
  308. // We can only overwrite existing scalar values, not deep merge
  309. if (overwrite) {
  310. this.errors.push('WARNING: Overwriting scalar value at', mappingPath);
  311. set(obj, mappingPath, valueToSet);
  312. } else {
  313. this.errors.push('WARNING: Discarding scalar value at', mappingPath, 'as exisiting non-scalar value would be overwritten');
  314. }
  315. } else if (t === 'object' && typeof valueToSet === 'object') {
  316. // Deep merge
  317. let merged = merge(currentVal, valueToSet);
  318. if (!overwrite) {
  319. merged = merge(merged, currentVal); // Is there a better way?
  320. }
  321. set(obj, mappingPath, merged);
  322. } else {
  323. this.errors.push('WARNING: Could not merge', typeof valueToSet, 'with object');
  324. }
  325. }
  326. private deepSetIfEmpty = (obj, mappingPath, valueToSet) => this.deepSet(obj, mappingPath, valueToSet, false);
  327. private deepCleanse = obj => {
  328. const cleansedObj = Object.keys(obj)
  329. .filter(k => obj[k] !== null && obj[k] !== undefined) // Remove undefined and null
  330. .reduce((newObj, k) => {
  331. typeof obj[k] === 'object' ?
  332. Object.assign(newObj, { [k]: this.deepCleanse(obj[k]) }) : // Recurse if value is non-empty object
  333. Object.assign(newObj, { [k]: obj[k] }); // Copy value
  334. return newObj;
  335. }, {});
  336. Object.keys(cleansedObj).forEach(k => {
  337. if (typeof cleansedObj[k] === 'object' && !Object.keys(cleansedObj[k]).length) {
  338. delete cleansedObj[k];
  339. }
  340. });
  341. return cleansedObj;
  342. }
  343. private sortByPathDepth = pathArr => sortBy(pathArr, p => p.split('.').length);
  344. private clearErrors() {
  345. this.errors = [];
  346. }
  347. }