baja/obj/EnumRange.js

/**
 * @copyright 2015 Tridium, Inc. All Rights Reserved.
 * @author Gareth Johnson
 */

/**
 * Defines {@link baja.EnumRange}.
 * @module baja/obj/EnumRange
 */
define([ "bajaScript/sys",
        "bajaScript/baja/obj/Facets",
        "bajaScript/baja/obj/Simple",
        "bajaScript/baja/obj/objUtil",
        "bajaPromises" ], function (baja, Facets, Simple, objUtil, Promise) {
  
  'use strict';
  
  var subclass = baja.subclass,
      callSuper = baja.callSuper,
      strictArg = baja.strictArg,
      strictAllArgs = baja.strictAllArgs,
      objectify = baja.objectify,
      bajaDef = baja.def,
      strictNumber = baja.strictNumber,

      cacheDecode = objUtil.cacheDecode,
      cacheEncode = objUtil.cacheEncode,
      uncacheConstantEncodeDecode = objUtil.uncacheConstantEncodeDecode,
      
      facetsDefault = Facets.DEFAULT;
  
  /**
   * Represents a `baja:EnumRange` in BajaScript.
   * 
   * An `EnumRange` stores a range of ordinal/name pairs for Enumerations.
   * 
   * When creating a `Simple`, always use the `make()` method instead of 
   * creating a new Object.
   *
   * @class
   * @alias baja.EnumRange
   * @extends baja.Simple
   */
  var EnumRange = function EnumRange(frozen, dynamic, byOrdinal, byTag, options) {
    callSuper(EnumRange, this, arguments);
    this.$frozen = frozen;
    this.$dynamic = strictArg(dynamic, Array);
    this.$byOrdinal = strictArg(byOrdinal, Object);
    this.$byTag = strictArg(byTag, Object);
    this.$options = strictArg(options, baja.Facets);
  };
  
  subclass(EnumRange, Simple);
  
  /**
   * Make an EnumRange.
   * 
   * The TypeSpec for a FrozenEnum can be used as the first argument. If other arguments
   * are required then an Object Literal is used to to specify the method's arguments.
   *
   * @param {Object} [obj] the Object Literal that holds the method's arguments.
   * @param {String|Type} [obj.frozen] the Type or TypeSpec for the FrozenEnum.
   * @param {Array.<Number|baja.Simple>} [obj.ordinals] an array of numbers (any
   * `baja:Number` type) that specify the dynamic enum ordinals.
   * @param {Array.<String>} [obj.tags] an array of strings that specify the
   * dynamic enum tags.
   * @param {baja.Facets} [obj.options] optional facets.
   * @returns {baja.EnumRange} the EnumRange.
   * 
   * @example
   *   var er = baja.EnumRange.make({
   *     ordinals: [0, 1, 2],
   *     tags: ["A", "B", "C"]
   *   });
   */
  EnumRange.make = function (obj) {    
    obj = objectify(obj, "frozen");
    
    var frozen = bajaDef(obj.frozen, null),
        ordinals = obj.ordinals,
        tags = obj.tags,
        count = obj.count,
        options = obj.options,
        byOrdinal = {},
        byTag = {},
        dynaOrdinals = [],
        o, t, i;
      
    // Support String typespec as well as type
    if (typeof frozen === "string") {
      frozen = baja.lt(frozen);
    }

    if (tags && !ordinals) {
      if (!frozen) {
        ordinals = tags.map(function (tag, index) { return index; });
      } else {
        throw new Error("Ordinals array required with FrozenEnum");
      }
    }
    if (!ordinals) {
      ordinals = [];
    }
    if (!tags) {
      tags = [];
    }
    if (count === undefined && ordinals instanceof Array) {
      count = ordinals.length;
    }
    if (!options) {
      options = facetsDefault;
    }
   
    strictAllArgs([ ordinals, tags, count, options ], [ Array, Array, Number, baja.Facets ]);
    
    if (ordinals.length !== tags.length) {
      throw new Error("Ordinals and tags arrays must match in length");
    }
    
    // optimization
    if (count === 0 && options === facetsDefault) {
      if (frozen === null) {
        return EnumRange.DEFAULT;
      }
      
      return new EnumRange(frozen, [], {}, {}, options);
    }

    for (i = 0; i < count; ++i) {
      o = ordinals[i];
      t = tags[i];
      
      // Check for undefined due to BajaScript loading sequence
      if (baja.SlotPath !== undefined) {      
        baja.SlotPath.verifyValidName(t);
      }
      
      // check against frozen
      if (frozen !== null && frozen.isOrdinal(o)) {
        continue;
      }  
              
      // check duplicate ordinal
      if (byOrdinal.hasOwnProperty(o)) {
        throw new Error("Duplicate ordinal: " + t + "=" + o);
      }

      // check duplicate tag
      if (byTag.hasOwnProperty(t) || 
          (frozen && frozen.isTag(t))) {
        throw new Error("Duplicate tag: " + t + "=" + o);
      }
        
      // put into map
      byOrdinal[o] = t;
      byTag[t] = o;
      dynaOrdinals.push(o);
    }
      
    return new EnumRange(frozen, dynaOrdinals, byOrdinal, byTag, options);
        
  };
  
  /**
   * Make an EnumRange.
   * 
   * The TypeSpec for a FrozenEnum can be used as the first argument. If other arguments
   * are required then an Object Literal is used to to specify the method's arguments.
   *
   * @param {Object} [obj] the Object Literal that holds the method's arguments.
   * @param {String|Type} [obj.frozen] the Type or TypeSpec for the FrozenEnum.
   * @param {Array.<Number|baja.Simple>} [obj.ordinals] an array of numbers (any
   * `baja:Number` type) that specify the dynamic enum ordinals.
   * @param {Array.<String>} [obj.tags] an array of strings that specify the
   * dynamic enum tags.
   * @param {baja.Facets} [obj.options] optional facets.
   * @returns {baja.EnumRange} the EnumRange .
   * 
   * @example
   *   var er = baja.$("baja:EnumRange").make({
   *     ordinals: [0, 1, 2],
   *     tags: ["A", "B", "C"]
   *   });
   */
  EnumRange.prototype.make = function (obj) {    
    return EnumRange.make.apply(EnumRange, arguments);
  };
  
  function splitFrozenDynamic(s) {       
    var frozen = null,
        dynamic = null,
        plus = s.indexOf('+');
    
    if (plus < 0) {
      if (s.indexOf("{") === 0) {
        dynamic = s;
      } else {
        frozen = s;
      }
    } else {
      if (s.indexOf("{") === 0) {
        dynamic = s.substring(0, plus);
        frozen = s.substring(plus + 1);   
      } else {
        frozen = s.substring(0, plus);
        dynamic = s.substring(plus + 1);   
      }
    }    
    return [ frozen, dynamic ];
  }

  function splitRangeAndOptions(str) {
    var range, options, question = str.indexOf('?');
    if (question >= 0) {
      range = str.substring(0, question);
      options = str.substring(question + 1);
    } else {
      range = str;
      options = '';
    }
    return [ range, options ];
  }
  
  function parseDynamic(s, ordinals, tags) {
    var count = 0,
        ordinal,
        st = s ? s.split(/[=,]/) : [],
        i;
    
    for (i = 0; i < st.length; ++i) {
      tags[count] = st[i];
      ordinal = parseInt(st[++i], 10);
      if (isNaN(ordinal)) {
        throw new Error("Invalid ordinal: " + st[i]);
      }
      ordinals[count] = ordinal;
      count++;
    }   
    return count;
  }

  /**
   * Decode an `EnumRange` from a `String`.
   *
   * @param {String} str
   * @param {Object} [params]
   * @param {Boolean} [params.unsafe=false] if set to true, this will allow
   * decodeFromString to continue. If not, decodeFromString will throw an error. This flag is for
   * internal bajaScript use only. All external implementations should use decodeAsync instead.
   * @returns {baja.EnumRange}
   */
  EnumRange.prototype.decodeFromString = function (str, { unsafe = false } = {}) {
    if (!unsafe) { throw new Error('EnumRange#decodeAsync should be called instead to ensure all types are loaded for the decode'); }

    if (str === "{}") {
      return EnumRange.DEFAULT;
    }
    
    // split body from options (there can't be a question mark 
    // anywhere until after the frozen type or dynamic {}
    var rangeAndOptions = splitRangeAndOptions(str),
        rangeStr = rangeAndOptions[0],
        options = facetsDefault.decodeFromString(rangeAndOptions[1], { unsafe }),
        split = splitFrozenDynamic(rangeStr),
        frozenStr = split[0],
        dynamicStr = split[1],
        frozen = null,
        ordinals,
        tags,
        count;
    
    // get frozen
    if (frozenStr !== null) {
      frozen = baja.lt(frozenStr);
      if (frozen === null) {
        throw new Error("Invalid frozen EnumRange spec: " + frozenStr);
      }
    }

    if (dynamicStr === null) {
      return this.make({
        "frozen": frozen, 
        "options": options
      });
    }

    // check for required braces on dynamic
    if (dynamicStr.charAt(0) !== "{") {
      throw new Error("Missing {");
    }
    if (dynamicStr.charAt(dynamicStr.length - 1) !== "}") {
      throw new Error("Missing }");
    }      
    dynamicStr = dynamicStr.substring(1, dynamicStr.length - 1);
                                                  
    // get dynamic                
    ordinals = [];
    tags = [];
    count = parseDynamic(dynamicStr, ordinals, tags);
    
    return this.make({
      "frozen": frozen, 
      "ordinals": ordinals, 
      "tags": tags, 
      "count": count, 
      "options": options
    });
  };

  /**
   * If the string encoding includes a frozen type, ensure that that type is
   * imported before decoding the EnumRange.
   *
   * @param {string} str
   * @param {baja.comm.Batch} [batch]
   * @returns {Promise.<baja.EnumRange>}
   */
  EnumRange.prototype.decodeAsync = function (str, batch) {
    var frozenType = splitFrozenDynamic(splitRangeAndOptions(str)[0])[0];

    return Promise.resolve(frozenType && baja.importTypes({
        typeSpecs: [ frozenType ],
        batch: batch
      }))
      .then(function () {
        return baja.EnumRange.DEFAULT.decodeFromString(str, baja.Simple.$unsafeDecode);
      });
  };

  /**
   * Encode an `EnumRange` to a `String`.
   *
   * @returns {String}
   */
  EnumRange.prototype.encodeToString = function () {
    // Optimization for encoding
    if (this === EnumRange.DEFAULT) {
      return "{}";
    }
  
    var s = "",
        key,
        tag, i;
    
    if (this.$frozen !== null) {
      s += this.$frozen.getTypeSpec();
    }
    
    if (this.$dynamic.length > 0 || this.$frozen === null) {
      if (s.length > 0) {
        s += "+";
      }
      
      s += "{";
      for (i = 0; i < this.$dynamic.length; ++i) {
        key = this.$dynamic[i];
        tag = this.$byOrdinal[key];
        if (i > 0) {
          s += ",";
        }
        s += tag + "=" + key;
      }
      s += "}";
    }     

    if (this.$options !== facetsDefault) {
      s += "?" + this.$options.encodeToString();
    }
    
    return s;
  };

  /**
     * Equality test.
     *
     * @param obj
     * @returns {Boolean} true if valid
     */
    EnumRange.prototype.equals = function (obj) {
      if (!baja.hasType(obj, 'baja:EnumRange')) { return false; }
      var ords1 = this.getOrdinals(),
          ords2 = obj.getOrdinals();
      if (ords1.length !== ords2.length) { return false; }
      for (var i = 0; i < ords1.length; i++) {
        if (!obj.isOrdinal(ords1[i]) || this.getTag(ords1[i]) !== obj.getTag(ords1[i])) {
          return false;
        }
      }
      if (!(obj.getOptions().equals(this.getOptions()))) {
        return false;
      }
      return true;
    };

  /**
   * Default EnumRange instance.
   * @type {baja.EnumRange}
   */   
  EnumRange.DEFAULT = uncacheConstantEncodeDecode(new EnumRange(null, [], {}, {}, facetsDefault));
  
  EnumRange.prototype.decodeFromString = cacheDecode(EnumRange.prototype.decodeFromString);
  EnumRange.prototype.encodeToString = cacheEncode(EnumRange.prototype.encodeToString);
         
  /**
   * Return the data type symbol.
   *
   * @returns {String} data type symbol.
   */
  EnumRange.prototype.getDataTypeSymbol = function () {
    return "E";
  };
  
  /**
   * Return all of the ordinals for the `EnumRange`.
   * 
   * The returned array contains both frozen and enum ordinals.
   *
   * @returns {Array.<Number>} an array of numbers that represents the ordinals for this 
   * `EnumRange`.
   */
  EnumRange.prototype.getOrdinals = function () {
    var ordinals, i;
    if (this.$frozen !== null) {
      ordinals = this.$frozen.getOrdinals();
    } else {
      ordinals = [];
    }
    for (i = 0; i < this.$dynamic.length; ++i) {
      ordinals.push(this.$dynamic[i]);
    }
    return ordinals;
  };
  
  /**
   * Return true if the ordinal is valid in this `EnumRange`.
   *
   * @param {Number|baja.Simple} ordinal (any `baja:Number` type)
   * @returns {Boolean} true if valid
   */
  EnumRange.prototype.isOrdinal = function (ordinal) {
    ordinal = strictNumber(ordinal);
    if (this.$frozen !== null && this.$frozen.isOrdinal(ordinal)) {
      return true;
    }
    return this.$byOrdinal.hasOwnProperty(ordinal);
  };  
  
  /**
   * Return the tag for the specified ordinal.
   * 
   * If the ordinal isn't valid then the ordinal is returned 
   * as a `String`.
   *
   * @param {Number|baja.Simple} ordinal (any `baja:Number` type)
   * @returns {String} tag
   */
  EnumRange.prototype.getTag = function (ordinal) {
    ordinal = strictNumber(ordinal);
    if (this.$byOrdinal.hasOwnProperty(ordinal)) {
      return this.$byOrdinal[ordinal];
    }
      
    if (this.$frozen !== null && this.$frozen.isOrdinal(ordinal)) {
      return this.$frozen.getTag(ordinal);
    }
    
    return String(ordinal);
  };  

  /**
   * Return the display tag for the specified ordinal.
   * 
   * If the ordinal isn't valid then the ordinal is returned 
   * as a `String`.
   *
   * @param {Number|baja.Simple} ordinal (any `baja:Number` type)
   * @returns {String} tag
   */
  EnumRange.prototype.getDisplayTag = function (ordinal) {
    var that = this,
        tag;

    ordinal = strictNumber(ordinal);
      
    if (that.$frozen !== null && that.$frozen.isOrdinal(ordinal)) {
      return that.$frozen.getDisplayTag(ordinal);
    }

    if (that.$byOrdinal.hasOwnProperty(ordinal)) {
      tag = that.$byOrdinal[ordinal];

      // TODO: should look up lexicon.

      return baja.SlotPath.unescape(tag);
    }
    
    return String(ordinal);
  };  
  
  /**
   * Return true if the tag is used within the `EnumRange`.
   *
   * @param {String} tag
   * @returns {Boolean} true if valid.
   */
  EnumRange.prototype.isTag = function (tag) {
    strictArg(tag, String);  
    if (this.$frozen !== null && this.$frozen.isTag(tag)) {
      return true;
    }
    
    return this.$byTag.hasOwnProperty(tag);
  }; 
  
  /**
   * Convert the tag to its ordinal within the `EnumRange`.
   *
   * @param {String} tag
   * @returns {Number} ordinal for the tag.
   * @throws {Error} if the tag is invalid.
   */
  EnumRange.prototype.tagToOrdinal = function (tag) {
    strictArg(tag, String);  
    if (this.$frozen !== null && this.$frozen.isTag(tag)) {
      return this.$frozen.tagToOrdinal(tag);
    }
     
    if (!this.$byTag.hasOwnProperty(tag)) {
      throw new Error("Invalid tag: " + tag);
    }
    
    return this.$byTag[tag];
  }; 
  
  /**
   * Get the enum for the specified tag or ordinal.
   * 
   * This method is used to access an enum based upon a tag or ordinal.
   *
   * @param {String|Number|baja.Simple} arg a tag or ordinal (any `baja:Number`
   * type).
   * @returns {baja.DynamicEnum|baja.FrozenEnum|Boolean} the enum for the tag or
   * ordinal.
   * @throws {Error} if the tag or ordinal is invalid.
   */
  EnumRange.prototype.get = function (arg) {      
      
    if (typeof arg === "string") {
      if (this === EnumRange.BOOLEAN_RANGE) {
        return arg !== "false";
      }
    
      // Look up via tag name
      if (this.$frozen !== null && this.$frozen.isTag(arg)) {
        return this.$frozen.getFrozenEnum(arg);
      }
    
      if (this.$byTag.hasOwnProperty(arg)) {
        return baja.DynamicEnum.make({ "ordinal": this.$byTag[arg], "range": this });
      }
    } else {
      arg = strictNumber(arg);

      if (this === EnumRange.BOOLEAN_RANGE) {
        return arg !== 0;
      }
    
      // Look up via ordinal
      if (this.$frozen !== null && this.$frozen.isOrdinal(arg)) {
        return this.$frozen.getFrozenEnum(arg);
      }
    
      if (this.isOrdinal(arg)) {
        return baja.DynamicEnum.make({ "ordinal": arg, "range": this });
      }
    }
    throw new Error("Unable to access enum");
  }; 
  
  /**
   * Return true if the ordinal is a valid ordinal in the frozen range.
   *
   * @param {Number|baja.Simple} ordinal (any `baja:Number` type)
   * @returns {Boolean} true if valid.
   */
  EnumRange.prototype.isFrozenOrdinal = function (ordinal) {
    ordinal = strictNumber(ordinal);
    return this.$frozen !== null && this.$frozen.isOrdinal(ordinal);
  };
  
  /**
   * Return true if the ordinal is a valid ordinal in the dynamic range.
   *
   * @param {Number|baja.Simple} ordinal (any `baja:Number` type)
   * @returns {Boolean} true if valid
   */
  EnumRange.prototype.isDynamicOrdinal = function (ordinal) {
    ordinal = strictNumber(ordinal);
    return this.$byOrdinal.hasOwnProperty(ordinal);
  };
  
  /**
   * Return the Type used for the frozen enum range or null if this range
   * has no frozen ordinal/tag pairs.
   *
   * @returns {Type} the Type for the FrozenEnum or null.
   */
  EnumRange.prototype.getFrozenType = function () {
    return this.$frozen;
  };
  
  /**
   * Get the options for this range stored as a Facets instance.
   *
   * @returns {baja.Facets} facets
   */
  EnumRange.prototype.getOptions = function () {
    return this.$options;
  };
  
  /**
   * Boolean EnumRange.
   * @type {baja.EnumRange}
   */
  EnumRange.BOOLEAN_RANGE = EnumRange.make({
    ordinals: [ 0, 1 ],
    tags: [ "false", "true" ],
    options: Facets.make({ lexicon: 'baja' })
  });    

  return EnumRange;
});