UNPKG

leancloud-storage

Version:
1,539 lines (1,397 loc) 49 kB
'use strict'; var _ = require('underscore'); var AVError = require('./error'); var AVRequest = require('./request').request; var utils = require('./utils'); var RESERVED_KEYS = ['objectId', 'createdAt', 'updatedAt']; var checkReservedKey = function checkReservedKey(key) { if (RESERVED_KEYS.indexOf(key) !== -1) { throw new Error('key[' + key + '] is reserved'); } }; // 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 = AV._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.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. * * <pre> * 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 = function (objects, options) { return AV.Promise.resolve().then(function () { return AVRequest('batch', null, null, 'POST', { requests: _.map(objects, function (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) { _.forEach(objects, function (object, i) { if (response[i].success) { object._finishFetch(object.parse(response[i].success)); } else { var error = new Error(response[i].error.error); error.code = response[i].error.code; throw error; } }); return objects; }); }; // 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 initialize() {}, /** * 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 fetchWhenSave(enable) { console.warn('AV.Object#fetchWhenSave is deprecated, use AV.Object#save with options.fetchWhenSave instead.'); if (!_.isBoolean(enable)) { throw "Expect boolean value for fetchWhenSave"; } this._fetchWhenSave = enable; }, /** * Returns the object's objectId. * @return {String} the objectId. */ getObjectId: function getObjectId() { return this.id; }, /** * Returns the object's createdAt attribute. * @return {Date} */ getCreatedAt: function getCreatedAt() { return this.createdAt || this.get('createdAt'); }, /** * Returns the object's updatedAt attribute. * @return {Date} */ getUpdatedAt: function getUpdatedAt() { return this.updatedAt || this.get('updatedAt'); }, /** * Returns a JSON version of the object suitable for saving to AV. * @return {Object} */ toJSON: function toJSON() { var json = this._toFullJSON(); AV._arrayEach(["__type", "className"], function (key) { delete json[key]; }); return json; }, _toFullJSON: function _toFullJSON(seenObjects) { var json = _.clone(this.attributes); AV._objectEach(json, function (val, key) { json[key] = AV._encode(val, seenObjects); }); AV._objectEach(this._operations, function (val, key) { json[key] = val; }); if (_.has(this, "id")) { json.objectId = this.id; } if (_.has(this, "createdAt")) { if (_.isDate(this.createdAt)) { json.createdAt = this.createdAt.toJSON(); } else { json.createdAt = this.createdAt; } } if (_.has(this, "updatedAt")) { if (_.isDate(this.updatedAt)) { json.updatedAt = this.updatedAt.toJSON(); } else { json.updatedAt = this.updatedAt; } } json.__type = "Object"; 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 _refreshCache() { 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 dirty(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; }, /** * Gets a Pointer referencing this Object. * @private */ _toPointer: function _toPointer() { // 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 get(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 relation(attr) { var value = this.get(attr); if (value) { if (!(value instanceof AV.Relation)) { throw "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 escape(attr) { var html = this._escapedAttributes[attr]; if (html) { return html; } var val = this.attributes[attr]; var escaped; if (utils.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 has(attr) { return !utils.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 _mergeMagicFields(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 _startSave() { 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 _cancelSave() { var self = this; 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 _finishSave(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(key, value); // 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(); 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 _finishFetch(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(key, value); }); // 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 _applyOpSet(opSet, target) { var self = this; AV._objectEach(opSet, function (change, key) { target[key] = change._estimate(target[key], self, key); if (target[key] === AV.Op._UNSET) { delete target[key]; } }); }, /** * 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 _resetCacheForKey(key) { var value = this.attributes[key]; if (_.isObject(value) && !(value instanceof AV.Object) && !(value instanceof AV.File)) { value = value.toJSON ? value.toJSON() : value; var json = JSON.stringify(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 _rebuildEstimatedDataForKey(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) { self.attributes[key] = op._estimate(self.attributes[key], self, key); if (self.attributes[key] === AV.Op._UNSET) { delete self.attributes[key]; } else { self._resetCacheForKey(key); } } }); }, /** * 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 _rebuildAllEstimatedData() { 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:<pre> * gameTurn.set({ * player: player1, * diceRoll: 2 * }, { * error: function(gameTurnAgain, error) { * // The set failed validation. * } * }); * * game.set("currentPlayer", player2, { * error: function(gameTurnAgain, error) { * // The set failed validation. * } * }); * * game.set("finished", true);</pre></p> * * @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 set(key, value, options) { var attrs, attr; if (_.isObject(key) || utils.isNullOrUndefined(key)) { attrs = key; AV._objectEach(attrs, function (v, k) { checkReservedKey(k); attrs[k] = AV._decode(k, v); }); options = value; } else { attrs = {}; checkReservedKey(key); attrs[key] = AV._decode(key, value); } // 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; var prev = this._previousAttributes || {}; // 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. */ unset: function unset(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 attr {String} The key. * @param amount {Number} The amount to increment by. */ increment: function increment(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 attr {String} The key. * @param item {} The item to add. */ add: function add(attr, item) { return this.set(attr, new AV.Op.Add(utils.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 attr {String} The key. * @param item {} The object to add. */ addUnique: function addUnique(attr, item) { return this.set(attr, new AV.Op.AddUnique(utils.ensureArray(item))); }, /** * Atomically remove all instances of an object from the array associated * with a given key. * * @param attr {String} The key. * @param item {} The object to remove. */ remove: function remove(attr, item) { return this.set(attr, new AV.Op.Remove(utils.ensureArray(item))); }, /** * 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 attr {String} The key. * @returns {AV.Op} The operation, or undefined if none. */ op: function op(attr) { return _.last(this._opSetQueue)[attr]; }, /** * Clear all attributes on the model, firing <code>"change"</code> unless * you choose to silence it. */ clear: function clear(options) { options = options || {}; options.unset = true; var keysToClear = _.extend(this.attributes, this._operations); return this.set(keysToClear, options); }, /** * Returns a JSON-encoded set of operations to be sent with the next save * request. * @private */ _getSaveJSON: function _getSaveJSON() { 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 _canBeSerialized() { 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' and * 'include' option. * @param {AuthOptions} options * @return {Promise} A promise that is fulfilled when the fetch * completes. */ fetch: function fetch() { var fetchOptions = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}; var options = arguments[1]; if (_.isArray(fetchOptions.keys)) { fetchOptions.keys = fetchOptions.keys.join(','); } if (_.isArray(fetchOptions.include)) { fetchOptions.include = fetchOptions.include.join(','); } var self = this; var request = AVRequest('classes', this.className, this.id, 'GET', fetchOptions, options); return request.then(function (response) { self._finishFetch(self.parse(response), true); return self; }); }, /** * 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> * * For example, <pre> * 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. * });</pre> * @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 {AV.Promise} A promise that is fulfilled when the save * completes. * @see AVError */ save: function save(arg1, arg2, arg3) { var i, attrs, current, options, saved; if (_.isObject(arg1) || utils.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; // If there is any unsaved child, save it first. model._refreshCache(); var unsavedChildren = []; var unsavedFiles = []; AV.Object._findUnsavedChildren(model.attributes, unsavedChildren, unsavedFiles); if (unsavedChildren.length + unsavedFiles.length > 0) { return AV.Object._deepSaveAsync(this.attributes, model, options).then(function () { return model.save(null, options); }); } this._startSave(); this._saving = (this._saving || 0) + 1; this._allPreviousSaves = this._allPreviousSaves || AV.Promise.resolve(); this._allPreviousSaves = this._allPreviousSaves.catch(function (e) {}).then(function () { var method = model.id ? 'PUT' : 'POST'; var json = model._getSaveJSON(); if (model._fetchWhenSave) { //Sepcial-case fetchWhenSave when updating object. json._fetchWhenSave = true; } if (options.fetchWhenSave) { json._fetchWhenSave = true; } if (options.query) { var queryJSON; if (typeof options.query.toJSON === 'function') { queryJSON = options.query.toJSON(); if (queryJSON) { json._where = queryJSON.where; } } if (!json._where) { var error = new Error('options.query is not an AV.Query'); throw error; } } 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 || AVRequest; var request = makeRequest(route, className, model.id, method, json, options); request = request.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 request; }); 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 destroy(options) { options = options || {}; var model = this; var triggerDestroy = function triggerDestroy() { model.trigger('destroy', model, model.collection, options); }; if (!this.id) { return triggerDestroy(); } if (!options.wait) { triggerDestroy(); } var request = AVRequest('classes', this.className, this.id, 'DELETE', null, 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 parse(resp) { var output = _.clone(resp); _(["createdAt", "updatedAt"]).each(function (key) { if (output[key]) { output[key] = AV._parseDate(output[key]); } }); if (!output.updatedAt) { output.updatedAt = output.createdAt; } return output; }, /** * Creates a new model with identical attributes to this one. * @return {AV.Object} */ clone: function clone() { return new this.constructor(this.attributes); }, /** * Returns true if this object has never been saved to AV. * @return {Boolean} */ isNew: function isNew() { 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 change(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 deleteChanged(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; }, /** * Determine if the model has changed since the last <code>"change"</code> * event. If you specify an attribute name, determine if that attribute * has changed. * @param {String} attr Optional attribute name * @return {Boolean} */ hasChanged: function hasChanged(attr) { if (!arguments.length) { return !_.isEmpty(this.changed); } return this.changed && _.has(this.changed, attr); }, /** * Returns an object containing all the attributes that have changed, or * false if there are no changed attributes. Useful for determining what * parts of a view need to be updated and/or what attributes need to be * persisted to the server. Unset attributes will be set to undefined. * You can also pass an attributes object to diff against the model, * determining if there *would be* a change. */ changedAttributes: function changedAttributes(diff) { if (!diff) { return this.hasChanged() ? _.clone(this.changed) : false; } var changed = {}; var old = this._previousAttributes; AV._objectEach(diff, function (diffVal, attr) { if (!_.isEqual(old[attr], diffVal)) { changed[attr] = diffVal; } }); return changed; }, /** * 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 previous(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 previousAttributes() { 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 isValid() { 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 validate(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 _validate(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 getACL() { 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 {Boolean} Whether the set passed validation. * @see AV.Object#set */ setACL: function setACL(acl, options) { return this.set("ACL", acl, options); } }); /** * Creates an instance of a subclass of AV.Object for the give classname * and id. * @param {String} className The name of the AV class backing this model. * @param {String} id The object id of this model. * @return {AV.Object} A new subclass instance of AV.Object. */ AV.Object.createWithoutData = function (className, id, hasData) { var result = new AV.Object(className); result.id = id; result._hasData = hasData; return result; }; /** * Delete objects in batch.The objects className must be the same. * @param {Array} 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) { options = options || {}; if (!objects || objects.length === 0) { return AV.Promise.resolve(); } var className = objects[0].className; var id = ""; var wasFirst = true; objects.forEach(function (obj) { if (obj.className != className) throw "AV.Object.destroyAll requires the argument object array's classNames must be the same"; if (!obj.id) throw "Could not delete unsaved object"; if (wasFirst) { id = obj.id; wasFirst = false; } else { id = id + ',' + obj.id; } }); var request = AVRequest('classes', className, id, 'DELETE', null, options); return request; }; /** * Returns the appropriate subclass for making new instances of the given * className string. * @private */ AV.Object._getSubclass = function (className) { if (!_.isString(className)) { throw "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> * * <p>You should call either:<pre> * var MyClass = AV.Object.extend("MyClass", { * <i>Instance properties</i> * }, { * <i>Class properties</i> * });</pre> * or, for Backbone compatibility:<pre> * var MyClass = AV.Object.extend({ * className: "MyClass", * <i>Other instance properties</i> * }, { * <i>Class properties</i> * });</pre></p> * * @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); }; 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 get() { var className = this._className || this.constructor.name; // If someone tries to subclass "User", coerce it to the right type. if (className === "User") { return "_User"; } return className; } }); AV.Object.register = function (klass) { if (!(klass.prototype instanceof AV.Object)) { throw new Error('registered class is not a subclass of AV.Object'); } var className = klass.name; if (!className.length) { throw new Error('registered class must be named'); } AV.Object._classMap[className] = klass; }; AV.Object._findUnsavedChildren = function (object, children, files) { AV._traverse(object, function (object) { if (object instanceof AV.Object) { object._refreshCache(); if (object.dirty()) { children.push(object); } return; } if (object instanceof AV.File) { if (!object.url() && !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); if (model) { unsavedChildren = _.filter(unsavedChildren, function (object) { return object != model; }); } var promise = AV.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 AV.Promise._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) { // Limit batches to 20 objects. if (batch.length > 20) { newRemaining.push(object); return; } 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 AV.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 = AV.Promise.resolve(_.map(batch, function (object) { return object._allPreviousSaves || AV.Promise.resolve(); })); // Save a single batch, whether previous saves succeeded or failed. var bathSavePromise = readyToStart.then(function () { return AVRequest("batch", null, null, "POST", { requests: _.map(batch, function (object) { var json = object._getSaveJSON(); var method = "POST"; var path = "/1.1/classes/" + object.className; if (object.id) { path = path + "/" + object.id; method = "PUT"; } object._startSave(); return { method: method, path: path, body: json }; }) }, options).then(function (response) { var error; AV._arrayEach(batch, function (object, i) { if (response[i].success) { object._finishSave(object.parse(response[i].success)); } else { error = error || response[i].error; object._cancelSave(); } }); if (error) { return AV.Promise.reject(new AVError(error.code, error.error)); } }); }); AV._arrayEach(batch, function (object) { object._allPreviousSaves = bathSavePromise; }); return bathSavePromise; }); }).then(function () { return object; }); }; };