object.js

const _ = require('underscore');
const AVError = require('./error');
const { _request } = require('./request');
const {
  isNullOrUndefined,
  ensureArray,
  transformFetchOptions,
  setValue,
  findValue,
  isPlainObject,
  continueWhile,
} = require('./utils');

const recursiveToPointer = value => {
  if (_.isArray(value)) return value.map(recursiveToPointer);
  if (isPlainObject(value)) return _.mapObject(value, recursiveToPointer);
  if (_.isObject(value) && value._toPointer) return value._toPointer();
  return value;
};

const RESERVED_KEYS = ['objectId', 'createdAt', 'updatedAt'];
const checkReservedKey = key => {
  if (RESERVED_KEYS.indexOf(key) !== -1) {
    throw new Error(`key[${key}] is reserved`);
  }
};

const handleBatchResults = results => {
  const firstError = _.find(results, result => result instanceof Error);
  if (!firstError) {
    return results;
  }
  const error = new AVError(firstError.code, firstError.message);
  error.results = results;
  throw error;
};

// Helper function to get a value from a Backbone object as a property
// or as a function.
function getValue(object, prop) {
  if (!(object && object[prop])) {
    return null;
  }
  return _.isFunction(object[prop]) ? object[prop]() : object[prop];
}

// AV.Object is analogous to the Java AVObject.
// It also implements the same interface as a Backbone model.

module.exports = function(AV) {
  /**
   * Creates a new model with defined attributes. A client id (cid) is
   * automatically generated and assigned for you.
   *
   * <p>You won't normally call this method directly.  It is recommended that
   * you use a subclass of <code>AV.Object</code> instead, created by calling
   * <code>extend</code>.</p>
   *
   * <p>However, if you don't want to use a subclass, or aren't sure which
   * subclass is appropriate, you can use this form:<pre>
   *     var object = new AV.Object("ClassName");
   * </pre>
   * That is basically equivalent to:<pre>
   *     var MyClass = AV.Object.extend("ClassName");
   *     var object = new MyClass();
   * </pre></p>
   *
   * @param {Object} attributes The initial set of data to store in the object.
   * @param {Object} options A set of Backbone-like options for creating the
   *     object.  The only option currently supported is "collection".
   * @see AV.Object.extend
   *
   * @class
   *
   * <p>The fundamental unit of AV data, which implements the Backbone Model
   * interface.</p>
   */
  AV.Object = function(attributes, options) {
    // Allow new AV.Object("ClassName") as a shortcut to _create.
    if (_.isString(attributes)) {
      return AV.Object._create.apply(this, arguments);
    }

    attributes = attributes || {};
    if (options && options.parse) {
      attributes = this.parse(attributes);
      attributes = this._mergeMagicFields(attributes);
    }
    var defaults = getValue(this, 'defaults');
    if (defaults) {
      attributes = _.extend({}, defaults, attributes);
    }
    if (options && options.collection) {
      this.collection = options.collection;
    }

    this._serverData = {}; // The last known data for this object from cloud.
    this._opSetQueue = [{}]; // List of sets of changes to the data.
    this._flags = {};
    this.attributes = {}; // The best estimate of this's current data.

    this._hashedJSON = {}; // Hash of values of containers at last save.
    this._escapedAttributes = {};
    this.cid = _.uniqueId('c');
    this.changed = {};
    this._silent = {};
    this._pending = {};
    this.set(attributes, { silent: true });
    this.changed = {};
    this._silent = {};
    this._pending = {};
    this._hasData = true;
    this._previousAttributes = _.clone(this.attributes);
    this.initialize.apply(this, arguments);
  };

  /**
   * @lends AV.Object.prototype
   * @property {String} id The objectId of the AV Object.
   */

  /**
   * Saves the given list of AV.Object.
   * If any error is encountered, stops and calls the error handler.
   *
   * @example
   * AV.Object.saveAll([object1, object2, ...]).then(function(list) {
   *   // All the objects were saved.
   * }, function(error) {
   *   // An error occurred while saving one of the objects.
   * });
   *
   * @param {Array} list A list of <code>AV.Object</code>.
   */
  AV.Object.saveAll = function(list, options) {
    return AV.Object._deepSaveAsync(list, null, options);
  };

  /**
   * Fetch the given list of AV.Object.
   *
   * @param {AV.Object[]} objects A list of <code>AV.Object</code>
   * @param {AuthOptions} options
   * @return {Promise.<AV.Object[]>} The given list of <code>AV.Object</code>, updated
   */

  AV.Object.fetchAll = (objects, options) =>
    Promise.resolve()
      .then(() =>
        _request(
          'batch',
          null,
          null,
          'POST',
          {
            requests: _.map(objects, object => {
              if (!object.className)
                throw new Error('object must have className to fetch');
              if (!object.id) throw new Error('object must have id to fetch');
              if (object.dirty())
                throw new Error('object is modified but not saved');
              return {
                method: 'GET',
                path: `/1.1/classes/${object.className}/${object.id}`,
              };
            }),
          },
          options
        )
      )
      .then(function(response) {
        const results = _.map(objects, function(object, i) {
          if (response[i].success) {
            const fetchedAttrs = object.parse(response[i].success);
            object._cleanupUnsetKeys(fetchedAttrs);
            object._finishFetch(fetchedAttrs);
            return object;
          }
          if (response[i].success === null) {
            return new AVError(AVError.OBJECT_NOT_FOUND, 'Object not found.');
          }
          return new AVError(response[i].error.code, response[i].error.error);
        });
        return handleBatchResults(results);
      });

  // Attach all inheritable methods to the AV.Object prototype.
  _.extend(
    AV.Object.prototype,
    AV.Events,
    /** @lends AV.Object.prototype */ {
      _fetchWhenSave: false,

      /**
       * Initialize is an empty function by default. Override it with your own
       * initialization logic.
       */
      initialize: function() {},

      /**
       * Set whether to enable fetchWhenSave option when updating object.
       * When set true, SDK would fetch the latest object after saving.
       * Default is false.
       *
       * @deprecated use AV.Object#save with options.fetchWhenSave instead
       * @param {boolean} enable  true to enable fetchWhenSave option.
       */
      fetchWhenSave: function(enable) {
        console.warn(
          'AV.Object#fetchWhenSave is deprecated, use AV.Object#save with options.fetchWhenSave instead.'
        );
        if (!_.isBoolean(enable)) {
          throw new Error('Expect boolean value for fetchWhenSave');
        }
        this._fetchWhenSave = enable;
      },

      /**
       * Returns the object's objectId.
       * @return {String} the objectId.
       */
      getObjectId: function() {
        return this.id;
      },

      /**
       * Returns the object's createdAt attribute.
       * @return {Date}
       */
      getCreatedAt: function() {
        return this.createdAt;
      },

      /**
       * Returns the object's updatedAt attribute.
       * @return {Date}
       */
      getUpdatedAt: function() {
        return this.updatedAt;
      },

      /**
       * Returns a JSON version of the object.
       * @return {Object}
       */
      toJSON: function(key, holder, seenObjects = []) {
        return this._toFullJSON(seenObjects, false);
      },

      /**
       * Returns a JSON version of the object with meta data.
       * Inverse to {@link AV.parseJSON}
       * @since 3.0.0
       * @return {Object}
       */
      toFullJSON(seenObjects = []) {
        return this._toFullJSON(seenObjects);
      },

      _toFullJSON: function(seenObjects, full = true) {
        var json = _.clone(this.attributes);
        if (_.isArray(seenObjects)) {
          var newSeenObjects = seenObjects.concat(this);
        }
        AV._objectEach(json, function(val, key) {
          json[key] = AV._encode(val, newSeenObjects, undefined, full);
        });
        AV._objectEach(this._operations, function(val, key) {
          json[key] = val;
        });

        if (_.has(this, 'id')) {
          json.objectId = this.id;
        }
        ['createdAt', 'updatedAt'].forEach(key => {
          if (_.has(this, key)) {
            const val = this[key];
            json[key] = _.isDate(val) ? val.toJSON() : val;
          }
        });
        if (full) {
          json.__type = 'Object';
          if (_.isArray(seenObjects) && seenObjects.length)
            json.__type = 'Pointer';
          json.className = this.className;
        }
        return json;
      },

      /**
       * Updates _hashedJSON to reflect the current state of this object.
       * Adds any changed hash values to the set of pending changes.
       * @private
       */
      _refreshCache: function() {
        var self = this;
        if (self._refreshingCache) {
          return;
        }
        self._refreshingCache = true;
        AV._objectEach(this.attributes, function(value, key) {
          if (value instanceof AV.Object) {
            value._refreshCache();
          } else if (_.isObject(value)) {
            if (self._resetCacheForKey(key)) {
              self.set(key, new AV.Op.Set(value), { silent: true });
            }
          }
        });
        delete self._refreshingCache;
      },

      /**
       * Returns true if this object has been modified since its last
       * save/refresh.  If an attribute is specified, it returns true only if that
       * particular attribute has been modified since the last save/refresh.
       * @param {String} attr An attribute name (optional).
       * @return {Boolean}
       */
      dirty: function(attr) {
        this._refreshCache();

        var currentChanges = _.last(this._opSetQueue);

        if (attr) {
          return currentChanges[attr] ? true : false;
        }
        if (!this.id) {
          return true;
        }
        if (_.keys(currentChanges).length > 0) {
          return true;
        }
        return false;
      },

      /**
       * Returns the keys of the modified attribute since its last save/refresh.
       * @return {String[]}
       */
      dirtyKeys: function() {
        this._refreshCache();
        var currentChanges = _.last(this._opSetQueue);
        return _.keys(currentChanges);
      },

      /**
       * Gets a Pointer referencing this Object.
       * @private
       */
      _toPointer: function() {
        // if (!this.id) {
        //   throw new Error("Can't serialize an unsaved AV.Object");
        // }
        return {
          __type: 'Pointer',
          className: this.className,
          objectId: this.id,
        };
      },

      /**
       * Gets the value of an attribute.
       * @param {String} attr The string name of an attribute.
       */
      get: function(attr) {
        switch (attr) {
          case 'objectId':
            return this.id;
          case 'createdAt':
          case 'updatedAt':
            return this[attr];
          default:
            return this.attributes[attr];
        }
      },

      /**
       * Gets a relation on the given class for the attribute.
       * @param {String} attr The attribute to get the relation for.
       * @return {AV.Relation}
       */
      relation: function(attr) {
        var value = this.get(attr);
        if (value) {
          if (!(value instanceof AV.Relation)) {
            throw new Error('Called relation() on non-relation field ' + attr);
          }
          value._ensureParentAndKey(this, attr);
          return value;
        } else {
          return new AV.Relation(this, attr);
        }
      },

      /**
       * Gets the HTML-escaped value of an attribute.
       */
      escape: function(attr) {
        var html = this._escapedAttributes[attr];
        if (html) {
          return html;
        }
        var val = this.attributes[attr];
        var escaped;
        if (isNullOrUndefined(val)) {
          escaped = '';
        } else {
          escaped = _.escape(val.toString());
        }
        this._escapedAttributes[attr] = escaped;
        return escaped;
      },

      /**
       * Returns <code>true</code> if the attribute contains a value that is not
       * null or undefined.
       * @param {String} attr The string name of the attribute.
       * @return {Boolean}
       */
      has: function(attr) {
        return !isNullOrUndefined(this.attributes[attr]);
      },

      /**
       * Pulls "special" fields like objectId, createdAt, etc. out of attrs
       * and puts them on "this" directly.  Removes them from attrs.
       * @param attrs - A dictionary with the data for this AV.Object.
       * @private
       */
      _mergeMagicFields: function(attrs) {
        // Check for changes of magic fields.
        var model = this;
        var specialFields = ['objectId', 'createdAt', 'updatedAt'];
        AV._arrayEach(specialFields, function(attr) {
          if (attrs[attr]) {
            if (attr === 'objectId') {
              model.id = attrs[attr];
            } else if (
              (attr === 'createdAt' || attr === 'updatedAt') &&
              !_.isDate(attrs[attr])
            ) {
              model[attr] = AV._parseDate(attrs[attr]);
            } else {
              model[attr] = attrs[attr];
            }
            delete attrs[attr];
          }
        });
        return attrs;
      },

      /**
       * Returns the json to be sent to the server.
       * @private
       */
      _startSave: function() {
        this._opSetQueue.push({});
      },

      /**
       * Called when a save fails because of an error. Any changes that were part
       * of the save need to be merged with changes made after the save. This
       * might throw an exception is you do conflicting operations. For example,
       * if you do:
       *   object.set("foo", "bar");
       *   object.set("invalid field name", "baz");
       *   object.save();
       *   object.increment("foo");
       * then this will throw when the save fails and the client tries to merge
       * "bar" with the +1.
       * @private
       */
      _cancelSave: function() {
        var failedChanges = _.first(this._opSetQueue);
        this._opSetQueue = _.rest(this._opSetQueue);
        var nextChanges = _.first(this._opSetQueue);
        AV._objectEach(failedChanges, function(op, key) {
          var op1 = failedChanges[key];
          var op2 = nextChanges[key];
          if (op1 && op2) {
            nextChanges[key] = op2._mergeWithPrevious(op1);
          } else if (op1) {
            nextChanges[key] = op1;
          }
        });
        this._saving = this._saving - 1;
      },

      /**
       * Called when a save completes successfully. This merges the changes that
       * were saved into the known server data, and overrides it with any data
       * sent directly from the server.
       * @private
       */
      _finishSave: function(serverData) {
        // Grab a copy of any object referenced by this object. These instances
        // may have already been fetched, and we don't want to lose their data.
        // Note that doing it like this means we will unify separate copies of the
        // same object, but that's a risk we have to take.
        var fetchedObjects = {};
        AV._traverse(this.attributes, function(object) {
          if (object instanceof AV.Object && object.id && object._hasData) {
            fetchedObjects[object.id] = object;
          }
        });

        var savedChanges = _.first(this._opSetQueue);
        this._opSetQueue = _.rest(this._opSetQueue);
        this._applyOpSet(savedChanges, this._serverData);
        this._mergeMagicFields(serverData);
        var self = this;
        AV._objectEach(serverData, function(value, key) {
          self._serverData[key] = AV._decode(value, key);

          // Look for any objects that might have become unfetched and fix them
          // by replacing their values with the previously observed values.
          var fetched = AV._traverse(self._serverData[key], function(object) {
            if (object instanceof AV.Object && fetchedObjects[object.id]) {
              return fetchedObjects[object.id];
            }
          });
          if (fetched) {
            self._serverData[key] = fetched;
          }
        });
        this._rebuildAllEstimatedData();
        const opSetQueue = this._opSetQueue.map(_.clone);
        this._refreshCache();
        this._opSetQueue = opSetQueue;
        this._saving = this._saving - 1;
      },

      /**
       * Called when a fetch or login is complete to set the known server data to
       * the given object.
       * @private
       */
      _finishFetch: function(serverData, hasData) {
        // Clear out any changes the user might have made previously.
        this._opSetQueue = [{}];

        // Bring in all the new server data.
        this._mergeMagicFields(serverData);
        var self = this;
        AV._objectEach(serverData, function(value, key) {
          self._serverData[key] = AV._decode(value, key);
        });

        // Refresh the attributes.
        this._rebuildAllEstimatedData();

        // Clear out the cache of mutable containers.
        this._refreshCache();
        this._opSetQueue = [{}];

        this._hasData = hasData;
      },

      /**
       * Applies the set of AV.Op in opSet to the object target.
       * @private
       */
      _applyOpSet: function(opSet, target) {
        var self = this;
        AV._objectEach(opSet, function(change, key) {
          const [value, actualTarget, actualKey] = findValue(target, key);
          setValue(target, key, change._estimate(value, self, key));
          if (actualTarget && actualTarget[actualKey] === AV.Op._UNSET) {
            delete actualTarget[actualKey];
          }
        });
      },

      /**
       * Replaces the cached value for key with the current value.
       * Returns true if the new value is different than the old value.
       * @private
       */
      _resetCacheForKey: function(key) {
        var value = this.attributes[key];
        if (
          _.isObject(value) &&
          !(value instanceof AV.Object) &&
          !(value instanceof AV.File)
        ) {
          var json = JSON.stringify(recursiveToPointer(value));
          if (this._hashedJSON[key] !== json) {
            var wasSet = !!this._hashedJSON[key];
            this._hashedJSON[key] = json;
            return wasSet;
          }
        }
        return false;
      },

      /**
       * Populates attributes[key] by starting with the last known data from the
       * server, and applying all of the local changes that have been made to that
       * key since then.
       * @private
       */
      _rebuildEstimatedDataForKey: function(key) {
        var self = this;
        delete this.attributes[key];
        if (this._serverData[key]) {
          this.attributes[key] = this._serverData[key];
        }
        AV._arrayEach(this._opSetQueue, function(opSet) {
          var op = opSet[key];
          if (op) {
            const [value, actualTarget, actualKey, firstKey] = findValue(
              self.attributes,
              key
            );
            setValue(self.attributes, key, op._estimate(value, self, key));
            if (actualTarget && actualTarget[actualKey] === AV.Op._UNSET) {
              delete actualTarget[actualKey];
            }
            self._resetCacheForKey(firstKey);
          }
        });
      },

      /**
       * Populates attributes by starting with the last known data from the
       * server, and applying all of the local changes that have been made since
       * then.
       * @private
       */
      _rebuildAllEstimatedData: function() {
        var self = this;

        var previousAttributes = _.clone(this.attributes);

        this.attributes = _.clone(this._serverData);
        AV._arrayEach(this._opSetQueue, function(opSet) {
          self._applyOpSet(opSet, self.attributes);
          AV._objectEach(opSet, function(op, key) {
            self._resetCacheForKey(key);
          });
        });

        // Trigger change events for anything that changed because of the fetch.
        AV._objectEach(previousAttributes, function(oldValue, key) {
          if (self.attributes[key] !== oldValue) {
            self.trigger('change:' + key, self, self.attributes[key], {});
          }
        });
        AV._objectEach(this.attributes, function(newValue, key) {
          if (!_.has(previousAttributes, key)) {
            self.trigger('change:' + key, self, newValue, {});
          }
        });
      },

      /**
       * Sets a hash of model attributes on the object, firing
       * <code>"change"</code> unless you choose to silence it.
       *
       * <p>You can call it with an object containing keys and values, or with one
       * key and value.  For example:</p>
       *
       * @example
       * gameTurn.set({
       *   player: player1,
       *   diceRoll: 2
       * });
       *
       * game.set("currentPlayer", player2);
       *
       * game.set("finished", true);
       *
       * @param {String} key The key to set.
       * @param {Any} value The value to give it.
       * @param {Object} [options]
       * @param {Boolean} [options.silent]
       * @return {AV.Object} self if succeeded, throws if the value is not valid.
       * @see AV.Object#validate
       */
      set: function(key, value, options) {
        var attrs;
        if (_.isObject(key) || isNullOrUndefined(key)) {
          attrs = _.mapObject(key, function(v, k) {
            checkReservedKey(k);
            return AV._decode(v, k);
          });
          options = value;
        } else {
          attrs = {};
          checkReservedKey(key);
          attrs[key] = AV._decode(value, key);
        }

        // Extract attributes and options.
        options = options || {};
        if (!attrs) {
          return this;
        }
        if (attrs instanceof AV.Object) {
          attrs = attrs.attributes;
        }

        // If the unset option is used, every attribute should be a Unset.
        if (options.unset) {
          AV._objectEach(attrs, function(unused_value, key) {
            attrs[key] = new AV.Op.Unset();
          });
        }

        // Apply all the attributes to get the estimated values.
        var dataToValidate = _.clone(attrs);
        var self = this;
        AV._objectEach(dataToValidate, function(value, key) {
          if (value instanceof AV.Op) {
            dataToValidate[key] = value._estimate(
              self.attributes[key],
              self,
              key
            );
            if (dataToValidate[key] === AV.Op._UNSET) {
              delete dataToValidate[key];
            }
          }
        });

        // Run validation.
        this._validate(attrs, options);

        options.changes = {};
        var escaped = this._escapedAttributes;

        // Update attributes.
        AV._arrayEach(_.keys(attrs), function(attr) {
          var val = attrs[attr];

          // If this is a relation object we need to set the parent correctly,
          // since the location where it was parsed does not have access to
          // this object.
          if (val instanceof AV.Relation) {
            val.parent = self;
          }

          if (!(val instanceof AV.Op)) {
            val = new AV.Op.Set(val);
          }

          // See if this change will actually have any effect.
          var isRealChange = true;
          if (
            val instanceof AV.Op.Set &&
            _.isEqual(self.attributes[attr], val.value)
          ) {
            isRealChange = false;
          }

          if (isRealChange) {
            delete escaped[attr];
            if (options.silent) {
              self._silent[attr] = true;
            } else {
              options.changes[attr] = true;
            }
          }

          var currentChanges = _.last(self._opSetQueue);
          currentChanges[attr] = val._mergeWithPrevious(currentChanges[attr]);
          self._rebuildEstimatedDataForKey(attr);

          if (isRealChange) {
            self.changed[attr] = self.attributes[attr];
            if (!options.silent) {
              self._pending[attr] = true;
            }
          } else {
            delete self.changed[attr];
            delete self._pending[attr];
          }
        });

        if (!options.silent) {
          this.change(options);
        }
        return this;
      },

      /**
       * Remove an attribute from the model, firing <code>"change"</code> unless
       * you choose to silence it. This is a noop if the attribute doesn't
       * exist.
       * @param key {String} The key.
       */
      unset: function(attr, options) {
        options = options || {};
        options.unset = true;
        return this.set(attr, null, options);
      },

      /**
       * Atomically increments the value of the given attribute the next time the
       * object is saved. If no amount is specified, 1 is used by default.
       *
       * @param key {String} The key.
       * @param amount {Number} The amount to increment by.
       */
      increment: function(attr, amount) {
        if (_.isUndefined(amount) || _.isNull(amount)) {
          amount = 1;
        }
        return this.set(attr, new AV.Op.Increment(amount));
      },

      /**
       * Atomically add an object to the end of the array associated with a given
       * key.
       * @param key {String} The key.
       * @param item {} The item to add.
       */
      add: function(attr, item) {
        return this.set(attr, new AV.Op.Add(ensureArray(item)));
      },

      /**
       * Atomically add an object to the array associated with a given key, only
       * if it is not already present in the array. The position of the insert is
       * not guaranteed.
       *
       * @param key {String} The key.
       * @param item {} The object to add.
       */
      addUnique: function(attr, item) {
        return this.set(attr, new AV.Op.AddUnique(ensureArray(item)));
      },

      /**
       * Atomically remove all instances of an object from the array associated
       * with a given key.
       *
       * @param key {String} The key.
       * @param item {} The object to remove.
       */
      remove: function(attr, item) {
        return this.set(attr, new AV.Op.Remove(ensureArray(item)));
      },

      /**
       * Atomically apply a "bit and" operation on the value associated with a
       * given key.
       *
       * @param key {String} The key.
       * @param value {Number} The value to apply.
       */
      bitAnd(attr, value) {
        return this.set(attr, new AV.Op.BitAnd(value));
      },

      /**
       * Atomically apply a "bit or" operation on the value associated with a
       * given key.
       *
       * @param key {String} The key.
       * @param value {Number} The value to apply.
       */
      bitOr(attr, value) {
        return this.set(attr, new AV.Op.BitOr(value));
      },

      /**
       * Atomically apply a "bit xor" operation on the value associated with a
       * given key.
       *
       * @param key {String} The key.
       * @param value {Number} The value to apply.
       */
      bitXor(attr, value) {
        return this.set(attr, new AV.Op.BitXor(value));
      },

      /**
       * Returns an instance of a subclass of AV.Op describing what kind of
       * modification has been performed on this field since the last time it was
       * saved. For example, after calling object.increment("x"), calling
       * object.op("x") would return an instance of AV.Op.Increment.
       *
       * @param key {String} The key.
       * @returns {AV.Op} The operation, or undefined if none.
       */
      op: function(attr) {
        return _.last(this._opSetQueue)[attr];
      },

      /**
       * Clear all attributes on the model, firing <code>"change"</code> unless
       * you choose to silence it.
       */
      clear: function(options) {
        options = options || {};
        options.unset = true;
        var keysToClear = _.extend(this.attributes, this._operations);
        return this.set(keysToClear, options);
      },

      /**
       * Clears any (or specific) changes to the model made since the last save.
       * @param {string|string[]} [keys] specify keys to revert.
       */
      revert(keys) {
        const lastOp = _.last(this._opSetQueue);
        const _keys = ensureArray(keys || _.keys(lastOp));
        _keys.forEach(key => {
          delete lastOp[key];
        });
        this._rebuildAllEstimatedData();
        return this;
      },

      /**
       * Returns a JSON-encoded set of operations to be sent with the next save
       * request.
       * @private
       */
      _getSaveJSON: function() {
        var json = _.clone(_.first(this._opSetQueue));
        AV._objectEach(json, function(op, key) {
          json[key] = op.toJSON();
        });
        return json;
      },

      /**
       * Returns true if this object can be serialized for saving.
       * @private
       */
      _canBeSerialized: function() {
        return AV.Object._canBeSerializedAsValue(this.attributes);
      },

      /**
       * Fetch the model from the server. If the server's representation of the
       * model differs from its current attributes, they will be overriden,
       * triggering a <code>"change"</code> event.
       * @param {Object} fetchOptions Optional options to set 'keys',
       *      'include' and 'includeACL' option.
       * @param {AuthOptions} options
       * @return {Promise} A promise that is fulfilled when the fetch
       *     completes.
       */
      fetch: function(fetchOptions = {}, options) {
        if (!this.id) {
          throw new Error('Cannot fetch unsaved object');
        }
        var self = this;
        var request = _request(
          'classes',
          this.className,
          this.id,
          'GET',
          transformFetchOptions(fetchOptions),
          options
        );
        return request.then(function(response) {
          const fetchedAttrs = self.parse(response);
          self._cleanupUnsetKeys(
            fetchedAttrs,
            fetchOptions.keys
              ? ensureArray(fetchOptions.keys)
                  .join(',')
                  .split(',')
              : undefined
          );
          self._finishFetch(fetchedAttrs, true);
          return self;
        });
      },

      _cleanupUnsetKeys(fetchedAttrs, fetchedKeys = _.keys(this._serverData)) {
        _.forEach(fetchedKeys, key => {
          if (fetchedAttrs[key] === undefined) delete this._serverData[key];
        });
      },

      /**
       * Set a hash of model attributes, and save the model to the server.
       * updatedAt will be updated when the request returns.
       * You can either call it as:<pre>
       *   object.save();</pre>
       * or<pre>
       *   object.save(null, options);</pre>
       * or<pre>
       *   object.save(attrs, options);</pre>
       * or<pre>
       *   object.save(key, value, options);</pre>
       *
       * @example
       * gameTurn.save({
       *   player: "Jake Cutter",
       *   diceRoll: 2
       * }).then(function(gameTurnAgain) {
       *   // The save was successful.
       * }, function(error) {
       *   // The save failed.  Error is an instance of AVError.
       * });
       *
       * @param {AuthOptions} options AuthOptions plus:
       * @param {Boolean} options.fetchWhenSave fetch and update object after save succeeded
       * @param {AV.Query} options.query Save object only when it matches the query
       * @return {Promise} A promise that is fulfilled when the save
       *     completes.
       * @see AVError
       */
      save: function(arg1, arg2, arg3) {
        var attrs, current, options;
        if (_.isObject(arg1) || isNullOrUndefined(arg1)) {
          attrs = arg1;
          options = arg2;
        } else {
          attrs = {};
          attrs[arg1] = arg2;
          options = arg3;
        }

        options = _.clone(options) || {};
        if (options.wait) {
          current = _.clone(this.attributes);
        }

        var setOptions = _.clone(options) || {};
        if (setOptions.wait) {
          setOptions.silent = true;
        }
        if (attrs) {
          this.set(attrs, setOptions);
        }

        var model = this;

        var unsavedChildren = [];
        var unsavedFiles = [];
        AV.Object._findUnsavedChildren(model, unsavedChildren, unsavedFiles);
        if (unsavedChildren.length + unsavedFiles.length > 1) {
          return AV.Object._deepSaveAsync(this, model, options);
        }

        this._startSave();
        this._saving = (this._saving || 0) + 1;

        this._allPreviousSaves = this._allPreviousSaves || Promise.resolve();
        this._allPreviousSaves = this._allPreviousSaves
          .catch(e => {})
          .then(function() {
            var method = model.id ? 'PUT' : 'POST';

            var json = model._getSaveJSON();
            var query = {};

            if (model._fetchWhenSave || options.fetchWhenSave) {
              query['new'] = 'true';
            }
            // user login option
            if (options._failOnNotExist) {
              query.failOnNotExist = 'true';
            }

            if (options.query) {
              var queryParams;
              if (typeof options.query._getParams === 'function') {
                queryParams = options.query._getParams();
                if (queryParams) {
                  query.where = queryParams.where;
                }
              }
              if (!query.where) {
                var error = new Error('options.query is not an AV.Query');
                throw error;
              }
            }

            _.extend(json, model._flags);

            var route = 'classes';
            var className = model.className;
            if (model.className === '_User' && !model.id) {
              // Special-case user sign-up.
              route = 'users';
              className = null;
            }
            //hook makeRequest in options.
            var makeRequest = options._makeRequest || _request;
            var requestPromise = makeRequest(
              route,
              className,
              model.id,
              method,
              json,
              options,
              query
            );

            requestPromise = requestPromise.then(
              function(resp) {
                var serverAttrs = model.parse(resp);
                if (options.wait) {
                  serverAttrs = _.extend(attrs || {}, serverAttrs);
                }
                model._finishSave(serverAttrs);
                if (options.wait) {
                  model.set(current, setOptions);
                }
                return model;
              },
              function(error) {
                model._cancelSave();
                throw error;
              }
            );

            return requestPromise;
          });
        return this._allPreviousSaves;
      },

      /**
       * Destroy this model on the server if it was already persisted.
       * Optimistically removes the model from its collection, if it has one.
       * @param {AuthOptions} options AuthOptions plus:
       * @param {Boolean} [options.wait] wait for the server to respond
       * before removal.
       *
       * @return {Promise} A promise that is fulfilled when the destroy
       *     completes.
       */
      destroy: function(options) {
        options = options || {};
        var model = this;

        var triggerDestroy = function() {
          model.trigger('destroy', model, model.collection, options);
        };

        if (!this.id) {
          return triggerDestroy();
        }

        if (!options.wait) {
          triggerDestroy();
        }

        var request = _request(
          'classes',
          this.className,
          this.id,
          'DELETE',
          this._flags,
          options
        );
        return request.then(function() {
          if (options.wait) {
            triggerDestroy();
          }
          return model;
        });
      },

      /**
       * Converts a response into the hash of attributes to be set on the model.
       * @ignore
       */
      parse: function(resp) {
        var output = _.clone(resp);
        ['createdAt', 'updatedAt'].forEach(function(key) {
          if (output[key]) {
            output[key] = AV._parseDate(output[key]);
          }
        });
        if (output.createdAt && !output.updatedAt) {
          output.updatedAt = output.createdAt;
        }
        return output;
      },

      /**
       * Creates a new model with identical attributes to this one.
       * @return {AV.Object}
       */
      clone: function() {
        return new this.constructor(this.attributes);
      },

      /**
       * Returns true if this object has never been saved to AV.
       * @return {Boolean}
       */
      isNew: function() {
        return !this.id;
      },

      /**
       * Call this method to manually fire a `"change"` event for this model and
       * a `"change:attribute"` event for each changed attribute.
       * Calling this will cause all objects observing the model to update.
       */
      change: function(options) {
        options = options || {};
        var changing = this._changing;
        this._changing = true;

        // Silent changes become pending changes.
        var self = this;
        AV._objectEach(this._silent, function(attr) {
          self._pending[attr] = true;
        });

        // Silent changes are triggered.
        var changes = _.extend({}, options.changes, this._silent);
        this._silent = {};
        AV._objectEach(changes, function(unused_value, attr) {
          self.trigger('change:' + attr, self, self.get(attr), options);
        });
        if (changing) {
          return this;
        }

        // This is to get around lint not letting us make a function in a loop.
        var deleteChanged = function(value, attr) {
          if (!self._pending[attr] && !self._silent[attr]) {
            delete self.changed[attr];
          }
        };

        // Continue firing `"change"` events while there are pending changes.
        while (!_.isEmpty(this._pending)) {
          this._pending = {};
          this.trigger('change', this, options);
          // Pending and silent changes still remain.
          AV._objectEach(this.changed, deleteChanged);
          self._previousAttributes = _.clone(this.attributes);
        }

        this._changing = false;
        return this;
      },

      /**
       * Gets the previous value of an attribute, recorded at the time the last
       * <code>"change"</code> event was fired.
       * @param {String} attr Name of the attribute to get.
       */
      previous: function(attr) {
        if (!arguments.length || !this._previousAttributes) {
          return null;
        }
        return this._previousAttributes[attr];
      },

      /**
       * Gets all of the attributes of the model at the time of the previous
       * <code>"change"</code> event.
       * @return {Object}
       */
      previousAttributes: function() {
        return _.clone(this._previousAttributes);
      },

      /**
       * Checks if the model is currently in a valid state. It's only possible to
       * get into an *invalid* state if you're using silent changes.
       * @return {Boolean}
       */
      isValid: function() {
        try {
          this.validate(this.attributes);
        } catch (error) {
          return false;
        }
        return true;
      },

      /**
       * You should not call this function directly unless you subclass
       * <code>AV.Object</code>, in which case you can override this method
       * to provide additional validation on <code>set</code> and
       * <code>save</code>.  Your implementation should throw an Error if
       * the attrs is invalid
       *
       * @param {Object} attrs The current data to validate.
       * @see AV.Object#set
       */
      validate: function(attrs) {
        if (_.has(attrs, 'ACL') && !(attrs.ACL instanceof AV.ACL)) {
          throw new AVError(AVError.OTHER_CAUSE, 'ACL must be a AV.ACL.');
        }
      },

      /**
       * Run validation against a set of incoming attributes, returning `true`
       * if all is well. If a specific `error` callback has been passed,
       * call that instead of firing the general `"error"` event.
       * @private
       */
      _validate: function(attrs, options) {
        if (options.silent || !this.validate) {
          return;
        }
        attrs = _.extend({}, this.attributes, attrs);
        this.validate(attrs);
      },

      /**
       * Returns the ACL for this object.
       * @returns {AV.ACL} An instance of AV.ACL.
       * @see AV.Object#get
       */
      getACL: function() {
        return this.get('ACL');
      },

      /**
       * Sets the ACL to be used for this object.
       * @param {AV.ACL} acl An instance of AV.ACL.
       * @param {Object} options Optional Backbone-like options object to be
       *     passed in to set.
       * @return {AV.Object} self
       * @see AV.Object#set
       */
      setACL: function(acl, options) {
        return this.set('ACL', acl, options);
      },

      disableBeforeHook: function() {
        this.ignoreHook('beforeSave');
        this.ignoreHook('beforeUpdate');
        this.ignoreHook('beforeDelete');
      },

      disableAfterHook: function() {
        this.ignoreHook('afterSave');
        this.ignoreHook('afterUpdate');
        this.ignoreHook('afterDelete');
      },

      ignoreHook: function(hookName) {
        if (
          !_.contains(
            [
              'beforeSave',
              'afterSave',
              'beforeUpdate',
              'afterUpdate',
              'beforeDelete',
              'afterDelete',
            ],
            hookName
          )
        ) {
          throw new Error('Unsupported hookName: ' + hookName);
        }

        if (!AV.hookKey) {
          throw new Error('ignoreHook required hookKey');
        }

        if (!this._flags.__ignore_hooks) {
          this._flags.__ignore_hooks = [];
        }

        this._flags.__ignore_hooks.push(hookName);
      },
    }
  );

  /**
   * Creates an instance of a subclass of AV.Object for the give classname
   * and id.
   * @param  {String|Function} class the className or a subclass of AV.Object.
   * @param {String} id The object id of this model.
   * @return {AV.Object} A new subclass instance of AV.Object.
   */
  AV.Object.createWithoutData = (klass, id, hasData) => {
    let _klass;
    if (_.isString(klass)) {
      _klass = AV.Object._getSubclass(klass);
    } else if (klass.prototype && klass.prototype instanceof AV.Object) {
      _klass = klass;
    } else {
      throw new Error('class must be a string or a subclass of AV.Object.');
    }
    if (!id) {
      throw new TypeError('The objectId must be provided');
    }
    const object = new _klass();
    object.id = id;
    object._hasData = hasData;
    return object;
  };
  /**
   * Delete objects in batch.
   * @param {AV.Object[]} objects The <code>AV.Object</code> array to be deleted.
   * @param {AuthOptions} options
   * @return {Promise} A promise that is fulfilled when the save
   *     completes.
   */
  AV.Object.destroyAll = function(objects, options = {}) {
    if (!objects || objects.length === 0) {
      return Promise.resolve();
    }
    const objectsByClassNameAndFlags = _.groupBy(objects, object =>
      JSON.stringify({
        className: object.className,
        flags: object._flags,
      })
    );
    const body = {
      requests: _.map(objectsByClassNameAndFlags, objects => {
        const ids = _.map(objects, 'id').join(',');
        return {
          method: 'DELETE',
          path: `/1.1/classes/${objects[0].className}/${ids}`,
          body: objects[0]._flags,
        };
      }),
    };
    return _request('batch', null, null, 'POST', body, options).then(
      response => {
        const firstError = _.find(response, result => !result.success);
        if (firstError)
          throw new AVError(firstError.error.code, firstError.error.error);
        return undefined;
      }
    );
  };

  /**
   * Returns the appropriate subclass for making new instances of the given
   * className string.
   * @private
   */
  AV.Object._getSubclass = function(className) {
    if (!_.isString(className)) {
      throw new Error('AV.Object._getSubclass requires a string argument.');
    }
    var ObjectClass = AV.Object._classMap[className];
    if (!ObjectClass) {
      ObjectClass = AV.Object.extend(className);
      AV.Object._classMap[className] = ObjectClass;
    }
    return ObjectClass;
  };

  /**
   * Creates an instance of a subclass of AV.Object for the given classname.
   * @private
   */
  AV.Object._create = function(className, attributes, options) {
    var ObjectClass = AV.Object._getSubclass(className);
    return new ObjectClass(attributes, options);
  };

  // Set up a map of className to class so that we can create new instances of
  // AV Objects from JSON automatically.
  AV.Object._classMap = {};

  AV.Object._extend = AV._extend;

  /**
   * Creates a new model with defined attributes,
   * It's the same with
   * <pre>
   *   new AV.Object(attributes, options);
   *  </pre>
   * @param {Object} attributes The initial set of data to store in the object.
   * @param {Object} options A set of Backbone-like options for creating the
   *     object.  The only option currently supported is "collection".
   * @return {AV.Object}
   * @since v0.4.4
   * @see AV.Object
   * @see AV.Object.extend
   */
  AV.Object['new'] = function(attributes, options) {
    return new AV.Object(attributes, options);
  };

  /**
   * Creates a new subclass of AV.Object for the given AV class name.
   *
   * <p>Every extension of a AV class will inherit from the most recent
   * previous extension of that class. When a AV.Object is automatically
   * created by parsing JSON, it will use the most recent extension of that
   * class.</p>
   *
   * @example
   * var MyClass = AV.Object.extend("MyClass", {
   *     // Instance properties
   * }, {
   *     // Class properties
   * });
   *
   * @param {String} className The name of the AV class backing this model.
   * @param {Object} protoProps Instance properties to add to instances of the
   *     class returned from this method.
   * @param {Object} classProps Class properties to add the class returned from
   *     this method.
   * @return {Class} A new subclass of AV.Object.
   */
  AV.Object.extend = function(className, protoProps, classProps) {
    // Handle the case with only two args.
    if (!_.isString(className)) {
      if (className && _.has(className, 'className')) {
        return AV.Object.extend(className.className, className, protoProps);
      } else {
        throw new Error(
          "AV.Object.extend's first argument should be the className."
        );
      }
    }

    // If someone tries to subclass "User", coerce it to the right type.
    if (className === 'User') {
      className = '_User';
    }

    var NewClassObject = null;
    if (_.has(AV.Object._classMap, className)) {
      var OldClassObject = AV.Object._classMap[className];
      // This new subclass has been told to extend both from "this" and from
      // OldClassObject. This is multiple inheritance, which isn't supported.
      // For now, let's just pick one.
      if (protoProps || classProps) {
        NewClassObject = OldClassObject._extend(protoProps, classProps);
      } else {
        return OldClassObject;
      }
    } else {
      protoProps = protoProps || {};
      protoProps._className = className;
      NewClassObject = this._extend(protoProps, classProps);
    }
    // Extending a subclass should reuse the classname automatically.
    NewClassObject.extend = function(arg0) {
      if (_.isString(arg0) || (arg0 && _.has(arg0, 'className'))) {
        return AV.Object.extend.apply(NewClassObject, arguments);
      }
      var newArguments = [className].concat(_.toArray(arguments));
      return AV.Object.extend.apply(NewClassObject, newArguments);
    };
    // Add the query property descriptor.
    Object.defineProperty(
      NewClassObject,
      'query',
      Object.getOwnPropertyDescriptor(AV.Object, 'query')
    );
    NewClassObject['new'] = function(attributes, options) {
      return new NewClassObject(attributes, options);
    };
    AV.Object._classMap[className] = NewClassObject;
    return NewClassObject;
  };

  // ES6 class syntax support
  Object.defineProperty(AV.Object.prototype, 'className', {
    get: function() {
      const className =
        this._className ||
        this.constructor._LCClassName ||
        this.constructor.name;
      // If someone tries to subclass "User", coerce it to the right type.
      if (className === 'User') {
        return '_User';
      }
      return className;
    },
  });

  /**
   * Register a class.
   * If a subclass of <code>AV.Object</code> is defined with your own implement
   * rather then <code>AV.Object.extend</code>, the subclass must be registered.
   * @param {Function} klass A subclass of <code>AV.Object</code>
   * @param {String} [name] Specify the name of the class. Useful when the class might be uglified.
   * @example
   * class Person extend AV.Object {}
   * AV.Object.register(Person);
   */
  AV.Object.register = (klass, name) => {
    if (!(klass.prototype instanceof AV.Object)) {
      throw new Error('registered class is not a subclass of AV.Object');
    }
    const className = name || klass.name;
    if (!className.length) {
      throw new Error('registered class must be named');
    }
    if (name) {
      klass._LCClassName = name;
    }
    AV.Object._classMap[className] = klass;
  };

  /**
   * Get a new Query of the current class
   * @name query
   * @memberof AV.Object
   * @type AV.Query
   * @readonly
   * @since v3.1.0
   * @example
   * const Post = AV.Object.extend('Post');
   * Post.query.equalTo('author', 'leancloud').find().then();
   */
  Object.defineProperty(AV.Object, 'query', {
    get() {
      return new AV.Query(this.prototype.className);
    },
  });

  AV.Object._findUnsavedChildren = function(objects, children, files) {
    AV._traverse(objects, function(object) {
      if (object instanceof AV.Object) {
        if (object.dirty()) {
          children.push(object);
        }
        return;
      }

      if (object instanceof AV.File) {
        if (!object.id) {
          files.push(object);
        }
        return;
      }
    });
  };

  AV.Object._canBeSerializedAsValue = function(object) {
    var canBeSerializedAsValue = true;

    if (object instanceof AV.Object || object instanceof AV.File) {
      canBeSerializedAsValue = !!object.id;
    } else if (_.isArray(object)) {
      AV._arrayEach(object, function(child) {
        if (!AV.Object._canBeSerializedAsValue(child)) {
          canBeSerializedAsValue = false;
        }
      });
    } else if (_.isObject(object)) {
      AV._objectEach(object, function(child) {
        if (!AV.Object._canBeSerializedAsValue(child)) {
          canBeSerializedAsValue = false;
        }
      });
    }

    return canBeSerializedAsValue;
  };

  AV.Object._deepSaveAsync = function(object, model, options) {
    var unsavedChildren = [];
    var unsavedFiles = [];
    AV.Object._findUnsavedChildren(object, unsavedChildren, unsavedFiles);

    unsavedFiles = _.uniq(unsavedFiles);

    var promise = Promise.resolve();
    _.each(unsavedFiles, function(file) {
      promise = promise.then(function() {
        return file.save();
      });
    });

    var objects = _.uniq(unsavedChildren);
    var remaining = _.uniq(objects);

    return promise
      .then(function() {
        return continueWhile(
          function() {
            return remaining.length > 0;
          },
          function() {
            // Gather up all the objects that can be saved in this batch.
            var batch = [];
            var newRemaining = [];
            AV._arrayEach(remaining, function(object) {
              if (object._canBeSerialized()) {
                batch.push(object);
              } else {
                newRemaining.push(object);
              }
            });
            remaining = newRemaining;

            // If we can't save any objects, there must be a circular reference.
            if (batch.length === 0) {
              return Promise.reject(
                new AVError(
                  AVError.OTHER_CAUSE,
                  'Tried to save a batch with a cycle.'
                )
              );
            }

            // Reserve a spot in every object's save queue.
            var readyToStart = Promise.resolve(
              _.map(batch, function(object) {
                return object._allPreviousSaves || Promise.resolve();
              })
            );

            // Save a single batch, whether previous saves succeeded or failed.
            const bathSavePromise = readyToStart.then(() =>
              _request(
                'batch',
                null,
                null,
                'POST',
                {
                  requests: _.map(batch, function(object) {
                    var method = object.id ? 'PUT' : 'POST';

                    var json = object._getSaveJSON();

                    _.extend(json, object._flags);

                    var route = 'classes';
                    var className = object.className;
                    var path = `/${route}/${className}`;
                    if (object.className === '_User' && !object.id) {
                      // Special-case user sign-up.
                      path = '/users';
                    }

                    var path = `/1.1${path}`;
                    if (object.id) {
                      path = path + '/' + object.id;
                    }

                    object._startSave();

                    return {
                      method: method,
                      path: path,
                      body: json,
                      params:
                        options && options.fetchWhenSave
                          ? { fetchWhenSave: true }
                          : undefined,
                    };
                  }),
                },
                options
              ).then(function(response) {
                const results = _.map(batch, function(object, i) {
                  if (response[i].success) {
                    object._finishSave(object.parse(response[i].success));
                    return object;
                  }
                  object._cancelSave();
                  return new AVError(
                    response[i].error.code,
                    response[i].error.error
                  );
                });
                return handleBatchResults(results);
              })
            );
            AV._arrayEach(batch, function(object) {
              object._allPreviousSaves = bathSavePromise;
            });
            return bathSavePromise;
          }
        );
      })
      .then(function() {
        return object;
      });
  };
};

pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy