collection/mutation-tracker.js

var _ = require('../util').lodash,
    PropertyBase = require('./property-base').PropertyBase,

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

    /**
     * Detects if the mutation is a primitive mutation type. A primitive mutation is the simplified mutation structure.
     *
     * @private
     * @param {MutationTracker.mutation} mutation -
     * @returns {Boolean}
     */
    isPrimitiveMutation = function (mutation) {
        return mutation && mutation.length <= 2;
    },

    /**
     * Applies a single mutation on a target.
     *
     * @private
     * @param {*} target -
     * @param {MutationTracker.mutation} mutation -
     */
    applyMutation = function applyMutation (target, mutation) {
        // only `set` and `unset` instructions are supported
        // for non primitive mutations, the instruction would have to be extracted from mutation
        /* istanbul ignore if */
        if (!isPrimitiveMutation(mutation)) {
            return;
        }

        // extract instruction from the mutation
        var operation = mutation.length > 1 ? PRIMITIVE_MUTATIONS.SET : PRIMITIVE_MUTATIONS.UNSET;

        // now hand over applying mutation to the target
        target.applyMutation(operation, ...mutation);
    },

    MutationTracker;

/**
 * A JSON representation of a mutation on an object. Here objects mean instances of postman-collection classes.
 * This captures the instruction and the parameters of the instruction so that it can be replayed on a different object.
 * Mutations can be any change on an object. For example setting a key or unsetting a key.
 *
 * For example, the mutation to set `name` on an object to 'Bruce Wayne' would look like ['name', 'Bruce Wayne']. Where
 * the first item is the key path and second item is the value. To add a property `punchLine` to the object it would be
 * the same as updating the property i.e. ['punchLine', 'I\'m Batman']. To remove a property `age` the mutation would
 * look like ['age'].
 *
 * This format of representing changes is derived from
 * {@link http://json-delta.readthedocs.io/en/latest/philosophy.html}.
 *
 * The `set` and `unset` are primitive instructions and can be derived from the mutation without explicitly stating the
 * instruction. For more complex mutation the instruction would have to be explicitly stated.
 *
 * @typedef {Array} MutationTracker.mutation
 */

/**
 * A JSON representation of the MutationTracker.
 *
 * @typedef MutationTracker.definition
 *
 * @property {Array} stream contains the stream mutations tracked
 * @property {Object} compacted contains a compacted version of the mutations
 * @property {Boolean} [autoCompact=false] when set to true, all new mutations would be compacted immediately
 */
_.inherit((

    /**
     * A MutationTracker allows to record mutations on any of object and store them. This stored mutations can be
     * transported for reporting or to replay on similar objects.
     *
     * @constructor
     * @extends {PropertyBase}
     *
     * @param {MutationTracker.definition} definition serialized mutation tracker
     */
    MutationTracker = function MutationTracker (definition) {
        // this constructor is intended to inherit and as such the super constructor is required to be executed
        MutationTracker.super_.call(this, definition);

        definition = definition || {};

        // initialize options
        this.autoCompact = Boolean(definition.autoCompact);

        // restore mutations
        this.stream = Array.isArray(definition.stream) ? definition.stream : [];
        this.compacted = _.isPlainObject(definition.compacted) ? definition.compacted : {};
    }), PropertyBase);

_.assign(MutationTracker.prototype, /** @lends MutationTracker.prototype */ {

    /**
     * Records a new mutation.
     *
     * @private
     * @param {MutationTracker.mutation} mutation -
     */
    addMutation (mutation) {
        // bail out for empty or unsupported mutations
        if (!(mutation && isPrimitiveMutation(mutation))) {
            return;
        }

        // if autoCompact is set, we need to compact while adding
        if (this.autoCompact) {
            this.addAndCompact(mutation);

            return;
        }

        // otherwise just push to the stream of mutations
        this.stream.push(mutation);
    },

    /**
     * Records a mutation compacting existing mutations for the same key path.
     *
     * @private
     * @param {MutationTracker.mutation} mutation -
     */
    addAndCompact (mutation) {
        // for `set` and `unset` mutations the key to compact with is the `keyPath`
        var key = mutation[0];

        // convert `keyPath` to a string
        key = Array.isArray(key) ? key.join('.') : key;

        this.compacted[key] = mutation;
    },

    /**
     * Track a mutation.
     *
     * @param {String} instruction the type of mutation
     * @param {...*} payload mutation parameters
     */
    track (instruction, ...payload) {
        // invalid call
        if (!(instruction && payload)) {
            return;
        }

        // unknown instruction
        if (!(instruction === PRIMITIVE_MUTATIONS.SET || instruction === PRIMITIVE_MUTATIONS.UNSET)) {
            return;
        }

        // for primitive mutations the arguments form the mutation object
        // if there is more complex mutation, we have to use a processor to create a mutation for the instruction
        this.addMutation(payload);
    },

    /**
     * Compacts the recorded mutations removing duplicate mutations that apply on the same key path.
     */
    compact () {
        // for each of the mutation, add to compacted list
        this.stream.forEach(this.addAndCompact.bind(this));

        // reset the `stream`, all the mutations are now recorded in the `compacted` storage
        this.stream = [];
    },

    /**
     * Returns the number of mutations tracked so far.
     *
     * @returns {Number}
     */
    count () {
        // the total count of mutations is the sum of
        // mutations in the stream
        var mutationCount = this.stream.length;

        // and the compacted mutations
        mutationCount += Object.keys(this.compacted).length;

        return mutationCount;
    },

    /**
     * Applies all the recorded mutations on a target object.
     *
     * @param {*} target Target to apply mutations. Must implement `applyMutation`.
     */
    applyOn (target) {
        if (!(target && target.applyMutation)) {
            return;
        }

        var applyIndividualMutation = function applyIndividualMutation (mutation) {
            applyMutation(target, mutation);
        };

        // mutations move from `stream` to `compacted`, so we apply the compacted mutations first
        // to ensure FIFO of mutations

        // apply the compacted mutations first
        _.forEach(this.compacted, applyIndividualMutation);

        // apply the mutations in the stream
        _.forEach(this.stream, applyIndividualMutation);
    }
});

_.assign(MutationTracker, /** @lends MutationTracker */ {
    /**
     * Defines the name of this property for internal use.
     *
     * @private
     * @readOnly
     * @type {String}
     */
    _postman_propertyName: 'MutationTracker',

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

module.exports = {
    MutationTracker
};