wbyoung/azul

View on GitHub
lib/relations/has_many_through.js

Summary

Maintainability
B
5 hrs
Test Coverage
'use strict';

var _ = require('lodash');
var util = require('util');
var inflection = require('../util/inflection');
var Promise = require('bluebird');
var Mixin = require('corazon/mixin');

/**
 * A wrapper function for overriding methods & taking action only when the
 * relation was set up with the `through` option enabled.
 *
 * @function HasMany~throughOverride
 * @param {Function} fn The method to call if this is a through relation.
 * @return {Function} The wrapper method.
 */
var override = function(fn) {
  return function() {
    if (this._options.through) {
      return fn.apply(this, arguments);
    }
    else {
      return this._super.apply(this, arguments);
    }
  };
};

/**
 * A convenience function for creating a through-only method override that will
 * throw an exception if this is not a through relation configured as a simple
 * many-to-many.
 *
 * @function HasMany~manyToManyOnly
 * @param {String} message The error message prefix.
 * @return {Function} The method.
 * @see {@link HasMany~throughOverride}
 */
var manyToManyOnly = function(message) {
  return override(function() {
    if (!this._isToMany) {
      var modelName = this._modelClass.__identity__.__name__;
      var relationName = this._name;
      throw new Error(util.format('%s for non many-to-many through relation ' +
        '%s#%s.', message, modelName, relationName));
    }
    return this._super.apply(this, arguments);
  });
};


/**
 * HasMany mixin for through support.
 *
 * This mixin separates some of the logic of {@link HasMany} and is only
 * intended to be mixed into that one class.
 */
module.exports = Mixin.create(/** @lends HasMany# */ {

  /**
   * Override of {@link BaseRelation#init}.
   *
   * @method
   * @protected
   * @param {String} [options.join] Simply an alias for join.
   * @see {@link BaseRelation#init}
   */
  init: function() {
    this._super.apply(this, arguments);
    this._options = _.defaults({}, this._options);

    // apply complex defaults to options
    if (this._options.join) {
      this._options.through = this._options.join;
    }
    if (this._options.through) {
      this._options.through = inflection.pluralize(this._options.through);
      this._options.through = _.camelCase(this._options.through);
    }
  },

  configure: override(function() {
    var implicit = this._options.implicit;
    var isToMany = this._calculateIsToMany();

    // this can be made into a many-to-many relationship if
    // `_calculateIsToMany` returns undefined (rather than true/false). if it
    // can be, now's the time to add the implicit `belongsTo` relationship.
    var canBeToMany = (isToMany === undefined);
    if (canBeToMany && !implicit) {
      var db = this._modelClass.db;
      var source = inflection.singularize(this._options.source || this._name);
      var joinModel = this._joinModel();
      var belongsToModel = this._relatedModel;
      var belongsToAttr = db.belongsTo(belongsToModel, { implicit: true });
      var belongsToAttrs = _.object([[source, belongsToAttr]]);
      joinModel.reopen(belongsToAttrs);
      isToMany = true;
    }

    this._isToMany = isToMany;
    this._super();

    // pre-configure all relations in the set of expanded relations
    _.invoke(this.expand(), 'configured');
  }),

  /**
   * Override of {@link HasMany#_inverse} that determines default inverse for
   * many-to-many through relationships.
   *
   * @method
   * @protected
   * @see {@link HasMany#_inverse}
   */
  _inverse: override(function() {
    var inverse;
    if (this._isToMany) {
      var sourceRelation = this._expand().reverse()[0];
      var sourcesInverse = sourceRelation.inverseRelation();
      var match = _.find(this._relatedModel.relations, function(relation) {
        return relation._options.through === sourcesInverse._name;
      }.bind(this));
      inverse = _.get(match, '_name');
    }
    return inverse;
  }),

  /**
   * Override of {@link BaseRelation#_joinTable}.
   *
   * @method
   * @protected
   * @see {@link BaseRelation#_joinTable}
   */
  _joinTable: override(function() {
    return this._isToMany ?
      this._joinModel().tableName :
      this._options.through;
  }),

  /**
   * Override of {@link HasMany#_foreignKey} that ensures that
   * many-to-many through relationships have a foreign key attribute that
   * matches the relationship they are through.
   *
   * @method
   * @protected
   * @see {@link HasMany#_foreignKey}
   */
  _foreignKey: override(function() {
    var foreignKey;
    var throughRelation = this._modelClass[this._options.through + 'Relation'];
    if (throughRelation) {
      foreignKey = throughRelation.foreignKey;
    }
    else {
      foreignKey = inflection.singularize(this._modelClass.__name__) + '_id';
      foreignKey = _.camelCase(foreignKey);
    }
    return foreignKey;
  }),

  /**
   * Override of {@link BaseRelation#_expansionName}.
   *
   * @method
   * @protected
   * @see {@link BaseRelation#_expansionName}
   */
  _expansionName: override(function() {
    return _.snakeCase(this._super() + '_through_' + this._options.through);
  }),

  /**
   * Get all relations from the this relation (not inclusive) to the source
   * relation (inclusive).
   *
   * @method
   * @private
   * @return {Array.<BaseRelation>}
   */
  _expand: override(function() {
    var details = this._expansionDetails();
    if (details.error) {
      throw details.error;
    }
    return details.relations;
  }),

  /**
   * Override of {@link BaseRelation#expand} that publicly exposes the
   * relations off of which this is built.
   *
   * @method
   * @private
   * @scope internal
   * @see {@link BaseRelation#expand}
   */
  expand: override(function() {
    return this._expand();
  }),

  /**
   * Handle create object for through relations by ensuring it only works for
   * many-to-many through relations.
   *
   * @method
   * @protected
   * @see {@link HasMany#clearObjects}
   */
  createObject: manyToManyOnly('Cannot create object'),

  /**
   * Handle add objects for through relations by ensuring it only works for
   * many-to-many through relations.
   *
   * @method
   * @protected
   * @see {@link HasMany#clearObjects}
   */
  addObjects: manyToManyOnly('Cannot add objects'),

  /**
   * Handle remove objects for through relations by ensuring it only works for
   * many-to-many through relations.
   *
   * @method
   * @protected
   * @see {@link HasMany#clearObjects}
   */
  removeObjects: manyToManyOnly('Cannot remove objects'),

  /**
   * Handle clear objects for through relations by ensuring it only works for
   * many-to-many through relations.
   *
   * @method
   * @protected
   * @see {@link HasMany#clearObjects}
   */
  clearObjects: manyToManyOnly('Cannot clear objects'),

  /**
   * Handle association of object attributes for through relations.
   *
   * @method
   * @protected
   * @see {@link HasMany#associateObjectAttributes}
   */
  associateObjectAttributes: override(function(/*instance, obj*/) {
  }),

  /**
   * Handle disassociation of object attributes for through relations.
   *
   * @method
   * @protected
   * @see {@link HasMany#disassociateObjectAttributes}
   */
  disassociateObjectAttributes: override(function(/*instance, obj*/) {
  }),

  /**
   * Override of {@link HasMany#scopeObjectQuery}.
   *
   * @method
   * @protected
   * @see {@link HasMany#scopeObjectQuery}
   */
  scopeObjectQuery: override(function(instance, query) {
    var throughRelations = this._expand().reverse();
    var joinableRelations = _.initial(throughRelations);
    var joinName;
    var inverseKeyAttr;

    // the use of `_joinRelation` is safe on a bound query (and in fact
    // requires it to be bound), so we leave the query bound for now.
    joinableRelations.forEach(function(relation, index) {
      var nextRelation = throughRelations[index + 1];
      var throughName = relation._name + '_through';
      joinName = nextRelation._name + '_through';
      inverseKeyAttr = nextRelation.inverseKeyAttr;
      query = query._joinRelation(joinName, relation, {
        through: throughName,
        reverse: true,
      });
    });

    // we cannot use the bound query automatic transformations on the where
    // clause of the query since the source relation could be implicit. we
    // therefore use the proper database attribute value here on an unbound
    // query, then rebind it afterwards.
    var pk = instance.getAttribute(this.primaryKeyAttr);
    var joinTable = query._joinedRelations[joinName].as;
    var qualifiedKey = [joinTable, inverseKeyAttr].join('.');
    var where = _.object([[qualifiedKey, pk]]);
    query = query.unbind().where(where).rebind();

    return query;
  }),

  /**
   * Handle addition for through relations.
   *
   * @method
   * @protected
   * @see {@link HasMany#executeAdd}
   */
  executeAdd: override(function(instance, objects) {
    if (!objects.length) { return; }

    var throughRelations = this._expand().reverse();
    var sourceRelation = throughRelations[0];
    var throughRelation = throughRelations[1];
    var after = this.afterAddingObjects.bind(this, instance, objects);
    var query = sourceRelation._modelClass.objects.insert([]).unbind();
    objects.forEach(function(object) {
      var values = {};
      values[throughRelation.foreignKeyAttr] = instance.pk;
      values[sourceRelation.foreignKeyAttr] = object.pk;
      query = query.values(values);
    });

    return query.execute().tap(after);
  }),

  /**
   * Handle removal for through relations.
   *
   * @method
   * @protected
   * @see {@link HasMany#executeRemove}
   */
  executeRemove: override(function(instance, objects) {
    if (!objects.length) { return; }

    var throughRelations = this._expand().reverse();
    var sourceRelation = throughRelations[0];
    var throughRelation = throughRelations[1];
    var after = this.afterRemovingObjects.bind(this, instance, objects);
    var removable = _.filter(objects, 'persisted');
    var query = sourceRelation._modelClass.objects.unbind();
    var fks = _.map(removable, 'pk');
    var fkQueryKey = sourceRelation.foreignKeyAttr;

    if (fks.length === 0) { query = null; }
    else if (fks.length === 1) { fks = fks[0]; }
    else { fkQueryKey += '$in'; }

    var where = {};
    where[throughRelation.foreignKeyAttr] = instance.pk;
    where[fkQueryKey] = fks;

    return Promise.resolve(query && query.where(where).delete()).tap(after);
  }),

  /**
   * Handle clearing for through relations.
   *
   * @method
   * @protected
   * @see {@link HasMany#executeRemove}
   */
  executeClear: override(function(instance) {
    var throughRelations = this._expand().reverse();
    var sourceRelation = throughRelations[0];
    var sourceModel = sourceRelation._modelClass;
    var throughRelation = throughRelations[1];
    var where = _.object([[throughRelation.foreignKeyAttr, instance.pk]]);
    var query = sourceModel.objects.unbind().where(where).delete();
    var after = this.afterClearingObjects.bind(this, instance);
    return query.execute().tap(after);
  }),

  /**
   * Handle pre-fetch for through relations.
   *
   * Pre-fetching of through relations is handled by
   * {@link BoundWith#_prefetch} when through relations are expanded, so this
   * method simply throws an error.
   *
   * @method
   * @protected
   * @see {@link HasMany#associatePrefetchResults}
   */
  prefetch: override(function(/*instances*/) {
    throw new Error('Cannot pre-fetch directly on a through relation.');
  }),

  /**
   * Handle pre-fetch association for through relations.
   *
   * @method
   * @protected
   * @see {@link HasMany#associatePrefetchResults}
   */
  associatePrefetchResults: override(function(instances, grouped, accumulated) {
    var self = this;
    var throughRelations = this._expand();
    instances.forEach(function(instance) {
      var pks = _.map([instance], self.primaryKey);
      var objects;
      throughRelations.forEach(function(relation, index) {
        var group = accumulated[index];
        objects = pks.reduce(function(array, pk) {
          return _.union(array, group[pk]);
        }, []);

        var nextRelation = throughRelations[index + 1];
        if (nextRelation) {
          pks = _.map(objects, function(obj) {
            return obj.getAttribute(nextRelation.joinKeyAttr);
          });
        }
      });
      self.associateFetchedObjects(instance, objects);
    });
  }),

  /**
   * The model class through which a many-to-many relationship should built.
   *
   * This is simply a convenience method used to determine the join model
   * during the process of configuring the relationship & the adding of
   * implicit relation for many-to-may setups.
   *
   * @method
   * @private
   * @return {Class}
   */
  _joinModel: override(function() {
    var db = this._modelClass.db;
    var through = this._options.through;
    var parts = _.snakeCase(through).split('_');
    var name = parts.map(inflection.singularize, inflection).join('_');
    var table = parts.map(inflection.pluralize, inflection).join('_');
    var modelClass = db.model(name);
    if (!modelClass.customizesTableName) {
      modelClass.tableName = table;
    }
    return modelClass;
  }),

  /**
   * Determine if this relation is a many-to-many relationship, that is if the
   * inverse relationship is a many relationship as well. Has many
   * relationships are not many-to-many by default.
   *
   * @method
   * @protected
   * @return {?Boolean} True if the relationship is many-to-many. False if it
   * is certainly not a many-to-many. Undefined if it could be with the
   * addition of a (belongsTo) source relation.
   */
  _calculateIsToMany: override(function() {
    var throughRelations = this._expansionDetails().relations;
    var throughRelation = throughRelations[0];
    var sourceRelation = throughRelations[1];
    var result;
    if (!sourceRelation) {
      // there is no source relation, this could be a many-to-many relationship
      // if one was added (which will happen automatically in `configure`).
      result = undefined;
    }
    else {
      // this checks that we have a belongs-to style & a has-many style
      // relationship set up. the check is done via the _style_ of the
      // relationships by looking at the keys instead of looking at the actual
      // type of the relationship to make it possible for other relationship
      // types to exist & still have many-to-many through relationships work
      // with them.
      var acceptableLength = (throughRelations.length === 2);
      var hasJoin = (throughRelation === this) || (acceptableLength &&
        (throughRelation.inverseKey === throughRelation.foreignKey));
      var hasSource = (acceptableLength &&
        (sourceRelation._relatedModel === this._relatedModel) &&
        (sourceRelation.joinKey === sourceRelation.foreignKey));
      result = hasSource && hasJoin;
    }
    return result;
  }),

  /**
   * Get all relations from the source relation (inclusive) to this relation
   * (not inclusive).
   *
   * This method ensures that while accessing relationships it does not
   * prematurely configure them.
   *
   * @method
   * @private
   * @return {{relations:Array.<BaseRelation>,error:Object}}
   */
  _expansionDetails: override(function() {
    var self = this;
    var details = {};
    var relations = details.relations = [];
    var relation = self;
    var targetModel = this._relatedModel;
    var source;

    var whileRelation = function(fn) { // jscs:ignore jsDoc
      while (relation) { fn(); }
    };
    whileRelation(function() {
      var db = relation._modelClass.db;
      var through = relation._options.through;
      var modelClass = relation._modelClass;
      var relatedModel = relation._relatedModel;
      var throughRelation = through && modelClass['_' + through + 'Relation'];
      var jumpToModel;

      // we collect all relations that are not through to another one.
      if (!throughRelation) { relations.push(relation); }

      // set source when we hit the first through relation in model class. this
      // is cleared each time we hop, so when not set, we'll be in a new model
      // class.
      if (!source && through) {
        source = relation._options.source || relation._name;
      }

      // termination condition: we found our the related model we're looking
      // for and it's not on a through relation.
      if (!through && relatedModel === targetModel) { relation = null; }
      else if (throughRelation) { relation = throughRelation; }
      else if (through && self._isToMany && relation === self) {
        jumpToModel = self._joinModel();
      }
      else if (through) {
        // this is the last through relation in this model, so this now jumps
        // to a new model class.
        jumpToModel = db.model(inflection.singularize(through));
      }
      else {
        // the next model to look at is just this relation's related model.
        // this relation is likely as `hasMany` to the next relation, but could
        // be a `belongsTo` as well.
        jumpToModel = relation._relatedModel;
      }

      // we're jumping to a new model class now, so we need to perform a search
      // for the source within that new model class.
      if (jumpToModel) {

        var search = _.uniq([
          source,
          inflection.pluralize(source),
          inflection.singularize(source),
        ]);

        var jumpRelation = _(search)
          .map(function(name) { return '_' + name + 'Relation'; })
          .map(_.propertyOf(jumpToModel))
          .find();

        // source name changes when we jump to a new model
        source = undefined;

        if (jumpRelation) { relation = jumpRelation; }
        else {
          var modelName = modelClass.__name__;
          var jumpModelName = jumpToModel.__name__;
          var names = search.map(function(name) {
            return util.format('%s#%s', jumpModelName, name);
          });
          names.push('or ' + names.pop());
          details.error = new Error(util.format(
            'Could not find relation %s via %s#%s for ' +
            'through relation %s#%s', names.join(', '),
            modelName, relation._name, self._modelClass.__name__, self._name));
          relation = null;
        }
      }
    });

    return details;
  }),

});