baja/obj/Facets.js

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

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

  /**
   * Mapping of data type symbol chars to their respective type specs. c&p'ed
   * from DataTypes.java.
   * 
   * @inner
   * @type {Object}
   */
  var symbolsToDataTypes = {
    b: 'baja:Boolean',
    i: 'baja:Integer',
    l: 'baja:Long',
    f: 'baja:Float',
    d: 'baja:Double',
    s: 'baja:String',
    e: 'baja:DynamicEnum',
    E: 'baja:EnumRange',
    a: 'baja:AbsTime',
    r: 'baja:RelTime',
    u: 'baja:Unit',
    z: 'baja:TimeZone',
    o: 'baja:Ord',
    m: 'baja:Marker'
  };

////////////////////////////////////////////////////////////////
// Utility functions
////////////////////////////////////////////////////////////////

  function getDefaultInstance(symbol) {
    return baja.$(symbolsToDataTypes[symbol]);
  }

  /**
   * Split the encoded string into an array of objects for decoding.
   * @param {string} str
   * @returns {Array.<Array.<string>>} array of keys, symbols, and encoded strings
   */
  function toSplitArray(str) {
    var keys = [];
    var symbols = [];
    var strings = [];
    var split = str.split('|');
    for (var i = 0, len = split.length; i < len; ++i) {
      var keyAndValue = split[i].split(/=(.*)/);
      var key = keyAndValue[0];
      var value = keyAndValue[1] || '';
      var symbolAndEncodedString = value.split(/:(.*)/); //split on first :
      var symbol = symbolAndEncodedString[0];
      var encodedString = symbolAndEncodedString[1];

      if (symbol === 's' || symbol === 'o') {
        //strings and ords are encoded into facets as slot-escaped
        encodedString = baja.SlotPath.unescape(encodedString);
      }
      keys.push(key);
      symbols.push(symbol);
      strings.push(encodedString);
    }
    return [ keys, symbols, strings ];
  }

  /**
   * Get the facets from a baja.Complex
   * 
   * @param {baja.Complex} c 
   */
  function getFacetsFromComplex(c) {
    // First, work with the 'facets' slot
    var slotFacets = c.get('facets');
    if (baja.hasType(slotFacets, 'baja:Facets')) {
      return slotFacets;
    }

    // Second, work with the 'out' slot
    // True for writable points and consts
    var out = c.get('out');

    if (out && typeof out.getStatusValueFacets === 'function') {
      return out.getStatusValueFacets();
    }

    if (baja.hasType(out, 'baja:StatusValue')) {
      return out.get('status').getFacets();
    }

    return baja.Facets.DEFAULT;
  }

////////////////////////////////////////////////////////////////
// Facets implementation
////////////////////////////////////////////////////////////////
  
  /**
   * Represents a `baja:Facets` in BajaScript.
   * 
   * `BFacets` is a map of name/value pairs used to annotate a
   * `BComplex`'s Slot or to just provide additional metadata
   * about something.  The values of facets may only be
   * `BIDataValue`s which are a predefined subset of simples.
   * 
   * When creating a `Simple`, always use the `make()` method instead of 
   * creating a new Object.
   *
   * @class
   * @alias baja.Facets
   * @extends baja.Simple
   */
  var Facets = function Facets(keys, vals) {
    callSuper(Facets, this, arguments);    
    this.$map = new baja.OrderedMap();
    strictArg(keys, Array, "Keys array required"); 
    strictArg(vals, Array, "Values array required");    
    
    if (keys.length !== vals.length) {
      throw new Error("baja.Facets Constructor must have an equal number of " +
          "keys and values");
    } 
    if (keys.length === 0) {
      return;
    }

    const SlotPath = baja.SlotPath;
    
    // Iterate through key, value pair arguments and add to the internal Map
    const dvt = baja.lt("baja:IDataValue");
        
    for (let i = 0; i < keys.length; ++i) {
      if (typeof keys[i] !== 'string') {
        throw new Error("Facets keys must be a String");
      }
      
      if (SlotPath) {
        // some Facets instances are constructed before baja namespace - these are internal to BajaScript itself so ok
        SlotPath.verifyValidName(keys[i]);

        // Check everything being added here is a DataValue
        if (!vals[i].getType) {
          throw new Error("Facet value has no Baja Type associated with it");
        }
        if (!vals[i].getType().is(dvt)) {
          throw new Error("Can only add baja:IDataValue Types to BFacets: " +
            vals[i].getType().getTypeSpec());
        }
      }

      this.$map.put(keys[i], vals[i]);
    }
  };
  
  subclass(Facets, Simple);
  
  facetsDefault = new Facets([], []);
  
  /**
   * Default `Facets` instance.
   * @type {baja.Facets}
   */
  Facets.DEFAULT = facetsDefault;
  
  /**
   * NULL `Facets` instance.
   * @type {baja.Facets}
   */
  Facets.NULL = facetsDefault;
  
  function facetsExtend(orig, toAdd) {
    var obj = {}, newKeys = [], newValues = [];
    
    baja.iterate(orig.getKeys(), function (key) {
      obj[key] = orig.get(key);
    });
    
    //overwrite any existing values with ones from toAdd
    baja.iterate(toAdd.getKeys(), function (key) {
      obj[key] = toAdd.get(key); 
    });
   
    baja.iterate(obj, function (value, key) {
      newKeys.push(key);
      newValues.push(value);
    });
    
    return Facets.make(newKeys, newValues);
  }
  
  function facetsFromObj(obj) {
    var keys = [], 
        values = [];
    
    baja.iterate(obj, function (v, k) {
      keys.push(k);
      values.push(v);
    });
    
    return Facets.make(keys, values);
  }

  /**
   * Make a `Facets` object. This function can either take two `Array` objects
   * for keys and values, two `Facets` or two object literals. In the latter two 
   * cases, a new `Facets` object will be returned containing a combination of 
   * keys and values from both input `Facets` objects. If a key exists on both 
   * `Facets` objects, the value from the second Facets will take precedence.
   *
   * @param {Array.<String>|baja.Facets|Object} keys an array of keys for the 
   * facets. The keys must be `String`s. (This may also be a `Facets` (or object 
   * literal) whose values will be combined with the second parameter. Values in 
   * this object will be overwritten by corresponding values from the other).
   * @param {Array.<baja.Simple>|baja.Facets|Object} [values] an array of values 
   * for the facets. The values must be BajaScript Objects whose `Type` 
   * implements `BIDataValue`. (This may also be a `Facets` (or object literal) 
   * object whose values will be combined with the first parameter. Values in 
   * this object will overwrite corresponding values on the other.)
   * @returns {baja.Facets} the Facets
   * 
   * @example
   *   var facets1 = baja.Facets.make(['a', 'b'], ['1', '2']);
   *   var facets2 = baja.Facets.make(['b', 'c'], ['3', '4']);
   *   var facets3 = baja.Facets.make(facets1, facets2);
   *   baja.outln(facets3.get('a')); //1
   *   baja.outln(facets3.get('b')); //3 - facets2 overwrote value in facets1
   *   baja.outln(facets3.get('c')); //4
   */
  Facets.make = function (keys, values) {
    // If there are no arguments are defined then return the default  
    if (arguments.length === 0) {
      return facetsDefault;
    }
    
    // Throw an error if no keys
    if (!keys) {
      throw new Error("Keys required");
    }
    
    // If the keys are an Object then convert to Facets
    if (keys.constructor === Object) {
      keys = facetsFromObj(keys);
    }
    
    // If the values are an Object then convert to Facets
    if (values && values.constructor === Object) {
      values = facetsFromObj(values);
    }
            
    if (keys instanceof Facets) {
      // If keys and values are facets then merge
      if (values && values instanceof Facets) {
        return facetsExtend(keys, values);
      }
      
      // If just the keys are facets then just return them
      return keys;
    }

    // If we've got here then we assume the keys and values are arrays...
    if (keys.length === 0) {
      return facetsDefault;
    }
      
    // Note: I could do more argument checking here but I don't want to slow this down
    // more than necessary.
    
    return new Facets(keys, values);
  };
  
  /**
   * Make a `Facets` object. Same as {@link baja.Facets.make}.
   *
   * @see baja.Facets.make
   */
  Facets.prototype.make = function (args) {
    return Facets.make.apply(Facets, arguments);
  };

  /**
   * Decode a `String` to a `Facets`.
   *
   * @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.Facets}
   */
  Facets.prototype.decodeFromString = cacheDecode(function (str, { unsafe = false } = {}) {
    if (!unsafe) { throw new Error('Facets#decodeAsync should be called instead to ensure all types are loaded for the decode'); }

    if (str.length === 0) { return facetsDefault; }

    var arr = toSplitArray(str);
    var keys = arr[0];
    var symbols = arr[1];
    var strings = arr[2];
    var values = [];
    for (var i = 0, len = symbols.length; i < len; ++i) {
      values.push(getDefaultInstance(symbols[i]).decodeFromString(strings[i], { unsafe }));
    }

    return Facets.make(keys, values);
  });

  /**
   * Decode a `String` to a `Facets`, doing an async decode on each individual
   * encoded Simple.
   *
   * @param {String} str
   * @param {baja.comm.Batch} [batch]
   * @returns {Promise.<baja.Facets>}
   */
  Facets.prototype.decodeAsync = function (str, batch) {
    if (!str) { return Promise.resolve(facetsDefault); }

    var arr = toSplitArray(str);
    var keys = arr[0];
    var symbols = arr[1];
    var strings = arr[2];
    var decodes = [];
    for (var i = 0, len = symbols.length; i < len; ++i) {
      decodes.push(getDefaultInstance(symbols[i]).decodeAsync(strings[i], batch));
    }

    return Promise.all(decodes)
      .then(function (vals) {
        return Facets.make(keys, vals);
      });
  };
  
  /**
   * Encode `Facets` to a `String`.
   *
   * @method
   * @returns {String}
   */
  Facets.prototype.encodeToString = cacheEncode(function () {
    var s = "", // TODO: This needs more work for data encoding
        k = this.$map.getKeys(),
        v, i, symbol;
    for (i = 0; i < k.length; ++i) {
      if (i > 0) {
        s += "|";
      }
      
      v = this.$map.get(k[i]);
            
      if (v.getDataTypeSymbol === undefined) {
        throw new Error("Cannot encode data type as 'getDataTypeSymbol' is not defined: " + v.getType());
      }

      symbol = v.getDataTypeSymbol();

      // If a String or Ord then escape it
      if (symbol === 's' || symbol === 'o') {
        v = baja.SlotPath.escape(String(v));
      }
      
      s += k[i] + "=" + symbol + ":" + v.encodeToString();
    } 
    return s;
  });
   
  /**
   * Return a value from the map for the given key.
   *
   * @param {String} key  the key used to look up the data value
   * @param [def] if defined, this value is returned if the key can't be found.
   * @returns the data value for the key (null if not found)
   */
  Facets.prototype.get = function (key, def) {
    strictArg(key, String);    
    def = bajaDef(def, null);
    var v = this.$map.get(key);
    return v === null ? def : v;
  };
  
  /**
   * Return a copy of the `Facets` keys.
   *
   * @returns {Array.<String>} all of the keys used in the `Facets`
   */
  Facets.prototype.getKeys = function () {
    return this.$map.getKeys();
  };  
  
  /**
   * Return a `String` representation of the `Facets`.
   * 
   * @param {Object} [cx] - Passed directly into the toString() functions of the
   * individual Simples within. Object Literal used to specify formatting
   * facets.
   *
   * @returns {String|Promise.<String>} returns a Promise if a cx is passed in.
   */
  Facets.prototype.toString = function (cx) {
    var str = "",
        keys = this.$map.getKeys(),
        unescape = baja.SlotPath.unescape,
        i;

    if (cx) {
      var promises = [];

      for (i = 0; i < keys.length; ++i) {
        promises.push(this.$map.get(keys[i]).toString(cx));
      }

      return Promise.all(promises)
        .then(function (values) {
          for (i = 0; i < values.length; ++i) {
            if (i > 0) {
              str += ",";
            }
            str += unescape(keys[i]) + "=" + values[i];
          }
          return str;
        });
    }
    for (i = 0; i < keys.length; ++i) {
      if (i > 0) {
        str += ",";
      }
      str += unescape(keys[i]) + "=" + this.$map.get(keys[i]).toString(); 
    }
    return str;
  }; 
  
  /**
   * Return the value of the `Facets`.
   *
   * @method
   * @returns {String}
   */
  Facets.prototype.valueOf = Facets.prototype.toString; 
  
  /**
   * Converts the `Facets` (itself) to an object literal.
   *
   * @returns {Object}
   */
  Facets.prototype.toObject = function () {
    var obj = {},
        keys = this.$map.getKeys(),
        i;

    for (i = 0; i < keys.length; ++i) {
      obj[keys[i]] = this.$map.get(keys[i]);
    }
    return obj;
  };

  /**
   * Removes the key from the provided facets and returns a new facets. If the orig facets doesn't
   * contain the specified key then return the original instance.
   * @since Niagara 4.14
   * @param {String} key the key in the facets to remove
   * @returns {baja.Facets}
   */
  Facets.prototype.makeRemove = function (key) {
    const obj = this.toObject();

    if (obj[key]) {
      delete obj[key];
      return Facets.make(obj);
    } else {
      return this;
    }
  };

  /**
   * Return the facets for the given object.
   * 
   * If the object is a `Facets`, that object is returned.
   * 
   * If the object has a `facets` slot, the value of that slot is returned.
   * 
   * If the object has a parent, the `Facets` from the object's parent slot is
   * returned. 
   * 
   * If the facets can't be found then {@link baja.Facets.DEFAULT} is returned.
   * 
   * @param obj
   * @returns {baja.Facets}
   */
  Facets.getFacetsFromObject = function (obj) {
    var val = facetsDefault;
    
    if (obj.getType().is("baja:Facets")) {
      val = obj;
    } else if (obj.getType().isComplex()) {
      val = getFacetsFromComplex(obj);
    }
    
    return val;
  };

  /**
   * Get the slot facets from a container, merged all the way down through a
   * property path or slot.
   * @param {baja.Complex} [container]
   * @param {Array.<baja.Property|string>|baja.Property|string} [propertyPath]
   * @returns {baja.Facets}
   * @since Niagara 4.10
   */
  Facets.mergeSlotFacets = function (container, propertyPath) {
    var facets = baja.Facets.DEFAULT;

    if (!propertyPath) { return facets; }

    if (!Array.isArray(propertyPath)) {
      propertyPath = [ propertyPath ];
    }

    for (var i = 0, len = propertyPath.length; i < len; i++) {
      if (!container) { return facets; }

      var prop = propertyPath[i];

      if (typeof container.getFacets === 'function') {
        var containerFacets = container.getFacets(prop);
        facets = containerFacets ? baja.Facets.make(containerFacets, facets) : facets;
      }

      if (typeof container.get === 'function' && typeof container.getSlot === 'function') {
        try {
          const slot = container.getSlot(prop);
          if (slot && slot.isProperty()) {
            container = container.get(prop);
          } else {
            container = null;
          }
        } catch (e) {
          container = null;
        }
      } else {
        return facets;
      }
    }

    return facets;
  };
  
  return Facets;
});