(backbone-parse-es6):
backbone/src/ParseModel.js
'use strict';
import _ from 'underscore';
import Parse from 'parse';
import Model from 'backbone-es6/src/Model.js';
import BBUtils from 'backbone-es6/src/Utils.js';
import BackboneParseObject from './BackboneParseObject.js';
import Utils from 'typhonjs-core-utils/src/Utils.js';
import Debug from 'backbone-es6/src/Debug.js';
/**
* ParseModel - Models are the heart of any JavaScript application. (http://backbonejs.org/#Model)
* --------------
*
* This implementation of Backbone.Model is backed by a ParseObject. If a ParseObject is not provided in `options`
* then a `className` for the associated table must be defined as options.className or a getter method such as
* `get className() { return '<CLASSNAME>'; }`. All methods that trigger synchronization return an ES6 Promise or a
* ParsePromise. This includes the following methods: destroy, fetch, save. Rather than passing in a error or success
* callback one can use promises to post a follow up chain of actions to complete.
*
* Models are the heart of any JavaScript application, containing the interactive data as well as a large part of the
* logic surrounding it: conversions, validations, computed properties, and access control.
*
* Backbone-Parse-ES6 supports the older "extend" functionality of the Parse SDK. You can still use "extend" to extend
* Backbone.Model with your domain-specific methods, and Model provides a basic set of functionality for managing
* changes. Refer to `modelExtend` which provides the "extend" functionality for ParseModel. It differs from the
* standard Backbone extend functionality such that the first parameter requires a class name string for the
* associated table.
*
* It is recommended though to use ES6 syntax for working with Backbone-Parse-ES6 foregoing the older "extend"
* mechanism.
*
* Create a new model with the specified attributes. A client id (`cid`) is automatically generated & assigned for you.
*
* If you pass a {collection: ...} as the options, the model gains a collection property that will be used to indicate
* which collection the model belongs to, and is used to help compute the model's url. The model.collection property is
* normally created automatically when you first add a model to a collection. Note that the reverse is not true, as
* passing this option to the constructor will not automatically add the model to the collection. Useful, sometimes.
*
* If {parse: true} is passed as an option, the attributes will first be converted by parse before being set on the
* model.
*
* Please see the `Model` documentation for relevant information about the parent class / implementation.
*
* @example
* import Backbone from 'backbone';
*
* export default class MyModel extends Backbone.Model
* {
* initialize() { alert('initialized!); }
* }
*
* older extend example:
* export default Backbone.Model.extend('<CLASSNAME>',
* {
* initialize: { alert('initialized!); }
* });
*
* @example
* The following methods return a promise - destroy, fetch, save. An example on using promises for save:
*
* model.save().then(() =>
* {
* // success
* },
* (error) =>
* {
* // error
* });
*/
class ParseModel extends Model
{
/**
* When creating an instance of a model, you can pass in the initial values of the attributes, which will be set on
* the model. If you define an initialize function, it will be invoked when the model is created.
*
* @param {object} attributes - Optional attribute hash of original values to set.
* @param {object} options - Optional parameters
*/
constructor(attributes = {}, options = {})
{
super(attributes, _.extend({ abortCtor: true }, options));
// Allows child classes to abort constructor execution.
if (_.isBoolean(options.abortCtor) && options.abortCtor) { return; }
const hasClassNameGetter = !_.isUndefined(this.className);
const hasCollectionGetter = !_.isUndefined(this.collection);
const hasSubclassGetter = !_.isUndefined(this.subClasses);
if (hasClassNameGetter)
{
if (!_.isString(this.className))
{
throw new TypeError('ctor - getter for className is not a string.');
}
}
if (options.subClasses && !hasSubclassGetter)
{
/**
* Object hash of name / class to register as sub classes.
* @type {object}
*/
this.subClasses = options.subClasses;
}
// Verify any sub class data.
if (this.subClasses)
{
if (!_.isObject(this.subClasses))
{
throw new TypeError('ctor - subClasses is not an object hash.');
}
_.each(this.subClasses, (value, key) =>
{
if (!_.isString(key))
{
throw new TypeError('ctor - subClass key is not a string.');
}
if (!Utils.isTypeOf(value, ParseModel))
{
throw new TypeError(`ctor - subClass is not a sub class of ParseModel for key: ${key}`);
}
});
}
let adjustedClassName;
const classNameOrParseObject = options.parseObject || options.className;
Debug.log(`ParseModel - ctor - 0 - options.parseObject: ${options.parseObject}`, true);
if (classNameOrParseObject instanceof Parse.Object)
{
const parseObject = classNameOrParseObject;
// Insure that any getter for className is the same as the Parse.Object
if (hasClassNameGetter && this.className !== parseObject.className)
{
throw new Error(`ctor - getter className '${this.className}
' does not equal 'parseObject' className '${parseObject.className}'.`);
}
if (!(parseObject instanceof BackboneParseObject))
{
/**
* Parse proxy ParseObject
* @type {BackboneParseObject}
*/
this.parseObject = new BackboneParseObject(parseObject.className, parseObject.attributes);
this.parseObject.id = parseObject.id;
this.parseObject._localId = parseObject._localId;
}
else
{
this.parseObject = parseObject;
}
adjustedClassName = this.parseObject.className;
}
else // Attempt to create Parse.Object from classNameOrParseObject, getter, or from "extend" construction.
{
if (_.isString(classNameOrParseObject))
{
adjustedClassName = classNameOrParseObject;
this.parseObject = new BackboneParseObject(adjustedClassName, attributes);
}
// Check for getter "get className()" usage.
else if (hasClassNameGetter)
{
this.parseObject = new BackboneParseObject(this.className, attributes);
}
// Check for className via "extend" usage.
else if (!_.isUndefined(this.__proto__ && _.isString(this.__proto__.constructor.className)))
{
adjustedClassName = this.__proto__.constructor.className;
this.parseObject = new BackboneParseObject(adjustedClassName, attributes);
}
}
if (_.isUndefined(this.parseObject))
{
throw new TypeError('ctor - classNameOrParseObject is not a string or BackboneParseObject.');
}
if (!hasClassNameGetter)
{
/**
* Parse class name
* @type {string}
*/
this.className = adjustedClassName;
}
// Register the given subClasses if an object hash exists.
if (this.subClasses)
{
_.each(this.subClasses, (value, key) =>
{
Parse.Object.registerSubclass(key, value);
});
}
let attrs = attributes || {};
options.parse = true;
options.updateParseObject = _.isBoolean(options.updateParseObject) ? options.updateParseObject : true;
/**
* The prefix is used to create the client id which is used to identify models locally.
* You may want to override this if you're experiencing name clashes with model ids.
*
* @type {string}
*/
this.cidPrefix = 'c';
/**
* Client side ID
* @type {number}
*/
this.cid = _.uniqueId(this.cidPrefix);
/**
* The hash of attributes for this model.
* @type {object}
*/
this.attributes = {};
if (options.collection && !hasCollectionGetter)
{
/**
* A potentially associated collection.
* @type {Collection}
*/
this.collection = options.collection;
}
/**
* A hash of attributes whose current and previous value differ.
* @type {object}
*/
this.changed = {};
/**
* The value returned during the last failed validation.
* @type {*}
*/
this.validationError = null;
// Allows child classes to postpone initialization.
if (_.isBoolean(options.abortCtorInit) && options.abortCtorInit) { return; }
if (options.parse) { attrs = this.parse(this.parseObject, options) || {}; }
attrs = _.defaults({}, attrs, _.result(this, 'defaults'));
this.set(attrs, options);
this.initialize(this, arguments);
}
/**
* Returns a new instance of the model with identical attributes.
*
* @see http://backbonejs.org/#Model-clone
*
* @returns {*}
*/
clone()
{
return new this.constructor({}, { parseObject: this.parseObject.clone() });
}
/**
* Destroys the model on the server by delegating delete request to Backbone.sync and the associated ParseObject.
* Returns ParsePromise or ES6 Promise if the model isNew. Accepts success and error callbacks in the options hash,
* which will be passed (model, response, options). Triggers a "destroy" event on the model, which will bubble up
* through any collections that contain it, and a "sync" event, after the server has successfully acknowledged the
* model's deletion. Pass {wait: true} if you'd like to wait for the server to respond before removing the model
* from the collection.
*
* @example
* book.destroy().then(() => {
* // do something
* };
*
* @see http://backbonejs.org/#Model-destroy
*
* @param {object} options - Provides optional properties used in destroying a model.
* @returns {Promise|ParsePromise}
*/
destroy(options)
{
options = options ? _.clone(options) : {};
const success = options.success;
const wait = options.wait;
const destroy = () =>
{
this.stopListening();
this.trigger('destroy', this, this.collection, options);
};
options.success = (resp) =>
{
if (wait) { destroy(); }
if (success) { success.call(options.context, this, resp, options); }
if (!this.isNew()) { this.trigger('sync', this, resp, options); }
};
let xhr;
if (this.isNew())
{
xhr = new Promise((resolve) =>
{
_.defer(options.success);
resolve();
});
}
else
{
BBUtils.wrapError(this, options);
xhr = this.sync('delete', this, options);
}
if (!wait) { destroy(); }
return xhr;
}
/**
* Has this model been saved to the server yet? If the model does not yet have an id, it is considered to be new.
*
* @see http://backbonejs.org/#Model-isNew
*
* @returns {boolean}
*/
isNew()
{
return _.isUndefined(this.id);
}
/* eslint-disable no-unused-vars */
/**
* parse is called whenever a model's data is returned by the server, in fetch, and save. The function is passed the
* raw response object, and should return the attributes hash to be set on the model. This implementation
* requires a ParseObject and the attributes are directly taken from the attributes of the ParseObject. To keep
* parity with the Parse SDK the ID of the ParseObject is set as `this.id`.
*
* @see http://backbonejs.org/#Model-parse
*
* @param {object} resp - ParseObject
* @param {object} options - May include options.parseObject.
* @returns {object} Attributes from the ParseObject.
*/
parse(resp, options)
{
/* eslint-enable no-unused-vars */
Debug.log(`ParseModel - parse - 0 - resp instanceof Parse.Object: ${resp instanceof Parse.Object}`, true);
Debug.log(`ParseModel - parse - 1 - ParseModel.prototype.idAttribute: ${ParseModel.prototype.idAttribute}`);
let merged;
if (resp instanceof Parse.Object)
{
/**
* Update the `id`.
* @type {*}
*/
this.id = resp._getId();
// Store the parse ID in local attributes; Note that it won't be propagated in "set()"
const mergeId = {};
mergeId[ParseModel.prototype.idAttribute] = this.id;
Debug.log(`ParseModel - parse - 2 - mergeId: ${mergeId[ParseModel.prototype.idAttribute]}`);
merged = _.extend(mergeId, resp.attributes);
Debug.log(`ParseModel - parse - 3 - merged: ${JSON.stringify(merged)}`);
}
else if (_.isObject(resp))
{
const parseObjectId = resp[ParseModel.prototype.idAttribute];
Debug.log(`ParseModel - parse - 4 - resp is an Object / existing model - parseObjectId: ${parseObjectId}; resp: ${JSON.stringify(resp)}`);
if (!_.isUndefined(parseObjectId) && this.id !== parseObjectId)
{
Debug.log(`ParseModel - parse - 5 - this.id !== parseObjectId; this.id: ${this.id}; parseObjectId: ${parseObjectId}`);
this.id = parseObjectId;
}
merged = resp;
}
return merged;
}
/**
* Save a model to your database (or alternative persistence layer), by delegating to Backbone.sync. Returns a
* Promise. The attributes hash (as in set) should contain the attributes you'd like to change — keys that aren't
* mentioned won't be altered — but, a complete representation of the resource will be sent to the server. As with
* set, you may pass individual keys and values instead of a hash. If the model has a validate method, and validation
* fails, the model will not be saved. If the model isNew, the save will be a "create" (HTTP POST), if the model
* already exists on the server, the save will be an "update" (HTTP PUT).
*
* If instead, you'd only like the changed attributes to be sent to the server, call model.save(attrs,
* {patch: true}). You'll get an HTTP PATCH request to the server with just the passed-in attributes.
*
* Calling save with new attributes will cause a "change" event immediately, a "request" event as the Ajax request
* begins to go to the server, and a "sync" event after the server has acknowledged the successful change. Pass
* {wait: true} if you'd like to wait for the server before setting the new attributes on the model.
*
* In particular this method is overridden to be able to support resolving Parse.Pointer deserializing which
* requires the deserialized data to be parsed by the associated models.
*
* @example
* const book = new Backbone.Model({
* title: 'The Rough Riders',
* author: 'Theodore Roosevelt'
* className: 'Book'
* });
*
* book.save();
*
* book.save({author: "Teddy"});
*
* or use full ES6 syntax:
*
* class Book extends Backbone.Model
* {
* get className() { return 'Book'; }
* get subClass() { return Book; } // If subClass is set this class will be registered with Parse.
* } // Object.registerSubclass()
*
* const book = new Book({
* title: 'The Rough Riders',
* author: 'Theodore Roosevelt'
* });
*
* @see http://backbonejs.org/#Model-save
*
* @param {key|object} key - Either a key defining the attribute to store or a hash of keys / values to store.
* @param {*} val - Any type to store in model.
* @param {object} options - Optional parameters.
* @returns {Promise}
*/
save(key, val, options)
{
let attrs;
if (Utils.isNullOrUndef(key) || typeof key === 'object')
{
Debug.log(`ParseModel - save - 0`);
attrs = key;
options = val;
}
else
{
Debug.log(`ParseModel - save - 1`);
(attrs = {})[key] = val;
}
// Save any previous options.success function.
const success = !Utils.isNullOrUndef(options) ? options.success : undefined;
Debug.log(`ParseModel - save - 2 - options.success defined: ${success !== undefined}`);
options = options || {};
options.success = (model, resp, options) =>
{
// Execute previously cached success function. Must do this first before resolving any potential
// child object changes.
if (success)
{
Debug.log('ParseModel - save - 3 - invoking original options.success.');
success.call(options.context, this, resp, options);
}
Debug.log('ParseModel - save - 4 - invoking ParseModel success.');
const modelAttrs = this.attributes;
for (const attr in modelAttrs)
{
const field = modelAttrs[attr];
// Here is the key part as if the associated Parse.Object id is different than the model id it
// needs to be parsed and data set to the Backbone.Model.
if (field.parseObject && field.parseObject.id !== field.id)
{
field.set(field.parse(field.parseObject), options);
}
}
};
return super.save(attrs, options);
}
/**
* Set a hash of attributes (one or many) on the model and potentially on the associated ParseObject. If any of the
* attributes change the model's state, a "change" event will be triggered on the model. Change events for specific
* attributes are also triggered, and you can bind to those as well, for example: change:title, and change:content.
* You may also pass individual keys and values. In addition option.updateParseObject may contain a boolean to
* indicate whether the associated ParseObject should be updated.
*
* @example
* note.set({ title: "March 20", content: "In his eyes she eclipses..." });
*
* book.set("title", "A Scandal in Bohemia");
*
* @see http://backbonejs.org/#Model-set
*
* @param {object|string} key - Either a string defining a key or a key / value hash.
* @param {*|object} val - Either any type to store or the shifted options hash.
* @param {object} options - Optional parameters.
* @returns {*}
*/
set(key, val, options = {})
{
if (Utils.isNullOrUndef(key)) { return this; }
// Handle both `"key", value` and `{key: value}` -style arguments.
let attrs;
if (typeof key === 'object')
{
attrs = key;
options = val || {};
}
else
{
(attrs = {})[key] = val;
}
// Run validation.
if (!this._validate(attrs, options)) { return false; }
// Extract attributes and options.
const unset = options.unset;
const silent = options.silent;
const updateParseObject = !_.isUndefined(options.updateParseObject) ? options.updateParseObject : true;
const changes = [];
const changing = this._changing;
this._changing = true;
Debug.log(`ParseModel - set - 0 - changing: ${changing}; attrs: ${JSON.stringify(attrs)}; options: ${JSON.stringify(options)}`, true);
if (!changing)
{
this._previousAttributes = _.clone(this.attributes);
this.changed = {};
}
const current = this.attributes;
const changed = this.changed;
const prev = this._previousAttributes;
// For each `set` attribute, update or delete the current value.
for (const attr in attrs)
{
val = attrs[attr];
if (!_.isEqual(current[attr], val))
{
Debug.log(`ParseModel - set - 1 - current[attr] != val for key: ${attr}`);
changes.push(attr);
}
let actuallyChanged = false;
if (!_.isEqual(prev[attr], val))
{
Debug.log(`ParseModel - set - 2 - prev[attr] != val for key: ${attr}`);
changed[attr] = val;
actuallyChanged = true;
}
else
{
Debug.log(`ParseModel - set - 3 - prev[attr] == val delete changed for key: ${attr}`);
delete changed[attr];
}
if (unset)
{
let unsetSuccess = !updateParseObject;
// Ignore any change to the Parse.Object id
if (attr === ParseModel.prototype.idAttribute)
{
continue;
}
if (updateParseObject && this.parseObject !== null && attr !== ParseModel.prototype.idAttribute)
{
// Parse.Object returns itself on success
unsetSuccess = this.parseObject === this.parseObject.unset(attr);
Debug.log(`ParseModel - set - 4 - unset Parse.Object - attr: ${attr}; unsetSuccess: ${unsetSuccess}`);
}
if (unsetSuccess)
{
delete current[attr];
}
}
else
{
let setSuccess = !updateParseObject || attr === ParseModel.prototype.idAttribute;
if (actuallyChanged && updateParseObject && this.parseObject !== null &&
attr !== ParseModel.prototype.idAttribute)
{
// Parse.Object returns itself on success
setSuccess = this.parseObject === this.parseObject.set(attr, val, options);
Debug.log(`ParseModel - set - 5 - set Parse.Object - attr: ${attr}; setSuccess: ${setSuccess}`);
}
if (actuallyChanged && setSuccess)
{
current[attr] = val;
}
}
}
// Trigger all relevant attribute changes.
if (!silent)
{
if (changes.length) { this._pending = options; }
for (let i = 0; i < changes.length; i++)
{
this.trigger(`change:${changes[i]}`, this, current[changes[i]], options);
Debug.log(`ParseModel - set - 6 - trigger - changeKey: ${changes[i]}`);
}
}
// You might be wondering why there's a `while` loop here. Changes can
// be recursively nested within `"change"` events.
if (changing) { return this; }
if (!silent)
{
while (this._pending)
{
options = this._pending;
this._pending = false;
this.trigger('change', this, options);
Debug.log(`ParseModel - set - 7 - trigger - change`);
}
}
this._pending = false;
this._changing = false;
return this;
}
/**
* Return a copy of the model's `attributes` object.
*
* @returns {object} JSON representation of this model.
*/
toJSON()
{
return this.parseObject.toJSON();
}
/**
* This is an unsupported operation for backbone-parse-es6.
*/
url()
{
throw new Error('ParseModel - url() - Unsupported Operation.');
}
}
// The Parse.Object id is set in Backbone.Model attributes to _parseObjectId. In set any change to _parseObjectId is not
// propagated to the associated Parse.Object. Note that the Parse.Object id is also set to this.id in "parse()".
ParseModel.prototype.idAttribute = '_parseObjectId';
/**
* Exports the ParseModel class.
*/
export default ParseModel;