baja/ord/BatchResolve.js

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

/**
 * Defines {@link baja.BatchResolve}.
 * @module baja/ord/BatchResolve
 */
define([
  "bajaScript/comm",
  "bajaScript/baja/ord/SlotPath",
  "bajaScript/baja/comp/compUtil",
  "bajaScript/baja/comm/Callback",
  "bajaScript/baja/ord/VirtualPath",
  "bajaPromises" ], function (
  baja,
  SlotPath,
  compUtil,
  Callback,
  VirtualPath,
  Promise) {
  
  "use strict";
  
  var subclass = baja.subclass,
      callSuper = baja.callSuper,
      strictArg = baja.strictArg,
      objectify = baja.objectify,
      bajaDef = baja.def,
      
      setContextInOkCallback = compUtil.setContextInOkCallback,
      setContextInFailCallback = compUtil.setContextInFailCallback,
      
      BaseBajaObj = baja.BaseBajaObj;
  
  /**
   * `BatchResolve` is used to resolve a list of ORDs together.
   * 
   * This method should always be used if multiple ORDs need to be resolved at 
   * the same time.
   *
   * @class
   * @alias baja.BatchResolve
   * @extends baja.BaseBajaObj
   * @param {Array.<baja.Ord>} ords an array of ORDs to resolve.
   */  
  var BatchResolve = function BatchResolve(ords) {
    callSuper(BatchResolve, this, arguments);
    strictArg(ords, Array);        
    // Ensure ORDs are normalized
    var items = [], i;
    for (i = 0; i < ords.length; ++i) {
      items.push({
        ord: baja.Ord.make(ords[i].toString()).normalize(),
        target: null
      });
    }
    this.$items = items;
    this.$groups = [];
    this.$ok = baja.ok;
    this.$fail = baja.fail;
    this.$resolved = false;
  };
  
  subclass(BatchResolve, BaseBajaObj);

  /**
   * Resolve an array of ORDs.
   * 
   * @param {Object} params parameters object. This may also be the array of
   * ORDs directly if no other parameters are required.
   * @param {Array.<String|baja.Ord>} [params.ords] the ORDs to resolve
   * @param {baja.Object} [params.base] the base Object to resolve the ORDs 
   * against.
   * @param {baja.Subscriber} [params.subscriber] if defined, any mounted
   * `Component`s are subscribed using this `Subscriber`.
   * @param {Boolean} [params.lease] if defined, any resolved and mounted
   * components are leased.
   * @param {Number|baja.RelTime} [params.leaseTime] the lease time used for
   * leasing `Component`s.
   * @returns {Promise} promise to be resolved with a `BatchResolve` instance
   * 
   * @example
   * BatchResolve.resolve([ 'station:|slot:/foo', 'station:|slot:/bar' ])
   *   .then(function (br) {
   *     var foo = br.get(0), bar = br.get(1);
   *   });
   */
  BatchResolve.resolve = function (params) {
    params = objectify(params, 'ords');
    
    return new BatchResolve(params.ords).resolve(params);
  };
  
  /**
   * Return the number of items in the Batch.
   *
   * @returns {Number}
   */  
  BatchResolve.prototype.size = function () {
    return this.$items.length;
  };
   
  /**
   * Return the ORD at the specified index or null if the index is invalid.
   *
   * @param {Number} index
   * @returns {baja.Ord}
   */      
  BatchResolve.prototype.getOrd = function (index) {
    strictArg(index, Number);
    var t = this.$items[index];
    return t ? t.ord : null;
  };
  
  /**
   * Return true if the ORD at the specified index has successfully resolved.
   *
   * @param {Number} index
   * @returns {Boolean}
   */  
  BatchResolve.prototype.isResolved = function (index) {
    strictArg(index, Number);
    var t = this.$items[index];
    return !!(t && t.target);
  };
  
  /**
   * Return the error for a particular ORD that failed to resolve or null for no error.
   *
   * @param {Number} index
   * @returns {Error} the error or null if no error.
   */
  BatchResolve.prototype.getFail = function (index) {
    strictArg(index, Number);
    var t = this.$items[index];
    return t && t.fail ? t.fail : null;
  };
  
  /**
   * Return the ORD Target at the specified index.
   * 
   * If the ORD failed to resolve, an error will be thrown.
   *
   * @param {Number} index
   * @returns {module:baja/ord/OrdTarget} the ORD Target
   * @throws {Error} thrown if ORD failed to resolve
   */
  BatchResolve.prototype.getTarget = function (index) {
    strictArg(index, Number);
    var t = this.$items[index];
    
    if (!t || !t.target) {
      throw t && t.fail ? t.fail : new Error("Unresolved ORD");
    }
    
    return t.target;
  };
  
  /**
   * Return an array of resolved ORD Targets.
   * 
   * If any of the ORDs failed to resolve, an error will be thrown.
   *
   * @returns {Array<module:baja/ord/OrdTarget>} an array of ORD Targets
   * @throws {Error} thrown if any ORDs failed to resolve
   */
  BatchResolve.prototype.getTargets = function () {
    var targets = [], i;
    for (i = 0; i < this.$items.length; ++i) {
      targets.push(this.getTarget(i));
    }
    return targets;
  };
  
  /**
   * Return an array of resolved objects.
   * 
   * If any of the ORDs failed to resolve, an error will be thrown.
   *
   * @returns {Array.<baja.Object>} an array of objects
   * @throws {Error} thrown if any ORDs failed to resolve
   */
  BatchResolve.prototype.getTargetObjects = function () {
    var objects = [], i;
    for (i = 0; i < this.$items.length; ++i) {
      objects.push(this.get(i));
    }
    return objects;
  };
  
  /**
   * Return the resolved object at the specified index.
   * 
   * If the ORD failed to resolve, an error will be thrown.
   *
   * @param {Number} index
   * @returns {baja.Object} the resolved object
   * @throws {Error} thrown if the ORD failed to resolve
   */
  BatchResolve.prototype.get = function (index) {
    return this.getTarget(index).getObject();
  };
  
  /**
   * For each resolved target, call the specified function.
   * 
   * If any ORDs failed to resolve, an error will be thrown.
   * 
   * When the function is called, the `this` will be the resolved 
   * Component's target. The target's object will be passed as a parameter 
   * into the function.
   *
   * @param {Function} func
   * @throws {Error} thrown if any of the ORDs failed to resolve
   */
  BatchResolve.prototype.each = function (func) {
    strictArg(func, Function);
    var target,
        result, 
        i,
        obj;
    
    for (i = 0; i < this.$items.length; ++i) {
      target = this.getTarget(i);
      try {
        obj = target.getComponent() || target.getObject();
        result = func.call(obj, target.getObject(), i);
        if (result) {
          return result;
        }
      } catch (err) {
        baja.error(err);
      }
    }
  };
  
  function endResolve(batchResolve) {    
    // Called at the very end once everything has resolved
    // If there are any unresolved ORDs then fail the callback
    for (var i = 0; i < batchResolve.$items.length; ++i) {
      if (!batchResolve.$items[i].target) {
        batchResolve.$fail(batchResolve.$items[i].fail || new Error("Unresolved ORD"));
        break;            
      }          
    }
    
    batchResolve.$ok();
  }
  
  function isSubscribable(comp) {
    return comp && comp.isMounted() && comp.getComponentSpace().hasCallbacks();
  }
  
  function batched(func, batch) {
    batch = batch || new baja.comm.Batch();
    var result = func(batch);
    batch.commit();
    return result;
  }
  
  function getSubscribableComponents(batchResolve) {
    var comps = [],
        groups = batchResolve.$groups;
    
    for (var i = 0; i < groups.length; i++) {
      var items = groups[i].items;
      
      for (var j = 0; j < items.length; j++) {
        var target = items[j].target;
        
        if (target) {
          var c = target.getComponent();
          if (isSubscribable(c)) {
            comps.push(c);
          }
        }
      }
    }
    
    return comps;
  }
  
  function subscribeTargets(batchResolve, resolveParams) {    
    
    return batched(function (batch) {
      var subscriber = resolveParams.subscriber,
          lease = resolveParams.lease,
          // If there's no lease and no subscriber then nothing to subscribe.
          comps = (subscriber || lease) && getSubscribableComponents(batchResolve);
      
      return Promise.resolve(comps && comps.length && Promise.all([
        // If we can subscribe then batch up a subscription network call      
        subscriber && subscriber.subscribe({
          comps: comps,
          batch: batch
        }),
        lease && baja.Component.lease({
          comps: comps,
          time: resolveParams.leaseTime,
          batch: batch
        })
      ]));
    });
  }
  
  function resolveOrds(batchResolve, resolveParams) { 
    var resolutions = batchResolve.$items.map(function (item) {
      // Resolve each ORD to its target (unless it's an unknown ORD whereby we've 
      // already tried to resolve it earlier)
      if (!item.unknown) {
        return item.ord.resolve({ base: resolveParams.base })
          .then(function (target) { item.target = target; })
          .catch(function (err) { item.fail = err; });
      }
    });
    
    return Promise.all(resolutions);
  }
  
  /*
   * How deep into the component is the given slot path already loaded?
   * Gets the index at which the slot path is exhausted and we need to make
   * a network call to finish it. -1 if no network calls are needed.
   */
  function getDepthRequestIndex(comp, path) {
    var isVirtual = path instanceof baja.VirtualPath,
        nameAtDepth,
        slot,
        x;
    
    // Find out what exists and doesn't exist
    for (x = 0; x < path.depth(); ++x) {
      nameAtDepth = path.nameAt(x);
    
      if (isVirtual) {
        nameAtDepth = VirtualPath.toSlotPathName(nameAtDepth);
      }
      
      slot = comp.getSlot(nameAtDepth);
      
      // If there's no slot present then we need to try and make a network call for it.
      if (slot === null) {
        return x;
      }
      
      // If the Slot isn't a Property then bail
      if (!slot.isProperty()) {
        return -1;
      }
      
      // If the Property isn't a Component then bail since we're only interested
      // in really loading up to a Component
      if (!slot.getType().isComponent()) {
        return -1;
      }
      
      comp = comp.get(slot);

      // If the slot is a component but doesn't have a handle then we need to request it.
      if (!comp || !comp.getHandle()) {
        return x;
      }
    }
    
    return -1;
  }
  
  function addToRequestMap(map, path, depthRequestIndex) {
    // Load ops on Slots that don't exist
    var slotOrd = "slot:/",
        isVirtual = path instanceof baja.VirtualPath,
        fullPath,
        o,
        nameAt,
        sn,
        i;
    
    for (i = 0; i < path.depth(); i++) {
      // If we've gone past the depth we need to request then build up the network
      // calls we need to make
      
      nameAt = path.nameAt(i);
      
      o = slotOrd;
      sn = isVirtual ? VirtualPath.toSlotPathName(nameAt) : nameAt;
  
      if (i >= depthRequestIndex) {
        fullPath = o + "/" + sn;
        
        // Only request the Slot Path if it already isn't going to be requested
        if (!map.hasOwnProperty(fullPath)) {
          map[fullPath] = { o: o, sn: sn };
        }
      }
      
      if (i > 0) {
        slotOrd += "/";
      }
      
      slotOrd += sn;
    }
  }
  
  function resolveSlotPaths(batchResolve, batch, resolveParams) { 
    var groups = batchResolve.$groups;
    
    var subscriptions = groups.map(function (group) {
      var space = group.space,
          items = group.items,
          comp = space.getRootComponent(),
          slotPathInfo = [],
          slotPathInfoMap = {},
          subscribeOrds = resolveParams.subscriber ? [] : null,
          path, depthRequestIndex;

      for (var i = 0; i < items.length; i++) {
        path = items[i].slot;

        // Skip if no valid SlotPath is available
        if (path) {
          // Record ORD for possible subscription
          if (subscribeOrds) {
            subscribeOrds.push(path.toString());
          }

          depthRequestIndex = getDepthRequestIndex(comp, path);

          // If we've got Slots to request then do so
          if (depthRequestIndex > -1) {
            addToRequestMap(slotPathInfoMap, path, depthRequestIndex);
          }
        }
      }

      //assemble network requests into an array for loadSlotPath
      baja.iterate(slotPathInfoMap, function (arg) { slotPathInfo.push(arg); });

      // Make network request if there are slot paths to load for this Space
      if (slotPathInfo.length) {
        return Promise.all([
          loadSlotPath(space, slotPathInfo, batch),
          // Attempt to roll the network subscription call into
          // the Slot Path resolution to avoid another network call...

          // TODO: subscribing in this way is not ideal. Here we're subscribing Components before they're
          // fully loaded into the Proxy Component Space to avoid another network call. This assumes that
          // each of these Components will fully load into the Component Space without any problems. This will
          // do for now since it's critical to customer's perceptions that BajaScript loads values quickly.
          // If any errors do occur (i.e. the values haven't loaded properly), they are flagged up using baja.error.
          subscribeOrds && resolveParams.subscriber.$ordSubscribe({
            ords: subscribeOrds,
            space: space,
            batch: batch
          })
        ]);
      }
    });

    return Promise.all(subscriptions);
  }
  
  function loadSlotPath(space, slotPathInfo, batch) {
    var cb = new Callback(null, null, batch);
    space.getCallbacks().loadSlotPath(slotPathInfo, space, cb, /*importAsync*/false);
    return cb.promise();
  }
  
  function groupComponentSpaceItems(batchResolve) {
    // Group Items together by Component Space
    var added, 
        item, 
        group, 
        i, 
        x;
    
    for (i = 0; i < batchResolve.$items.length; ++i) {  
      item = batchResolve.$items[i];
      added = false;   

      // Skip grouping for Spaces that don't have callbacks
      if (!item.space) {
        continue;
      }        
      if (!item.space.hasCallbacks()) {
        continue;
      }
    
      for (x = 0; x < batchResolve.$groups.length; ++x) {
        group = batchResolve.$groups[x];
       
        if (String(group.space.getNavOrd()) === String(item.spaceOrd)) {
          group.items.push(item);
          added = true;
          break;      
        }        
      }
      
      // If the item isn't added then create a new group for this item
      if (!added) {
        batchResolve.$groups.push({
          space: item.space,
          items: [ item ]
        });     
      }      
    }
  }
    
  function resolveComponentSpaces(batchResolve, resolveParams) {
    var resolutions = batchResolve.$items.map(function (item) {
      var spaceOrd = item.spaceOrd;
      if (!spaceOrd) { return; }
      var spaceOrdStr = spaceOrd.toString();

      // Optimization for local Station Space
      if (spaceOrdStr === 'station:' || spaceOrdStr === 'local:|station:') {
        item.space = baja.station;
        item.spaceOrd = baja.station.getNavOrd();
      } else {
        return spaceOrd.get({ base: resolveParams.base })
          .then(function (value) {
            var space;

            if (value.getType().is("baja:ComponentSpace")) {
              space = item.space = value;
              item.spaceOrd = space.getNavOrd();
            } else if (value.getType().is("baja:VirtualGateway")) {
              // Note: this may result in some network calls to mount the Virtual Component Space
              space = item.space = value.getVirtualSpace();
              if (!space) {
                return value.loadSlots()
                  .then(function () {
                    space = item.space = value.getVirtualSpace();
                    item.spaceOrd = space.getNavOrd();
                  });
              } else {
                item.spaceOrd = space.getNavOrd();
              }
            } else if (value.getType().is("baja:Component")) {
              space = item.space = value.getComponentSpace();
              item.spaceOrd = space.getNavOrd();
            }
          })
          .catch(ignore);
      }
    });
    
    return Promise.all(resolutions);
  }
  
  function makeAbsSlotPath(query, resolveParams) {
    // Create an absolute SlotPath using the base if necessary
    var isVirtual = query.getSchemeName() === "virtual",
        path = isVirtual ? 
                 new baja.VirtualPath(query.getBody()) : 
                 new SlotPath(query.getBody()),
        basePath,
        newBody;
    
    // If the path is already absolute then use it
    if (path.isAbsolute()) {
      return path;
    }
    
    // Attempt to merge the ORD with the base to get our Absolute SlotPath
    if (resolveParams.base.getType().isComponent() && !isVirtual) {
      basePath = resolveParams.base.getSlotPath();
      if (basePath !== null) {
        newBody = basePath.merge(path);
        return new SlotPath(newBody);
      }
    }
    
    return null;
  }
  
  function resolveUnknown(item, resolveParams, batch) {
    const { base, subscriber, lease, leaseTime } = resolveParams;
    // Batch up the unknown ORD resolution...
    var cb = new Callback(null, null, batch);

    // Make the network call to resolve the complete ORD Server side
    return item.ord.resolve({
      cb: cb,
      base,
      fromBatchResolve: true,
      subscriber,
      lease,
      leaseTime
    })
      .then(function (target) {
        item.target = target;
      })
      .catch(function (err) {
        item.fail = err;
      });
  }
  
  function ignore(ignore) {}
                          
  function processOrds(batchResolve, resolveParams) {
    var unknownBatch, unknownResolutions = [];
    batchResolve.$items.forEach(function (item) {
      var list = item.list = item.ord.parse();
      if (!list.isClientResolvable()) {
        // unknown ORDs get processed completely server side.
        item.unknown = true;

        if (!unknownBatch) { unknownBatch = new baja.comm.Batch(); }

        return unknownResolutions.push(resolveUnknown(item, resolveParams, unknownBatch));
      }

      var cursor = list.getCursor(),
          foundIndex = -1;

      // Work out the ORD just before the virtual, slot or handle scheme
      while (cursor.next()) {
        var q = cursor.get();
        var scheme = q.getSchemeName();

        if (scheme === "virtual") {
          foundIndex = cursor.getIndex();
          item.slot = makeAbsSlotPath(q, resolveParams);
          break;
        } else if (scheme === "h" && foundIndex === -1) {
          foundIndex = cursor.getIndex();
          item.h = q.getBody();
        } else if (scheme === "slot" && foundIndex === -1) {
          foundIndex = cursor.getIndex();
          item.slot = makeAbsSlotPath(q, resolveParams);
        } else if (scheme === 'hierarchy') {
          item.spaceOrd = baja.Ord.make('hierarchy:');
          return;
        } else if (scheme === 'http' || scheme === 'https') {
          return; // no space ord
        }
      }

      // Note down the ORD to the Space
      if (foundIndex !== -1) {
        item.spaceOrd = baja.Ord.make(list.toString(foundIndex));

        // If there's no ORD then just try using the base to resolve the CS.
        if (item.spaceOrd === baja.Ord.DEFAULT) {
          item.spaceOrd = resolveParams.base.getNavOrd();
        }
      }
    });
    
    //perform the individual steps. we ignore all promise rejections and
    //continue trying to resolve whatever we can. at the end, individual ORDs
    //will be marked as resolved or not.
    return resolveComponentSpaces(batchResolve, resolveParams)
      .then(function () {
        groupComponentSpaceItems(batchResolve);
        // piggy back the slot path resolution off the same batch as the unknown ords
        return Promise.all([
          batched(function (batch) {
            return resolveSlotPaths(batchResolve, batch, resolveParams);
          }, unknownBatch),
          Promise.all(unknownResolutions)
        ])
          .catch(ignore);
      })
      .then(function () {
        // If we've resolved the SlotPaths from each group then we can finally attempt 
        // to resolve each ORD. With a bit of luck, this will result in minimal network calls
        return resolveOrds(batchResolve, resolveParams).catch(ignore);
      })
      .then(function () {
        return subscribeTargets(batchResolve, resolveParams).catch(ignore);
      })
      .then(function () {
        return endResolve(batchResolve);
      });
  }
  
  /**
   * Batch resolve an array of ORDs.
   * 
   * A Batch Resolve should be used whenever more than one ORD needs to resolved.
   * 
   * Any network calls that result from processing an ORD are always asynchronous.
   * 
   * This method can only be called once per BatchResolve instance.
   * 
   * When using promises, the static `BatchResolve.resolve` function will be
   * a little cleaner.
   * 
   * @see baja.Ord
   * @see baja.BatchResolve.resolve
   *
   * @param {Object} [obj] the object literal that contains the method's 
   * arguments.
   * @param {Function} [obj.ok] (Deprecated: use Promise) the ok function called
   * once all of the ORDs have been successfully resolved. When the function is
   * called, `this` is set to the `BatchResolve` object.
   * @param {Function} [obj.fail] (Deprecated: use Promise) the fail function
   * called if any of the ORDs fail to resolve. The first error found is pass
   * as an argument to this function.
   * @param {baja.Object} [obj.base] the base Object to resolve the ORDs against. 
   * @param {baja.Subscriber} [obj.subscriber] if defined, any mounted 
   * `Component`s are subscribed using this `Subscriber`.
   * @param {Boolean} [obj.lease] if defined, any resolved and mounted 
   * components are leased.
   * @param {Number|baja.RelTime} [obj.leaseTime] the lease time used for 
   * leasing `Component`s.
   * @returns {Promise} a promise that will be resolved once the ORD resolution
   * is complete. The argument to the `then` callback will be the `BatchResolve`
   * instance.
   * 
   * @example
   *   var r = new baja.BatchResolve(["station:|slot:/Ramp", "station:|slot:/SineWave"]);
   *   var sub = new baja.Subscriber(); // Also batch subscribe all resolved Components
   *   
   *   r.resolve({ subscriber: sub })
   *     .then(function () {
   *       // Get resolved objects
   *       var obj = r.getTargetObjects();
   *     })
   *     .catch(function (err) {
   *       // Called if any of the ORDs fail to resolve
   *     });
   *
   *   // Or use the each method (will only be called if all ORDs resolve). Each will
   *   // be called for each target.
   *   r.resolve({
   *     each: function () {
   *       baja.outln("Resolved: " + this.toPathString());
   *     },
   *     subscriber: sub
   *   });
   */
  BatchResolve.prototype.resolve = function (obj) {
    obj = objectify(obj);
    
    var cb = new Callback(obj.ok, obj.fail),
        that = this;
    
    // Ensure 'this' is Component in callbacks...
    setContextInOkCallback(that, cb);
    setContextInFailCallback(that, cb);

    // If an each function was passed in then call if everything resolves ok.
    that.$ok = function () {
      if (typeof obj.each === "function") {
        try {
          that.each(obj.each);
        } catch (err) {
          baja.error(err);
        }
      }
      cb.ok(that);
    };
    
    that.$fail = function (err) {
      cb.fail(err);
    };
    
    // Can only resolve once
    if (that.$resolved) {
      that.$fail("Cannot call resolve more than once");
      return cb.promise();
    }
    that.$resolved = true;
    
    // Initialize
    obj.base = bajaDef(obj.base, baja.nav.localhost);

    // Check the user isn't trying to batch an ORD as this isn't supported
    if (obj.batch) {
      that.$fail("Cannot batch ORD resolution");
      return cb.promise();
    }
      
    // Start resolution 
    if (that.$items.length > 0) {      
      processOrds(that, {
        subscriber: obj.subscriber,
        lease: obj.lease,
        leaseTime: obj.leaseTime,
        base: obj.base
      });
    } else {
      that.$ok();
    }
    
    return cb.promise();
  };
  
  return BatchResolve;
});