prantlf/backbone.composite-model

View on GitHub
backbone.composite-model.js

Summary

Maintainability
C
1 day
Test Coverage
// Backbone.CompositeModel 0.1.7
// https://github.com/prantlf/backbone.composite-model
//
// Copyright (c) 2015-2017 Ferdinand Prantl
// Licensed under the MIT license.
//
// Supports composite Backbone.Model objects which consist of a main model
// and child models or collections maintained automatically according to a
// composite configuration

// Example
// -------

// Let's have a versioned file-system model: folders which contain
// sub-folders and files, files consisting of file versions.  The REST
// API resource `/files/:id` returns the following (simplified) response
// about a file:
//
//     {
//       id: ...,                // globally unique ID
//       name: '...',            // file display name
//       parent_expanded: {...}, // parent folder object
//       versions: [...]         // version object array
//     }
//
// Let's declare the following (simplified) models and collections for them:
//
//     var FolderModel = Backbone.Model.extend({...});
//
//     var VersionCollection = Backbone.Collection.extend({...});
//
//     var FileModel = Backbone.Model.extend({...})
//
//       // Declare what attributes from the response back
//       // up what child models and collection stored in
//       // properties on the object instance
//       composite: {
//         parent_expanded: {
//           model: FolderModel,
//           property: 'parent'
//         },
//         versions: VersionCollection,
//       },
//
//       // Override the constructor to see the name `FileModel`
//       // in the debugger and to be able to initialize the
//       // composite model
//       constructor: function FileModel(attributes, options) {
//         FileModel.__super__.constructor.apply(this, arguments);
//         this,makeComposite(options);
//       },
//
//       // Point to the resource representing the file
//       // on the server
//       urlRoot: '/files'
//
//     });
//
//     // Extend the function object prototype to become
//     // a composite of child models and collections
//     Backbone.mixinCompositeModel(FileModel.prototype);
//
// This lets the `parent` and `versions` properties maintained automatically
// without an additional code.
//
//     var file = new FileModel({id: ...});
//     file.fetch()
//         .done(function () {
//           console.log('Name:', file.get('name'));
//           // This does not work.
//           console.log('Parent folder:', file.parent.get('name'));
//           console.log('Version count:', file.versions.length);
//         });
//
// The `parent` object and the `versions` array are be accessible as
// `Backbone.Model` and `Backbone.Collection` to be able to pass them to
// other models and views and listen to their events in the application
// using a common Backbone interface.

// Module Factory
// --------------

// UMD wrapper to support multiple module dependency loaders
(function (root, factory) {
  'use strict';

  // Handle AMD modules (RequireJS)
  if (typeof define === 'function' && define.amd) {
    define(['underscore', 'backbone'], factory);
    // Handle CommonJS modules (NodeJS)
  } else if (typeof module === 'object' && module.exports) {
    module.exports = factory(require('underscore'), require('backbone'));
    // Handle global variables setting modules (web browser alone)
  } else {
    root.returnExports = factory(root._, root.Backbone);
  }
}(this, function (_, Backbone) {
  'use strict';

  // Mixin Function
  // --------------

  //
  // Applied on a `Backbone.Model` prototype, extends it to support model
  // composites made of child models and collections, which can be
  // configured by the `composite` property from the object prototype,
  // instance or constructor `options`.
  //
  // Members of the `composite` object (map):
  //
  //     // Maintains a property on the model instance with
  //     // the attribute name pointing to an object instance
  //     // of the specified type
  //     <attribute name>: <Backbone model or collection>,
  //
  //     // Maintains a property on the model instance backed
  //     // up by the specified attribute overriding the
  //     // default handling
  //     <attribute name>: {
  //       // Model or collection to create for the attribute
  //       // value (required)
  //       type: <Backbone model or collection>,
  //       // Property name to store the sub-object on the
  //       // model with (optional; the attribute name is
  //       //  the default)
  //       property: <property name>,
  //       // Additional options to pass to the constructor of
  //       // the sub-object (optional; undefined by default)
  //       options: <object literal>,
  //       // Method to call on the child model or collection
  //       // if updated data are being set (optional; `set` is
  //       // the default for models, `add` for collections)
  //       method: <method name>,
  //       // Function to call before the value is passed to
  //       // child model or collection constructor or `set` /
  //       // `add` method to "massage" the input data
  //       // (optional; `undefined` is the default)
  //       parse: <function (value, options)>,
  //     }
  //
  Backbone.mixinCompositeModel = function (prototype) {
    var originalSet = prototype.set,
        originalToJSON = prototype.toJSON;

    return _.extend(prototype, {

      // Initialization Function
      // -----------------------

      // Initializes the composite model support; to be called from the
      // `initialize` method or from the overridden constructor, after
      // the parent constructor has been called
      makeComposite: function (options) {
        // Mark the object creation scenario for the _updateComposite below
        options = _.extend({create: true}, options);
        this._compositeMap = this._createCompositeMap(options);
        // Update properties explicitly, because the `set` method with
        // the initial attributes of the model is called in the parent
        // constructor already, before the `_compositeMap` has been
        // called here, thus the composite extension could not apply yet
        this._updateComposite(this.attributes, options);
        return this;
      },

      // Overridden Functions
      // -------------------

      // Overrides the `Backbone.Model:set()` method to ensure that the
      // nested attribute values will be propagated to the child models
      // and collections of this composite
      set: function (key, value, options) {
        var attributes;
        // Do nothing if nought has been asked for
        if (key == null) {
          return this;
        }
        // Normalize the input parameters to become two object literals:
        // handle both `'key', value` and `{key: value}` -style arguments
        if (typeof key === 'object') {
          attributes = key;
          options = value;
        } else {
          (attributes = {})[key] = value;
        }
        options || (options = {});
        // Set the common attributes and check the result first
        var result = originalSet.call(this, attributes, options);
        // Update the child models and collections if the composite
        // map has been initialized (after the constructor has finished)
        if (result && this._compositeMap) {
          this._updateComposite(attributes, options);
        }
        // Return the same result as the original `set` method
        return result;
      },

      // Overrides the `Backbone.Model:toJSON()` method to ensure that the
      // up-to-date nested attribute values will be present in the result
      toJSON: function(options) {
        var result = originalToJSON.call(this, options);
        // Update keys maintained by child models and collections only if
        // the composite map has been initialized (after the constructor
        // has finished)
        if (this._compositeMap) {
          // Process only attributes listed in the composite map
          _.each(this._compositeMap, function (composite, key) {
            // Get the nested model or collection for the composite item
            var child = this[composite.property];
            // If the nested model or collection is available, propagate
            // its current content to the resulting JSON
            if (child) {
              result[key] = child.toJSON();
            }
          }, this);
        }
        return result;
      },

      // Private Functions
      // -----------------

      // Merges prototype.composite and options.composite and normalizes
      // the child model or collection configuration
      _createCompositeMap: function (options) {
        // Allow specifying the composite property as a function returning
        // the actual configuration object
        var thisComposite = this.composite,
            optionsComposite = options.composite;
        if (typeof thisComposite === 'function') {
          thisComposite = thisComposite.call(this, options);
        }
        if (typeof optionsComposite === 'function') {
          optionsComposite = optionsComposite.call(this, options);
        }
        if (thisComposite && typeof thisComposite !== 'object' ||
            optionsComposite && typeof optionsComposite !== 'object') {
          throw new Error('Invalid composite configuration');
        }
        // Allow the caller to specify additional or override existing
        // attribute rules defined in the prototype or in the instance
        var composite = _.extend({}, thisComposite, optionsComposite);
        return _.reduce(composite, function (result, model, attribute) {
          var property, parse, method;
          // Just model or collection function object can be used for
          // convenience
          if (model.prototype instanceof Backbone.Model ||
              model.prototype instanceof Backbone.Collection) {
            property = attribute;
            // Otherwise the child model or collection object descriptor
            // should be an object literal with configuration properties
          } else {
            if (typeof model !== 'object') {
              throw new Error('Invalid composite child descriptor');
            }
            // Attribute name is the default for the property name
            property = model.property || attribute;
            // Make sure that the extra data parsing function is not set
            // or is a valid function
            parse = model.parse;
            if (parse != null && typeof parse !== 'function') {
              throw new Error('Invalid child model data parse function');
            }
            method = model.method;
            // Make sure that the child model or collection type is valid
            model = model.type;
            if (!(model.prototype instanceof Backbone.Model ||
                  model.prototype instanceof Backbone.Collection)) {
              throw new Error('Invalid composite child model');
            }
          }
          // Avoid replacing an existing prototype member with the child
          // model or collection instance
          if (prototype[property]) {
            throw new Error('Property conflict in the composite prototype');
          }
          // Use the default data updating method if not specified
          if (!method) {
            method = model.prototype instanceof Backbone.Model ? 'set' : 'add';
          }
          // Make sure that the data updating method exists in the child
          // model or collection prototype
          if (!model.prototype[method]) {
            throw new Error('Invalid chidl model data updating method');
          }
          // Make every map entry look consistent
          result[attribute] = {
            model: model,
            property: property,
            parse: parse,
            method: method
          };
          return result;
        }, {});
      },

      // Checks if the changed attributes contained a key, which backs up
      // a child model or collection and updates the child object
      // accordingly
      _updateComposite: function (attributes, options) {
        // Creates a new instance of the child model or collection
        function create(composite, parameters) {
          var createOptions = _.extend({}, composite.options, options);
          this[composite.property] = new composite.model(parameters,
            createOptions);
        }

        // Ensures that the property with the child model or collection
        // exists and clears it if
        function createOrClear(composite) {
          var child = this[composite.property];
          if (child) {
            // Clearing an attribute on the main model should clear the
            // child model or collection; requesting the `parse` option
            // gives a hint about re-fetching the entire model, which
            // should do the same, but not when saving; the server may
            // respond with incomplete model attributes
            //
            // TODO: How to handle `fetch` with suppressed `parse`?
            // TODO: How to handle `save` with suppressed validation?
            if (options.unset || options.parse && !options.validate) {
              if (child instanceof Backbone.Model) {
                child.clear(options);
              } else {
                child.reset(undefined, options);
              }
            }
          } else if (options.create) {
            // If called from the constructor or with an undefined or with
            // an explicit null, force  creation of empty child models and
            // collections, at least
            create.call(this, composite);
          }
        }

        // Propagates the attribute change to the child model ort collection
        function populate(composite, value) {
          // Pre-process the attributes or models before they are
          // propagated to the child object
          if (composite.parse) {
            value = composite.parse.call(this, value, options);
          }
          // If called from the constructor, the property will not exist
          var child = this[composite.property];
          if (child) {
            // When the main model is re-fetched, the child models or
            // collections should be reset; requesting the `parse` option
            // gives a hint about it and the `validate` option discloses
            // the saving; the server may respond with an incomplete data
            //
            // TODO: How to handle `fetch` with suppressed `parse`?
            // TODO: How to handle `save` with suppressed validation?
            if (options.parse && !options.validate) {
              if (child instanceof Backbone.Model) {
                var missing = _.omit(child.attributes, _.keys(value));
                child.set(missing, {
                  unset: true,
                  silent: true
                });
              } else {
                child.reset(undefined, {silent: true});
              }
            }
            child[composite.method](value, options);
          } else {
            create.call(this, composite, value);
          }
        }

        // Process only attributes listed in the composite map
        _.each(this._compositeMap, function (composite, key) {
          // Leave the child model or collection intact if the attributes
          // do not contain its key
          if (_.has(attributes, key)) {
            var value = attributes[key];
            if (value != null) {
              populate.call(this, composite, value);
            } else {
              createOrClear.call(this, composite);
            }
          } else {
            // If called from the constructor without attributes, force
            // creation of empty child models and collections, at least
            createOrClear.call(this, composite);
          }
        }, this);
      }

    });

  };

  // Export the function to apply the mixin to a prototype either as a result
  // of this module callback or as a property on the `Backbone` object
  return Backbone.mixinCompositeModel;

}));