common/ParameterBag.js

import cloneDeep from 'lodash/cloneDeep.js';
import equal from 'fast-deep-equal';

export const sharedOptions = {
  nullable: false,
  event: false, // if event=true, nullable=true
  metas: {},
  filterChange: true,
  immediate: false,
};

export const types = {
  boolean: {
    required: ['default'],
    get defaultOptions() {
      return Object.assign(cloneDeep(sharedOptions), {});
    },
    coerceFunction: (name, def, value) => {
      if (typeof value !== 'boolean') {
        throw new TypeError(`[SharedState] Invalid value "${value}" for boolean parameter "${name}"`);
      }

      return value;
    },
  },
  string: {
    required: ['default'],
    get defaultOptions() {
      return Object.assign(cloneDeep(sharedOptions), {});
    },
    coerceFunction: (name, def, value) => {
      if (typeof value !== 'string') {
        throw new TypeError(`[SharedState] Invalid value "${value}" for string parameter "${name}"`);
      }

      return value;
    },
  },
  integer: {
    required: ['default'],
    get defaultOptions() {
      return Object.assign(cloneDeep(sharedOptions), {
        min: -Infinity,
        max: +Infinity,
      });
    },
    sanitizeSchema: (def) => {
      // sanitize `null` values in received schema, this prevent a bug when
      // `min` and `max` are explicitely set to `±Infinity`, the schema is stringified
      // when sent over the network and therefore Infinity is transformed to `null`
      //
      // JSON.parse({ a: Infinity });
      // > { "a": null }
      if (def.min === null) {
        def.min = -Infinity;
      }

      if (def.max === null) {
        def.max = Infinity;
      }

      return def;
    },
    coerceFunction: (name, def, value) => {
      if (!(typeof value === 'number' && Math.floor(value) === value)) {
        throw new TypeError(`[SharedState] Invalid value "${value}" for integer parameter "${name}"`);
      }

      return Math.max(def.min, Math.min(def.max, value));
    },
  },
  float: {
    required: ['default'],
    get defaultOptions() {
      return Object.assign(cloneDeep(sharedOptions), {
        min: -Infinity,
        max: +Infinity,
      });
    },
    sanitizeSchema: (def) => {
      // sanitize `null` values in received schema, this prevent a bug when
      // `min` and `max` are explicitely set to `±Infinity`, the schema is stringified
      // when sent over the network and therefore Infinity is transformed to `null`
      //
      // JSON.parse({ a: Infinity });
      // > { "a": null }
      if (def.min === null) {
        def.min = -Infinity;
      }

      if (def.max === null) {
        def.max = Infinity;
      }

      return def;
    },
    coerceFunction: (name, def, value) => {
      if (typeof value !== 'number' || value !== value) { // reject NaN
        throw new TypeError(`[SharedState] Invalid value "${value}" for float parameter "${name}"`);
      }

      return Math.max(def.min, Math.min(def.max, value));
    },
  },
  enum: {
    required: ['default', 'list'],
    get defaultOptions() {
      return Object.assign(cloneDeep(sharedOptions), {});
    },
    coerceFunction: (name, def, value) => {
      if (def.list.indexOf(value) === -1) {
        throw new TypeError(`[SharedState] Invalid value "${value}" for enum parameter "${name}"`);
      }


      return value;
    },
  },
  any: {
    required: ['default'],
    get defaultOptions() {
      return Object.assign(cloneDeep(sharedOptions), {});
    },
    coerceFunction: (name, def, value) => {
      // no check as it can have any type...
      return value;
    },
  },
};


/**
 * Bag of parameters.
 * @private
 */
class ParameterBag {
  static validateSchema(schema) {
    for (let name in schema) {
      const def = schema[name];

      if (!Object.prototype.hasOwnProperty.call(def, 'type')) {
        throw new TypeError(`[StateManager.registerSchema] Invalid schema definition - param "${name}": "type" key is required`);
      }

      if (!Object.prototype.hasOwnProperty.call(types, def.type)) {
        throw new TypeError(`[StateManager.registerSchema] Invalid schema definition - param "${name}": "{ type: '${def.type}' }" does not exists`);
      }

      const required = types[def.type].required;

      required.forEach(key => {
        if (def.event === true && key === 'default') {
          // do nothing, default is always null for `event` params
        } else if (!Object.prototype.hasOwnProperty.call(def, key)) {
          throw new TypeError(`[StateManager.registerSchema] Invalid schema definition - param "${name}" (type "${def.type}"): "${key}" key is required`);
        }
      });
    }
  }

  constructor(schema, initValues = {}) {
    if (!schema) {
      throw new Error(`[ParameterBag] schema is mandatory`);
    }

    schema = cloneDeep(schema);
    initValues = cloneDeep(initValues);

    /**
     * List of parameters.
     *
     * @type {Object<String, Param>}
     * @name _params
     * @memberof ParameterBag
     * @instance
     * @private
     */
    this._values = {};

    /**
     * List of schema with init values.
     *
     * @type {Object<String, paramDefinition>}
     * @name _schema
     * @memberof ParameterBag
     * @instance
     * @private
     */
    this._schema = {};

    ParameterBag.validateSchema(schema);

    // make shure initValues make sens according to the given schema
    for (let name in initValues) {
      if (!Object.prototype.hasOwnProperty.call(schema, name)) {
        throw new ReferenceError(`[StateManager.create] init value defined for undefined param "${name}"`);
      }
    }

    for (let [name, def] of Object.entries(schema)) {
      if (types[def.type].sanitizeSchema) {
        def = types[def.type].sanitizeSchema(def);
      }

      const { defaultOptions } = types[def.type];
      def = Object.assign({}, defaultOptions, def);
      // if event property is set to true, the param must
      // be nullable and its default value is `undefined`
      if (def.event === true) {
        def.nullable = true;
        def.default = null;
      }

      let initValue;

      if (Object.prototype.hasOwnProperty.call(initValues, name)) {
        initValue = initValues[name];
      } else {
        initValue = def.default;
      }


      this._schema[name] = def;
      // coerce init value and store in definition
      initValue = this.set(name, initValue)[0];

      this._schema[name].initValue = initValue;
      this._values[name] = initValue;
    }
  }

  /**
   * Define if the parameter exists.
   *
   * @param {string} name - Name of the parameter.
   * @return {Boolean}
   */
  has(name) {
    return Object.prototype.hasOwnProperty.call(this._schema, name);
  }

  /**
   * Return values of all parameters as a flat object. If a parameter is of `any`
   * type, a deep copy is made.
   *
   * @return {object}
   */
  getValues() {
    let values = {};

    for (let name in this._values) {
      values[name] = this.get(name);
    }

    return values;
  }

  /**
   * Return values of all parameters as a flat object. Similar to `getValues` but
   * returns a reference to the underlying value in case of `any` type. May be
   * usefull if the underlying value is big (e.g. sensors recordings, etc.) and
   * deep cloning expensive. Be aware that if changes are made on the returned
   * object, the state of your application will become inconsistent.
   *
   * @return {object}
   */
  getValuesUnsafe() {
    let values = {};

    for (let name in this._values) {
      values[name] = this.getUnsafe(name);
    }

    return values;
  }

  /**
   * Return the value of the given parameter. If the parameter is of `any` type,
   * a deep copy is returned.
   *
   * @param {string} name - Name of the parameter.
   * @return {Mixed} - Value of the parameter.
   */
  get(name) {
    if (!this.has(name)) {
      throw new ReferenceError(`[SharedState] Cannot get value of undefined parameter "${name}"`);
    }

    if (this._schema[name].type === 'any') {
      // we return a deep copy of the object as we don't want the client code to
      // be able to modify our underlying data.
      return cloneDeep(this._values[name]);
    } else {
      return this._values[name];
    }
  }

  /**
   * Similar to `get` but returns a reference to the underlying value in case of
   * `any` type. May be usefull if the underlying value is big (e.g. sensors
   * recordings, etc.) and deep cloning expensive. Be aware that if changes are
   * made on the returned object, the state of your application will become
   * inconsistent.
   *
   * @param {string} name - Name of the parameter.
   * @return {Mixed} - Value of the parameter.
   */
  getUnsafe(name) {
    if (!this.has(name)) {
      throw new ReferenceError(`[SharedState] Cannot get value of undefined parameter "${name}"`);
    }

    return this._values[name];
  }

  /**
   * Check that the value is valid according to the schema and return it coerced
   * to the schema definition
   *
   * @param {String} name - Name of the parameter.
   * @param {Mixed} value - Value of the parameter.
   */
  coerceValue(name, value) {
    if (!this.has(name)) {
      throw new ReferenceError(`[SharedState] Cannot set value of undefined parameter "${name}"`);
    }

    const def = this._schema[name];

    if (value === null && def.nullable === false) {
      throw new TypeError(`[SharedState] Invalid value for ${def.type} param "${name}": value is null and param is not nullable`);
    } else if (value === null && def.nullable === true) {
      value = null;
    } else {
      const { coerceFunction } = types[def.type];
      value = coerceFunction(name, def, value);
    }

    return value;
  }

  /**
   * Set the value of a parameter. If the value of the parameter is updated
   * (aka if previous value is different from new value) all registered
   * callbacks are registered.
   *
   * @param {string} name - Name of the parameter.
   * @param {Mixed} value - Value of the parameter.
   * @return {Array} - [new value, updated flag].
   */
  set(name, value) {
    value = this.coerceValue(name, value);
    const currentValue = this._values[name];
    const updated = !equal(currentValue, value);

    // we store a deep copy of the object as we don't want the client to be able
    // to modify our underlying data, which leads to unexpected behavior where the
    // deep equal check to returns true, and therefore the update is not triggered.
    // @see tests/common.state-manager.spec.js
    // 'should copy stored value for "any" type to have a predictable behavior'
    if (this._schema[name].type === 'any') {
      value = cloneDeep(value);
    }

    this._values[name] = value;

    // return tuple so that the state manager can handle the `filterChange` option
    return [value, updated];
  }

  /**
   * Reset a parameter to its initialization values. Reset all parameters if no argument.
   * @note - prefer `state.set(state.getInitValues())`
   *         or     `state.set(state.getDefaultValues())`
   *
   * @param {string} [name=null] - Name of the parameter to reset.
   */
  // reset(name = null) {
  //   if (name !== null) {
  //     this._params[name] = this._initValues[name];
  //   } else {
  //     for (let name in this.params) {
  //       this._params[name].reset();
  //     }
  //   }
  // }

  /**
   * Return the given schema along with the initialization values.
   *
   * @return {object}
   */
  getSchema(name = null) {
    if (name === null) {
      return this._schema;
    }

    if (!this.has(name)) {
      throw new ReferenceError(`[SharedState] Cannot get schema description of undefined parameter "${name}"`);
    }

    return this._schema[name];
  }

  // return the default value, if initValue has been given, return init values
  getInitValues() {
    const initValues = {};

    for (let [name, def] of Object.entries(this._schema)) {
      initValues[name] = def.initValue;
    }

    return initValues;
  }

  // return the default value, if initValue has been given, return init values
  getDefaults() {
    const defaults = {};

    for (let [name, def] of Object.entries(this._schema)) {
      defaults[name] = def.default;
    }

    return defaults;
  }
}

export default ParameterBag;