Sunday, February 9, 2014

Backbone Sync: Customizing JSON Payload Options for Persistence Operations

Backbone Models provide a reasonably light-weight layer over raw AJAX calls to make it easier to define data objects that are acquired and persisted using RESTful services. As I've been working Backbone into more complex projects, I've noticed a pattern emerging that requires some minor tweaks to the default persisting behavior provided by the library. Fortunately, Backbone is very flexible and augmenting these features is not exceptionally difficult. Let's begin with a little context on a specific problem. Consider a simple model:


var myModel = Backbone.Model.extend({
   
   defaults: {
      id: null,
      color: 'blue',
      size: 'big',      
   },

   ...
});



A Model.fetch() call may return more data than what is specified in the defaults like created_on and created_by, where the former is a date stamp set on the server side when the record was originally added and the latter is a person, again, set server-side based on the logged in user. The contents of the created_by attribute could be a hash containing additional details about the user beyond just a name or ID value. These extra fields are useful for rendering information on the screen, but are not required to persist the model back to the server. In the case of the created_by attribute, packing the extra detail into the read operation saves a round trip to the server to fetch the related record. However, the default behavior when Backbone saves the data is to include all the attributes in the model. On more complex models, this can result in fairly large PUT payloads. Not only could they become relatively large, they also become difficult to debug since there is so much extra data that will simply be ignored by the server. My goal is to try to slim down the payload being sent back to the server even if the read operation pulled some extra data.

Solving this problem requires intercepting the data prior to Backbone pushing it to the server. You might think that's as easy as writing a custom Model.toJSON() function to filter out the extra attributes, but then you would lose those columns when rendering views. Having worked with Backbone.computedFields, I already knew it was possible to pass along options in the Model.toJSON() call. What I needed was a way to tell Model.toJSON() that it was being called to perform a write operation. That way, it could exclude those fields in that case and leave them in all others. Finding where to place that flag required studying the call path starting at Model.save() and finding where Model.toJSON() was called to acquire the data to be persisted. Examining the code reveals that Model defers all read/write operations to Backbone.sync() but it does this by proxying through Model.sync(). Along the way, each call passes an options hash which will be provided in the Model.toJSON() call that happens inside Backbone.sync(). Given that, if we override Model.sync(), we can inject a flag that will be passed to Model.toJSON() which will provide the necessary information to filter out attributes not required to persist the model:


var _modelToJSON = Backbone.Model.prototype.toJSON;
var _modelSync = Backbone.Model.prototype.sync;

_.extend(Backbone.Model.prototype, {

   sync: function( method, model, options ) {

      options.persistData = true;
      return _modelSync.call( this, method, model, options );
   },

   toJSON: function( options ) {

      var data, opts = options || {};

      if ( opts.persistData )
         data = this.pick( _.keys( this.defaults ) );
      else
         data = _modelToJSON.call( this, options );

      return data;
   },
});



To keep it simple, I use the defaults hash to identify what attributes are necessary for a write operation. Anything else was acquired from a read and is there only for rendering in views. Of course, once you have this defined, you can easily control any number of options in the process. And, as the documentation eludes to, this is the place to customize that behavior.