/** * Copyright © schukai GmbH and all contributing authors, {{copyRightYear}}. All rights reserved. * Node module: @schukai/monster * * This source code is licensed under the GNU Affero General Public License version 3 (AGPLv3). * The full text of the license can be found at: https://www.gnu.org/licenses/agpl-3.0.en.html * * For those who do not wish to adhere to the AGPLv3, a commercial license is available. * Acquiring a commercial license allows you to use this software without complying with the AGPLv3 terms. * For more information about purchasing a commercial license, please contact schukai GmbH. * * SPDX-License-Identifier: AGPL-3.0 */ import { isFunction, isObject, isString } from "../types/is.mjs"; import { validateString } from "../types/validate.mjs"; import { clone } from "../util/clone.mjs"; import { DELIMITER, Pathfinder, WILDCARD } from "./pathfinder.mjs"; export { buildMap, PARENT, assembleParts }; /** * @type {string} * @memberOf Monster.Data */ const PARENT = "^"; /** * Maps can be easily created from data objects with the help of the function `buildMap()`. * * The path can be specified as either a simple definition a.b.c or a template ${a.b.c}. * Key and value can be either a definition or a template. The key does not have to be defined. * The templates determine the appearance of the keys and the value of the map. Either a single value * id can be taken or a composite key ${id} ${name} can be used. * * If you want to access the values of the parent data set, you have to use the ^ character, for example, ${id} ${^.name}. * * @externalExample ../../example/data/buildmap.mjs * * @param {*} subject - The data object from which the map will be created * @param {string|Monster.Data~exampleSelectorCallback} selector - The path to the data object, or a callback that returns a map. * @param {string} [valueTemplate] - A template for the value of the map. * @param {string} [keyTemplate] - A template for the key of the map. * @param {Monster.Data~exampleFilterCallback} [filter] - A callback function to filter out values. * @return {*} - The created map. * @memberOf Monster.Data * @throws {TypeError} - If the value is neither a string nor a function. * @throws {TypeError} - If the selector callback does not return a map. **/ function buildMap(subject, selector, valueTemplate, keyTemplate, filter) { return assembleParts(subject, selector, filter, function (v, k, m) { k = build(v, keyTemplate, k); v = build(v, valueTemplate); this.set(k, v); }); } /** * The assembleParts function is a private function that helps in building a map from a subject object based on a provided * selector. The selector can either be a string or a callback function. This function is meant to be used as a * helper function by other functions in the module. * * The function takes four parameters: * * subject: The subject object from which the map is to be built * selector: The selector to determine the structure of the map. It can be a string or a callback function. * filter (optional): A callback function that can be used to filter values based on some criteria. * callback: A function to be called for each element in the map. * If the selector parameter is a callback function, it is executed passing the subject as its argument, * and the resulting value must be an instance of Map. Otherwise, if the selector parameter is a string, * buildFlatMap is called to build a flat map with keys and values extracted from the subject object based on the selector. * * If the filter parameter is provided, it will be used to filter out certain elements from the map, based on some * criteria. The callback will be passed the value, key, and map object, and if it returns false, the element will be skipped. * * For each element in the map, the callback function is called with the following parameters: * * v: The value of the element * k: The key of the element * m: The map object * The function returns a new map with the processed values. If map is not an instance of Map, an empty map will be returned. * * Example Usage: * * ```javascript * const obj = { * name: "John", * age: 30, * address: { * city: "New York", * state: "NY", * country: "USA", * }, * }; * * const selector = "address"; * * const map = assembleParts(obj, selector, null, function (v, k, m) { * this.set(k, v); * }); * * console.log(map); * // Output: Map(3) { * // "address.city" => "New York", * // "address.state" => "NY", * // "address.country" => "USA" * // } * ``` * * * @private * @param {*} subject - The subject object from which the map is to be built. * @param {string|Monster.Data~exampleSelectorCallback} selector - The selector to determine the structure of the map. It can be a string or a callback function. * @param {Monster.Data~exampleFilterCallback} [filter] - A callback function that can be used to filter values based on some criteria. * @param {function} callback - A function to be called for each element in the map. * @return {Map} - A new map with the processed values. * @throws {TypeError} - When selector is neither a string nor a function. * @memberOf Monster.Data */ function assembleParts(subject, selector, filter, callback) { const result = new Map(); let map; if (isFunction(selector)) { map = selector(subject); if (!(map instanceof Map)) { throw new TypeError("the selector callback must return a map"); } } else if (isString(selector)) { map = new Map(); buildFlatMap.call(map, subject, selector); } else { throw new TypeError("selector is neither a string nor a function"); } if (!(map instanceof Map)) { return result; } map.forEach((v, k, m) => { if (isFunction(filter)) { if (filter.call(m, v, k) !== true) return; } callback.call(result, v, k, m); }); return result; } /** * @private * @param subject * @param selector * @param key * @param parentMap * @return {*} */ function buildFlatMap(subject, selector, key, parentMap) { const currentMap = new Map(); const resultLength = this.size; if (key === undefined) key = []; const parts = selector.split(DELIMITER); let current = ""; const currentPath = []; do { current = parts.shift(); currentPath.push(current); if (current === WILDCARD) { const finder = new Pathfinder(subject); let map; try { map = finder.getVia(currentPath.join(DELIMITER)); } catch (e) { const a = e; map = new Map(); } for (const [k, o] of map) { const copyKey = clone(key); currentPath.map((a) => { copyKey.push(a === WILDCARD ? k : a); }); const kk = copyKey.join(DELIMITER); const sub = buildFlatMap.call( this, o, parts.join(DELIMITER), copyKey, o, ); if (isObject(sub) && parentMap !== undefined) { sub[PARENT] = parentMap; } currentMap.set(kk, sub); } } } while (parts.length > 0); // no set in child run if (resultLength === this.size) { for (const [k, o] of currentMap) { this.set(k, o); } } return subject; } /** * With the help of this filter callback, values can be filtered out. Only if the filter function returns true, the value is taken for the map. * * @callback Monster.Data~exampleFilterCallback * @param {*} value Value * @param {string} key Key * @memberOf Monster.Data * @see {@link Monster.Data.buildMap} */ /** * Alternatively to a string selector a callback can be specified. this must return a map. * * @example * import {buildMap} from '@schukai/monster/source/data/buildmap.mjs'; * * let obj = { * "data": [ * { * "id": 10, * "name": "Cassandra", * "enrichment": { * variants: [ * { * sku: 1, label: "XXS", price: [ * {vk: '12.12 €'}, * {vk: '12.12 €'} * ] * }, * { * sku: 2, label: "XS", price: [ * {vk: '22.12 €'}, * {vk: '22.12 €'} * ] * }, * { * sku: 3, label: "S", price: [ * {vk: '32.12 €'}, * {vk: '32.12 €'} * ] * }, * { * sku: 4, label: "L", price: [ * {vk: '42.12 €'}, * {vk: '42.12 €'} * ] * } * ] * * } * }, * { * "id": 20, * "name": "Yessey!", * "enrichment": { * variants: [ * { * sku: 1, label: "XXS", price: [ * {vk: '12.12 €'}, * {vk: '12.12 €'} * ] * }, * { * sku: 2, label: "XS", price: [ * {vk: '22.12 €'}, * {vk: '22.12 €'} * ] * }, * { * sku: 3, label: "S", price: [ * {vk: '32.12 €'}, * {vk: '32.12 €'} * ] * }, * { * sku: 4, label: "L", price: [ * {vk: '42.12 €'}, * {vk: '42.12 €'} * ] * } * ] * * } * } * ] * }; * * let callback = function (subject) { * let m = new Map; * * for (const [i, b] of Object.entries(subject.data)) { * * let key1 = i; * * for (const [j, c] of Object.entries(b.enrichment.variants)) { * let key2 = j; * * for (const [k, d] of Object.entries(c.price)) { * * let key3 = k; * * d.name = b.name; * d.label = c.label; * d.id = [key1, key2, key3].join('.'); * * m.set(d.id, d); * } * * } * } * return m; * } * * let map = buildMap(obj, callback, '${name} ${vk}', '${id}') * * // ↦ Map(3) { * // "0.0.0":"Cassandra 12.12 €", * // "0.0.1":"Cassandra 12.12 €", * // "0.1.0":"Cassandra 22.12 €", * // "0.1.1":"Cassandra 22.12 €", * // "0.2.0":"Cassandra 32.12 €", * // "0.2.1":"Cassandra 32.12 €", * // "0.3.0":"Cassandra 42.12 €", * // "0.3.1":"Cassandra 42.12 €", * // "1.0.0":"Yessey! 12.12 €", * // "1.0.1":"Yessey! 12.12 €", * // "1.1.0":"Yessey! 22.12 €", * // "1.1.1":"Yessey! 22.12 €", * // "1.2.0":"Yessey! 32.12 €", * // "1.2.1":"Yessey! 32.12 €", * // "1.3.0":"Yessey! 42.12 €", * // "1.3.1":"Yessey! 42.12 €" * // } * * @callback Monster.Data~exampleSelectorCallback * @param {*} subject subject * @return Map * @license AGPLv3 * @since 1.17.0 * @memberOf Monster.Data * @see {@link Monster.Data.buildMap} */ /** * @private * @param {*} subject * @param {string|undefined} definition * @param {*} defaultValue * @return {*} */ function build(subject, definition, defaultValue) { if (definition === undefined) return defaultValue ? defaultValue : subject; validateString(definition); const regexp = /(?<placeholder>\${(?<path>[a-z\^A-Z.\-_0-9]*)})/gm; const array = [...definition.matchAll(regexp)]; const finder = new Pathfinder(subject); if (array.length === 0) { return finder.getVia(definition); } array.forEach((a) => { const groups = a?.["groups"]; const placeholder = groups?.["placeholder"]; if (placeholder === undefined) return; const path = groups?.["path"]; let v = finder.getVia(path); if (v === undefined) v = defaultValue; definition = definition.replaceAll(placeholder, v); }); return definition; }