var _ = require('../util').lodash, PropertyBase = require('./property-base').PropertyBase, __PARENT = '__parent', DEFAULT_INDEX_ATTR = 'id', DEFAULT_INDEXCASE_ATTR = false, DEFAULT_INDEXMULTI_ATTR = false, PropertyList; /** * An item constructed of PropertyList.Type. * * @typedef {Object} PropertyList.Type */ _.inherit(( /** * @constructor * @param {Function} type - * @param {Object} parent - * @param {Array} populate - */ PropertyList = function PostmanPropertyList (type, parent, populate) { // @todo add this test sometime later // if (!type) { // throw new Error('postman-collection: cannot initialise a list without a type parameter'); // } PropertyList.super_.call(this); // call super with appropriate options this.setParent(parent); // save reference to parent _.assign(this, /** @lends PropertyList.prototype */ { /** * @private * @type {Array} */ members: this.members || [], /** * @private * @type {Object} * @note This should not be used, and it's not guaranteed to be in sync with the actual list of members. */ reference: this.reference || {}, /** * @private * @type {Function} */ Type: type }); // if the type this list holds has its own index key, then use the same _.getOwn(type, '_postman_propertyIndexKey') && (this._postman_listIndexKey = type._postman_propertyIndexKey); // if the type has case sensitivity flags, set the same _.getOwn(type, '_postman_propertyIndexCaseInsensitive') && (this._postman_listIndexCaseInsensitive = type._postman_propertyIndexCaseInsensitive); // if the type allows multiple values, set the flag _.getOwn(type, '_postman_propertyAllowsMultipleValues') && (this._postman_listAllowsMultipleValues = type._postman_propertyAllowsMultipleValues); // prepopulate populate && this.populate(populate); }), PropertyBase); _.assign(PropertyList.prototype, /** @lends PropertyList.prototype */ { /** * Indicates that this element contains a number of other elements. * * @private */ _postman_propertyIsList: true, /** * Holds the attribute to index this PropertyList by. Default: 'id' * * @private * @type {String} */ _postman_listIndexKey: DEFAULT_INDEX_ATTR, /** * Holds the attribute whether indexing of this list is case sensitive or not * * @private * @type {String} */ _postman_listIndexCaseInsensitive: DEFAULT_INDEXCASE_ATTR, /** * Holds the attribute whether exporting the index retains duplicate index items * * @private * @type {String} */ _postman_listAllowsMultipleValues: DEFAULT_INDEXMULTI_ATTR, /** * Insert an element at the end of this list. When a reference member specified via second parameter is found, the * member is inserted at an index before the reference member. * * @param {PropertyList.Type} item - * @param {PropertyList.Type|String} [before] - */ insert: function (item, before) { if (!_.isObject(item)) { return; } // do not proceed on empty param var duplicate = this.indexOf(item), index; // remove from previous list PropertyList.isPropertyList(item[__PARENT]) && (item[__PARENT] !== this) && item[__PARENT].remove(item); // inject the parent reference _.assignHidden(item, __PARENT, this); // ensure that we do not double insert things into member array (duplicate > -1) && this.members.splice(duplicate, 1); // find the position of the reference element before && (before = this.indexOf(before)); // inject to the members array ata position or at the end in case no item is there for reference (before > -1) ? this.members.splice(before, 0, item) : this.members.push(item); // store reference by id, so create the index string. we first ensure that the index value is truthy and then // recheck that the string conversion of the same is truthy as well. if ((index = item[this._postman_listIndexKey]) && (index = String(index))) { // desensitise case, if the property needs it to be this._postman_listIndexCaseInsensitive && (index = index.toLowerCase()); // if multiple values are allowed, the reference may contain an array of items, mapped to an index. if (this._postman_listAllowsMultipleValues && Object.hasOwnProperty.call(this.reference, index)) { // if the value is not an array, convert it to an array. !_.isArray(this.reference[index]) && (this.reference[index] = [this.reference[index]]); // add the item to the array of items corresponding to this index this.reference[index].push(item); } else { this.reference[index] = item; } } }, /** * Insert an element at the end of this list. When a reference member specified via second parameter is found, the * member is inserted at an index after the reference member. * * @param {PropertyList.Type} item - * @param {PropertyList.Type|String} [after] - */ insertAfter: function (item, after) { // convert item to positional reference return this.insert(item, this.idx(this.indexOf(after) + 1)); }, /** * Adds or moves an item to the end of this list. * * @param {PropertyList.Type} item - */ append: function (item) { return this.insert(item); }, /** * Adds or moves an item to the beginning of this list. * * @param {PropertyList.Type} item - */ prepend: function (item) { return this.insert(item, this.idx(0)); }, /** * Add an item or item definition to this list. * * @param {Object|PropertyList.Type} item - * @todo * - remove item from original parent if already it has a parent * - validate that the original parent's constructor matches this parent's constructor */ add: function (item) { // do not proceed on empty param, but empty strings are in fact valid. // eslint-disable-next-line lodash/prefer-is-nil if (_.isNull(item) || _.isUndefined(item) || _.isNaN(item)) { return; } // create new instance of the item based on the type specified if it is not already this.insert((item.constructor === this.Type) ? item : // if the property has a create static function, use it. // eslint-disable-next-line prefer-spread (_.has(this.Type, 'create') ? this.Type.create.apply(this.Type, arguments) : new this.Type(item))); }, /** * Add an item or update an existing item * * @param {PropertyList.Type} item - * @returns {?Boolean} */ upsert: function (item) { // do not proceed on empty param, but empty strings are in fact valid. if (_.isNil(item) || _.isNaN(item)) { return null; } var indexer = this._postman_listIndexKey, existing = this.one(item[indexer]); if (existing) { if (!_.isFunction(existing.update)) { throw new Error('collection: unable to upsert into a list of Type that does not support .update()'); } existing.update(item); return false; } // since there is no existing item, just add a new one this.add(item); return true; // indicate added }, /** * Removes all elements from the PropertyList for which the predicate returns truthy. * * @param {Function|String|PropertyList.Type} predicate - * @param {Object} context Optional context to bind the predicate to. */ remove: function (predicate, context) { var match; // to be used if predicate is an ID !context && (context = this); if (_.isString(predicate)) { // if predicate is id, then create a function to remove that // need to take care of case sensitivity as well :/ match = this._postman_listIndexCaseInsensitive ? predicate.toLowerCase() : predicate; predicate = function (item) { var id = item[this._postman_listIndexKey]; this._postman_listIndexCaseInsensitive && (id = id.toLowerCase()); return id === match; }.bind(this); } else if (predicate instanceof this.Type) { // in case an object reference is sent, prepare it for removal using direct reference comparison match = predicate; predicate = function (item) { return (item === match); }; } _.isFunction(predicate) && _.remove(this.members, function (item) { var index; if (predicate.apply(context, arguments)) { if ((index = item[this._postman_listIndexKey]) && (index = String(index))) { this._postman_listIndexCaseInsensitive && (index = index.toLowerCase()); if (this._postman_listAllowsMultipleValues && _.isArray(this.reference[index])) { // since we have an array of multiple values, remove only the value for which the // predicate returned truthy. If the array becomes empty, just delete it. _.remove(this.reference[index], function (each) { return each === item; }); // If the array becomes empty, remove it /* istanbul ignore next */ (this.reference[index].length === 0) && (delete this.reference[index]); // If the array contains only one element, remove the array, and assign the element // as the reference value (this.reference[index].length === 1) && (this.reference[index] = this.reference[index][0]); } else { delete this.reference[index]; } } delete item[__PARENT]; // unlink from its parent return true; } }.bind(this)); }, /** * Removes all items in the list */ clear: function () { // we unlink every member from it's parent (assuming this is their parent) this.all().forEach(PropertyList._unlinkItemFromParent); this.members.length = 0; // remove all items from list // now we remove all items from index reference Object.keys(this.reference).forEach(function (key) { delete this.reference[key]; }.bind(this)); }, /** * Load one or more items * * @param {Object|Array} items - */ populate: function (items) { // if Type supports parsing of string headers then do it before adding it. _.isString(items) && _.isFunction(this.Type.parse) && (items = this.Type.parse(items)); // add a single item or an array of items. _.forEach(_.isArray(items) ? items : // if population is not an array, we send this as single item in an array or send each property separately // if the core Type supports Type.create ((_.isPlainObject(items) && _.has(this.Type, 'create')) ? items : [items]), this.add.bind(this)); }, /** * Clears the list and adds new items. * * @param {Object|Array} items - */ repopulate: function (items) { this.clear(); this.populate(items); }, /** * Add or update values from a source list. * * @param {PropertyList|Array} source - * @param {Boolean} [prune=false] Setting this to `true` will cause the extra items from the list to be deleted */ assimilate: function (source, prune) { var members = PropertyList.isPropertyList(source) ? source.members : source, list = this, indexer = list._postman_listIndexKey, sourceKeys = {}; // keeps track of added / updated keys for later exclusion if (!_.isArray(members)) { return; } members.forEach(function (item) { /* istanbul ignore if */ if (!(item && _.has(item, indexer))) { return; } list.upsert(item); sourceKeys[item[indexer]] = true; }); // now remove any variable that is not in source object // @note - using direct `this.reference` list of keys here so that we can mutate the list while iterating // on it if (prune) { _.forEach(list.reference, function (value, key) { if (_.has(sourceKeys, key)) { return; } // de not delete if source obj has this variable list.remove(key); // use PropertyList functions to remove so that the .members array is cleared too }); } }, /** * Returns a map of all items. * * @returns {Object} */ all: function () { return _.clone(this.members); }, /** * Get Item in this list by `ID` reference. If multiple values are allowed, the last value is returned. * * @param {String} id - * @returns {PropertyList.Type} */ one: function (id) { var val = this.reference[this._postman_listIndexCaseInsensitive ? String(id).toLowerCase() : id]; if (this._postman_listAllowsMultipleValues && Array.isArray(val)) { return val.length ? val[val.length - 1] : /* istanbul ignore next */ undefined; } return val; }, /** * Get the value of an item in this list. This is similar to {@link PropertyList.one} barring the fact that it * returns the value of the underlying type of the list content instead of the item itself. * * @param {String|Function} key - * @returns {PropertyList.Type|*} */ get: function (key) { var member = this.one(key); if (!member) { return; } // eslint-disable-line getter-return return member.valueOf(); }, /** * Iterate on each item of this list. * * @param {Function} iterator - * @param {Object} context - */ each: function (iterator, context) { _.forEach(this.members, _.isFunction(iterator) ? iterator.bind(context || this.__parent) : iterator); }, /** * @param {Function} rule - * @param {Object} context - */ filter: function (rule, context) { return _.filter(this.members, _.isFunction(rule) && _.isObject(context) ? rule.bind(context) : rule); }, /** * Find an item within the item group * * @param {Function} rule - * @param {Object} [context] - * @returns {Item|ItemGroup} */ find: function (rule, context) { return _.find(this.members, _.isFunction(rule) && _.isObject(context) ? rule.bind(context) : rule); }, /** * Iterates over the property list. * * @param {Function} iterator Function to call on each item. * @param {Object} context Optional context, defaults to the PropertyList itself. */ map: function (iterator, context) { return _.map(this.members, _.isFunction(iterator) ? iterator.bind(context || this) : iterator); }, /** * Iterates over the property list and accumulates the result. * * @param {Function} iterator Function to call on each item. * @param {*} accumulator Accumulator initial value * @param {Object} context Optional context, defaults to the PropertyList itself. */ reduce: function (iterator, accumulator, context) { return _.reduce(this.members, _.isFunction(iterator) ? iterator.bind(context || this) : /* istanbul ignore next */ iterator , accumulator); }, /** * Returns the length of the PropertyList * * @returns {Number} */ count: function () { return this.members.length; }, /** * Get a member of this list by it's index * * @param {Number} index - * @returns {PropertyList.Type} */ idx: function (index) { return this.members[index]; }, /** * Find the index of an item in this list * * @param {String|Object} item - * @returns {Number} */ indexOf: function (item) { return this.members.indexOf(_.isString(item) ? (item = this.one(item)) : item); }, /** * Check whether an item exists in this list * * @param {String|PropertyList.Type} item - * @param {*=} value - * @returns {Boolean} */ has: function (item, value) { var match, val, i; match = _.isString(item) ? this.reference[this._postman_listIndexCaseInsensitive ? item.toLowerCase() : item] : this.filter(function (member) { return member === item; }); // If we don't have a match, there's nothing to do if (!match) { return false; } // if no value is provided, just check if item exists if (arguments.length === 1) { return Boolean(_.isArray(match) ? match.length : match); } // If this property allows multiple values and we get an array, we need to iterate through it and see // if any element matches. if (this._postman_listAllowsMultipleValues && _.isArray(match)) { for (i = 0; i < match.length; i++) { // use the value of the current element val = _.isFunction(match[i].valueOf) ? match[i].valueOf() : /* istanbul ignore next */ match[i]; if (val === value) { return true; } } // no matches were found, so return false here. return false; } // We didn't have an array, so just check if the matched value equals the provided value. _.isFunction(match.valueOf) && (match = match.valueOf()); return match === value; }, /** * Iterates over all parents of the property list * * @param {Function} iterator - * @param {Object=} [context] - */ eachParent: function (iterator, context) { // validate parameters if (!_.isFunction(iterator)) { return; } !context && (context = this); var parent = this.__parent, prev; // iterate till there is no parent while (parent) { // call iterator with the parent and previous parent iterator.call(context, parent, prev); // update references prev = parent; parent = parent.__parent; } }, /** * Converts a list of Properties into an object where key is `_postman_propertyIndexKey` and value is determined * by the `valueOf` function * * @param {?Boolean} [excludeDisabled=false] - When set to true, disabled properties are excluded from the resultant * object. * @param {?Boolean} [caseSensitive] - When set to true, properties are treated strictly as per their original * case. The default value for this property also depends on the case insensitivity definition of the current * property. * @param {?Boolean} [multiValue=false] - When set to true, only the first value of a multi valued property is * returned. * @param {Boolean} [sanitizeKeys=false] - When set to true, properties with falsy keys are removed. * @todo Change the function signature to an object of options instead of the current structure. * @returns {Object} */ toObject: function (excludeDisabled, caseSensitive, multiValue, sanitizeKeys) { var obj = {}, // create transformation data accumulator // gather all the switches of the list key = this._postman_listIndexKey, sanitiseKeys = this._postman_sanitizeKeys || sanitizeKeys, sensitive = !this._postman_listIndexCaseInsensitive || caseSensitive, multivalue = this._postman_listAllowsMultipleValues || multiValue; // iterate on each member to create the transformation object this.each(function (member) { // Bail out for the current member if ANY of the conditions below is true: // 1. The member is falsy. // 2. The member does not have the specified property list index key. // 3. The member is disabled and disabled properties have to be ignored. // 4. The member has a falsy key, and sanitize is true. if (!member || !_.has(member, key) || (excludeDisabled && member.disabled) || (sanitiseKeys && !member[key])) { return; } // based on case sensitivity settings, we get the property name of the item var prop = sensitive ? member[key] : String(member[key]).toLowerCase(); // now, if transformation object already has a member with same property name, we either overwrite it or // append to an array of values based on multi-value support if (multivalue && _.has(obj, prop)) { (!Array.isArray(obj[prop])) && (obj[prop] = [obj[prop]]); obj[prop].push(member.valueOf()); } else { obj[prop] = member.valueOf(); } }); return obj; }, /** * Adds ability to convert a list to a string provided it's underlying format has unparse function defined. * * @returns {String} */ toString: function () { if (this.Type.unparse) { return this.Type.unparse(this.members); } return this.constructor ? this.constructor.prototype.toString.call(this) : ''; }, toJSON: function () { if (!this.count()) { return []; } return _.map(this.members, function (member) { // use member.toJSON if it exists if (!_.isEmpty(member) && _.isFunction(member.toJSON)) { return member.toJSON(); } return _.reduce(member, function (accumulator, value, key) { if (value === undefined) { // true/false/null need to be preserved. return accumulator; } // Handle plurality of PropertyLists in the SDK vs the exported JSON. // Basically, removes the trailing "s" from key if the value is a property list. if (value && value._postman_propertyIsList && !value._postman_proprtyIsSerialisedAsPlural && _.endsWith(key, 's')) { key = key.slice(0, -1); } // Handle 'PropertyBase's if (value && _.isFunction(value.toJSON)) { accumulator[key] = value.toJSON(); return accumulator; } // Handle Strings if (_.isString(value)) { accumulator[key] = value; return accumulator; } // Everything else accumulator[key] = _.cloneElement(value); return accumulator; }, {}); }); } }); _.assign(PropertyList, /** @lends PropertyList */ { /** * Defines the name of this property for internal use. * * @private * @readOnly * @type {String} */ _postman_propertyName: 'PropertyList', /** * Removes child-parent links for the provided PropertyList member. * * @param {Property} item - The property for which to perform parent de-linking. * @private */ _unlinkItemFromParent: function (item) { item.__parent && (delete item.__parent); // prevents V8 from making unnecessary look-ups if there is no __parent }, /** * Checks whether an object is a PropertyList * * @param {*} obj - * @returns {Boolean} */ isPropertyList: function (obj) { return Boolean(obj) && ((obj instanceof PropertyList) || _.inSuperChain(obj.constructor, '_postman_propertyName', PropertyList._postman_propertyName)); } }); module.exports = { PropertyList };