collection/variable-scope.js

var _ = require('../util').lodash,
    Property = require('./property').Property,
    PropertyBase = require('./property-base').PropertyBase,
    VariableList = require('./variable-list').VariableList,
    MutationTracker = require('./mutation-tracker').MutationTracker,

    /**
     * Known variable mutation types.
     *
     * @private
     * @constant
     * @type {Object}
     */
    MUTATIONS = {
        SET: 'set',
        UNSET: 'unset'
    },

    VariableScope;

/**
 * Environment and Globals of postman is exported and imported in a specified data structure. This data structure can be
 * passed on to the constructor parameter of {@link VariableScope} or {@link VariableList} to instantiate an instance of
 * the same with pre-populated values from arguments.
 *
 * @typedef VariableScope.definition
 * @property {String} [id] ID of the scope
 * @property {String} [name] A name of the scope
 * @property {Array.<Variable.definition>} [values] A list of variables defined in an array in form of `{name:String,
 * value:String}`
 *
 * @example <caption>JSON definition of a VariableScope (environment, globals, etc)</caption>
 * {
 *   "name": "globals",
 *   "values": [{
 *     "key": "var-1",
 *     "value": "value-1"
 *   }, {
 *     "key": "var-2",
 *     "value": "value-2"
 *   }]
 * }
 */
_.inherit((

    /**
     * VariableScope is a representation of a list of variables in Postman, such as the environment variables or the
     * globals. Using this object, it is easy to perform operations on this list of variables such as get a variable or
     * set a variable.
     *
     * @constructor
     * @extends {Property}
     *
     * @param {VariableScope.definition} definition The constructor accepts an initial set of values for initialising
     * the scope
     * @param {Array<VariableList>=} layers Additional parent scopes to search for and resolve variables
     *
     * @example <caption>Load a environment from file, modify and save back</caption>
     * var fs = require('fs'), // assuming NodeJS
     *     env,
     *     sum;
     *
     * // load env from file assuming it has initial data
     * env = new VariableScope(JSON.parse(fs.readFileSync('./my-postman-environment.postman_environment').toString()));
     *
     * // get two variables and add them
     * sum = env.get('one-var') + env.get('another-var');
     *
     * // save it back in environment and write to file
     * env.set('sum', sum, 'number');
     * fs.writeFileSync('./sum-of-vars.postman_environment', JSON.stringify(env.toJSON()));
     */
    VariableScope = function PostmanVariableScope (definition, layers) {
        // in case the definition is an array (legacy format) or existing as list, we convert to actual format
        if (_.isArray(definition) || VariableList.isVariableList(definition)) {
            definition = { values: definition };
        }

        // we accept parent scopes to increase search area. Here we normalize the argument to be an array
        // so we can easily loop though them and add them to the instance.
        layers && !_.isArray(layers) && (layers = [layers]);

        // this constructor is intended to inherit and as such the super constructor is required to be executed
        VariableScope.super_.call(this, definition);

        var values = definition && definition.values, // access the values (need this var to reuse access)

            // enable mutation tracking if `mutations` are in definition (restore the state)
            // or is enabled  through options
            mutations = definition && definition.mutations,
            ii,
            i;

        /**
         * @memberof VariableScope.prototype
         * @type {VariableList}
         */
        this.values = new VariableList(this, VariableList.isVariableList(values) ? values.toJSON() : values);
        // in above line, we clone the values if it is already a list. there is no point directly using the instance of
        // a variable list since one cannot be created with a parent reference to begin with.

        if (layers) {
            this._layers = [];

            for (i = 0, ii = layers.length; i < ii; i++) {
                VariableList.isVariableList(layers[i]) && this._layers.push(layers[i]);
            }
        }

        // restore previously tracked mutations
        if (mutations) {
            this.mutations = new MutationTracker(mutations);
        }
    }), Property);

/**
 * @note Handling disabled and duplicate variables:
 * | method | single enabled    | single disabled | with duplicates                                                    |
 * |--------|-------------------|-----------------|------------------------------------------------------------------- |
 * | has    | true              | false           | true (if last enabled) OR false (if all disabled)                  |
 * | get    | {Variable}        | undefined       | last enabled {Variable} OR undefined (if all disabled)             |
 * | set    | update {Variable} | new {Variable}  | update last enabled {Variable} OR new {Variable} (if all disabled) |
 * | unset  | delete {Variable} | noop            | delete all enabled {Variable}                                      |
 *
 * @todo Expected behavior of `unset` with duplicates:
 * delete last enabled {Variable} and update the reference with last enabled in rest of the list.
 * This requires unique identifier in the variable list for mutations to work correctly.
 */
_.assign(VariableScope.prototype, /** @lends VariableScope.prototype */ {
    /**
     * Defines whether this property instances requires an id
     *
     * @private
     * @readOnly
     * @type {String}
     */
    _postman_propertyRequiresId: true,

    /**
     * @private
     * @deprecated discontinued in v4.0
     */
    variables: function () {
        // eslint-disable-next-line max-len
        throw new Error('`VariableScope#variables` has been discontinued, use `VariableScope#syncVariablesTo` instead.');
    },

    /**
     * Converts a list of Variables into an object where key is `_postman_propertyIndexKey` and value is determined
     * by the `valueOf` function
     *
     * @param {Boolean} excludeDisabled -
     * @param {Boolean} caseSensitive -
     * @returns {Object}
     */
    toObject: function (excludeDisabled, caseSensitive) {
        // if the scope has no layers, we simply export the contents of primary store
        if (!this._layers) {
            return this.values.toObject(excludeDisabled, caseSensitive);
        }

        var mergedLayers = {};

        _.forEachRight(this._layers, function (layer) {
            _.assign(mergedLayers, layer.toObject(excludeDisabled, caseSensitive));
        });

        return _.assign(mergedLayers, this.values.toObject(excludeDisabled, caseSensitive));
    },

    /**
     * Determines whether one particular variable is defined in this scope of variables.
     *
     * @param {String} key - The name of the variable to check
     * @returns {Boolean} - Returns true if an enabled variable with given key is present in current or parent scopes,
     *                      false otherwise
     */
    has: function (key) {
        var variable = this.values.oneNormalizedVariable(key),
            i,
            ii;

        // if a variable is disabled or does not exist in local scope,
        // we search all the layers and return the first occurrence.
        if ((!variable || variable.disabled === true) && this._layers) {
            for (i = 0, ii = this._layers.length; i < ii; i++) {
                variable = this._layers[i].oneNormalizedVariable(key);
                if (variable && variable.disabled !== true) { break; }
            }
        }

        return Boolean(variable && variable.disabled !== true);
    },

    /**
     * Fetches a variable from the current scope or from parent scopes if present.
     *
     * @param {String} key - The name of the variable to get.
     * @returns {*} The value of the specified variable across scopes.
     */
    get: function (key) {
        var variable = this.values.oneNormalizedVariable(key),
            i,
            ii;

        // if a variable does not exist in local scope, we search all the layers and return the first occurrence.
        if ((!variable || variable.disabled === true) && this._layers) {
            for (i = 0, ii = this._layers.length; i < ii; i++) {
                variable = this._layers[i].oneNormalizedVariable(key);
                if (variable && variable.disabled !== true) { break; }
            }
        }

        return (variable && variable.disabled !== true) ? variable.valueOf() : undefined;
    },

    /**
     * Creates a new variable, or updates an existing one.
     *
     * @param {String} key - The name of the variable to set.
     * @param {*} value - The value of the variable to be set.
     * @param {Variable.types} [type] - Optionally, the value of the variable can be set to a type
     */
    set: function (key, value, type) {
        var variable = this.values.oneNormalizedVariable(key),

            // create an object that will be used as setter
            update = { key, value };

        _.isString(type) && (update.type = type);

        // If a variable by the name key exists, update it's value and return.
        // @note adds new variable if existing is disabled. Disabled variables are not updated.
        if (variable && !variable.disabled) {
            variable.update(update);
        }
        else {
            this.values.add(update);
        }

        // track the change if mutation tracking is enabled
        this._postman_enableTracking && this.mutations.track(MUTATIONS.SET, key, value);
    },

    /**
     * Removes the variable with the specified name.
     *
     * @param {String} key -
     */
    unset: function (key) {
        var lastDisabledVariable;

        this.values.remove(function (variable) {
            // bail out if variable name didn't match
            if (variable.key !== key) {
                return false;
            }

            // don't delete disabled variables
            if (variable.disabled) {
                lastDisabledVariable = variable;

                return false;
            }

            // delete all enabled variables
            return true;
        });

        // restore the reference with the last disabled variable
        if (lastDisabledVariable) {
            this.values.reference[key] = lastDisabledVariable;
        }

        // track the change if mutation tracking is enabled
        this._postman_enableTracking && this.mutations.track(MUTATIONS.UNSET, key);
    },

    /**
     * Removes *all* variables from the current scope. This is a destructive action.
     */
    clear: function () {
        var mutations = this.mutations;

        // track the change if mutation tracking is enabled
        // do this before deleting the keys
        if (this._postman_enableTracking) {
            this.values.each(function (variable) {
                mutations.track(MUTATIONS.UNSET, variable.key);
            });
        }

        this.values.clear();
    },

    /**
     * Replace all variable names with their values in the given template.
     *
     * @param {String|Object} template - A string or an object to replace variables in
     * @returns {String|Object} The string or object with variables (if any) substituted with their values
     */
    replaceIn: function (template) {
        if (_.isString(template) || _.isArray(template)) {
            // convert template to object because replaceSubstitutionsIn only accepts objects
            var result = Property.replaceSubstitutionsIn({ template }, _.concat(this.values, this._layers));

            return result.template;
        }

        if (_.isObject(template)) {
            return Property.replaceSubstitutionsIn(template, _.concat(this.values, this._layers));
        }

        return template;
    },

    /**
     * Enable mutation tracking.
     *
     * @note: Would do nothing if already enabled.
     * @note: Any previously tracked mutations would be reset when starting a new tracking session.
     *
     * @param {MutationTracker.definition} [options] Options for Mutation Tracker. See {@link MutationTracker}
     */
    enableTracking: function (options) {
        // enabled already, do nothing
        if (this._postman_enableTracking) {
            return;
        }

        /**
         * Controls if mutation tracking is enabled
         *
         * @memberof VariableScope.prototype
         *
         * @private
         * @property {Boolean}
         */
        this._postman_enableTracking = true;

        // we don't want to add more mutations to existing mutations
        // that will lead to mutations not capturing the correct state
        // so we reset this with the new instance
        this.mutations = new MutationTracker(options);
    },

    /**
     * Disable mutation tracking.
     */
    disableTracking: function () {
        // disable further tracking but keep the tracked mutations
        this._postman_enableTracking = false;
    },

    /**
     * Apply a mutation instruction on this variable scope.
     *
     * @private
     * @param {String} instruction Instruction identifying the type of the mutation, e.g. `set`, `unset`
     * @param {String} key -
     * @param {*} value -
     */
    applyMutation: function (instruction, key, value) {
        // we know that `set` and `unset` are the only supported instructions
        // and we know the parameter signature of both is the same as the items in a mutation
        /* istanbul ignore else */
        if (this[instruction]) {
            this[instruction](key, value);
        }
    },

    /**
     * Using this function, one can sync the values of this variable list from a reference object.
     *
     * @private
     * @param {Object} obj -
     * @param {Boolean=} [track] -
     * @returns {Object}
     */
    syncVariablesFrom: function (obj, track) {
        return this.values.syncFromObject(obj, track);
    },

    /**
     * Transfer the variables in this scope to an object
     *
     * @private
     * @param {Object=} [obj] -
     * @returns {Object}
     */
    syncVariablesTo: function (obj) {
        return this.values.syncToObject(obj);
    },

    /**
     * Convert this variable scope into a JSON serialisable object. Useful to transport or store, environment and
     * globals as a whole.
     *
     * @returns {Object}
     */
    toJSON: function () {
        var obj = PropertyBase.toJSON(this);

        // @todo - remove this when pluralisation is complete
        if (obj.value) {
            obj.values = obj.value;
            delete obj.value;
        }

        // ensure that the concept of layers is not exported as JSON. JSON cannot retain references and this will end up
        // being a pointless object post JSONification.
        if (obj._layers) {
            delete obj._layers;
        }

        // ensure that tracking flag is not serialized
        // otherwise, it is very easy to let tracking trickle to many instances leading to a snowball effect
        if (obj._postman_enableTracking) {
            delete obj._postman_enableTracking;
        }

        return obj;
    },

    /**
     * Adds a variable list to the current instance in order to increase the surface area of variable resolution.
     * This enables consumers to search across scopes (eg. environment and globals).
     *
     * @private
     * @param {VariableList} [list] -
     */
    addLayer: function (list) {
        if (!VariableList.isVariableList(list)) {
            return;
        }

        !this._layers && (this._layers = []); // lazily initialize layers
        this._layers.push(list);
    }
});

_.assign(VariableScope, /** @lends VariableScope */ {
    /**
     * Defines the name of this property for internal use.
     *
     * @private
     * @readOnly
     * @type {String}
     *
     * @note that this is directly accessed only in case of VariableScope from _.findValue lodash util mixin
     */
    _postman_propertyName: 'VariableScope',

    /**
     * Check whether an object is an instance of {@link VariableScope}.
     *
     * @param {*} obj -
     * @returns {Boolean}
     */
    isVariableScope: function (obj) {
        return Boolean(obj) && ((obj instanceof VariableScope) ||
            _.inSuperChain(obj.constructor, '_postman_propertyName', VariableScope._postman_propertyName));
    }
});

module.exports = {
    VariableScope
};