Source: curious.js

/* global define */
/**
 * curious.js - JavaScript consumer code for Curious APIs.
 *
 * Copyright (c) 2015 Ginkgo Bioworks, Inc.
 * @license MIT
 */

/**
  * Curious JavaScript client and query construction
  *
  * @module curious
  */

'use strict';

// For node.js and CommonJS
Object.defineProperty(exports, '__esModule', { value: true });

// QUERY TERMS

/**
  * Abstract base class for query terms
  *
  * @private
  * @abstract
  * @class
  * @alias module:curious~QueryTerm
  *
  * @param {string} term The internal term text to use
  */
function QueryTerm(term) {
  /**
    * The term contents
    *
    * @method term
    * @readonly
    * @public
    *
    * @return {string} The term contents
    */
  this.term = function _term() { return term; };

  return this;
}

/**
  * Return the term contents as they belong in a query, wrapped with parens and
  * other operators.
  *
  * @public
  *
  * @return {string} The term contents, formatted
  */
QueryTerm.prototype.toString = function toString() { return this.term(); };

/**
  * Determine whether or not the terms sticks implicit joins into adjacent
  * terms.
  *
  * @public
  *
  * @return {boolean}
  *   True if the term implicitly joins with its following term. True by
  *   default
  */
QueryTerm.prototype.leftJoin = function leftJoin() { return false; };

/**
* Determine whether or not the term is a conditional and does not affect
* returned results.
*
* @public
*
* @return {boolean}
*   True if term is a conditional
*/
QueryTerm.prototype.conditional = function conditional() { return false; };

/**
  * Make a term that follows the query chain
  *
  * @private
  * @class
  * @extends {module:curious~QueryTerm}
  * @alias module:curious~QueryTermFollow
  *
  * @param {string} term The term contents
  */
function QueryTermFollow(term) {
  QueryTerm.call(this, term);

  return this;
}
QueryTermFollow.prototype = new QueryTerm();

/**
  * Make a term that performs a filter.
  *
  * @private
  * @class
  * @extends {module:curious~QueryTerm}
  * @alias module:curious~QueryTermHaving
  *
  * @param {string} term The term contents
  */
function QueryTermHaving(term) {
  QueryTerm.call(this, term);

  return this;
}
QueryTermHaving.prototype = new QueryTerm();

QueryTermHaving.prototype.conditional = function conditional() { return true; };

QueryTermHaving.prototype.toString = function toString() { return '+(' + this.term() + ')'; };

/**
  * Make a term that performs a negative (exclusive) filter.
  *
  * @private
  * @class
  * @extends {module:curious~QueryTerm}
  * @alias module:curious~QueryTermHaving
  *
  * @param {string} term The term contents
  */
function QueryTermNotHaving(term) {
  QueryTerm.call(this, term);

  return this;
}
QueryTermNotHaving.prototype = new QueryTerm();

QueryTermNotHaving.prototype.conditional = function conditional() { return true; };

QueryTermNotHaving.prototype.toString = function toString() { return '-(' + this.term() + ')'; };

/**
  * Make a term that performs an outer join.
  *
  * @private
  * @class
  * @extends {module:curious~QueryTerm}
  * @alias module:curious~QueryTermWith
  *
  * @param {string} term The term contents
  */
function QueryTermWith(term) {
  QueryTerm.call(this, term);

  return this;
}
QueryTermWith.prototype = new QueryTerm();

QueryTermWith.prototype.leftJoin = function leftJoin() { return true; };

QueryTermWith.prototype.toString = function toString() { return '?(' + this.term() + ')'; };


// QUERY OBJECT

/**
  * <p>Make a Curious query from constituent parts, using a chain of method
  * calls to a single object.</p>
  *
  * <p>CuriousQuery objects are an object-based representation of a Curious
  * query string to make passing around parts of a query and assembling
  * queries easier.</p>
  *
  * <p>The result of curious queries will be an object containing arrays of
  * objects, as specified in the Curious query. This would be analogous to what
  * a Django QuerySet might look like on the back end.</p>
  *
  * <p>If there is more than one kind of object returned by the query and the
  * query specifies some kind of relationship between the data in the objects
  * (for example, Reactions that have Datasets), the returned objects will have
  * attributes that point to their related objects. The names of these
  * relationships are provided as a user-specified parameter.</p>
  *
  * <p>You construct CuriousQuery objects with a repeated chain of function
  * calls on a core object, CuriousQuery object, much like in jQuery, or
  * <code>_.chain()</code>. Every stage of the chain specifies a new term in
  * the query, and a relationship name as a string. The stages can also take
  * an optional third parameter that will specify the class of the constructed
  * objects (insead of just <code>CuriousObject</code>).</p>
  *
  * <p>The initial Curious term happens either by passing parameters directly
  * to the construtor, or by calling <code>.start()</code>.</p>
  *
  * @class
  * @alias module:curious.CuriousQuery
  *
  * @param {string=} initialTermString
  *   The string for the starting term
  * @param {string=} initialRelationship
  *   The starting term's relationship
  * @param {function(Object)=} initialObjectClass
  *   A custom object class constructor for the starting term
  *
  * @return {CuriousQuery} The newly constructed object
  *
  * @example
  * // Explicitly set start, wrapWith classes
  * var q = (new curious.CuriousQuery())
  *   .start('Experiment(id=302)', 'experiment')
  *   .follow('Experiment.reaction_set', 'reactions')
  *   .follow('Reaction.dataset_set', 'dataset').wrapWith(Dataset)
  *   .follow('Dataset.attachment_set');
  *
  * q.query() ==
  *   'Experiment(id=302), Experiment.reaction_set, '
  *   + 'Reaction.dataset_set, Dataset.attachment_set'
  *
  * @example
  * // Terser version of the same query above
  * var q = new curious.CuriousQuery('Experiment(id=302)', 'experiment')
  *   .follow('Experiment.reaction_set', 'reactions')
  *   .follow('Reaction.dataset_set', 'dataset', Dataset)
  *   .follow('Dataset.attachment_set');
  */
function CuriousQuery(
  initialTermString, initialRelationship, initialObjectClass
) {
  this.terms = [];
  this.relationships = [];
  this.objectFactories = [];
  this.params = null;
  this.existingObjects = null;  // array of object arrays

  // then-style callback pairs to attach to the end of the promise when the query is performed
  this.thens = [];

  if (initialTermString && initialRelationship) {
    this.start(initialTermString, initialRelationship, initialObjectClass);
  }

  return this;
}

/**
  * Generate the constructed query string represented by this object.
  *
  * @return {string} The fully constructed query
  */
CuriousQuery.prototype.query = function query() {
  var queryString = '';
  var terms = [];

  // Flatten all terms and arrays of terms into a single array
  this.terms.forEach(function (term) {
    terms = terms.concat(term);
  });

  terms.forEach(function (term, termIndex) {
    // The first term just gets added directly: it's the starting model or
    // object. The following terms either do or do not have an implicit inner
    // join between them. If they do not have an implicit inner join,
    // commas are inserted to ensure that the objects that correspond to
    // those terms are returned
    if (termIndex > 0) {
      if (term.conditional()) {
        queryString += ' ';
      } else if (
        !term.conditional()
        && !terms[termIndex - 1].leftJoin()
        && !term.leftJoin()
      ) {
        queryString += ', ';
      } else {
        queryString += ' ';
      }
    }

    queryString += term;
  });

  return queryString;
};

/**
  * Convert this object to a string, returning the complete query string
  *
  * @return {string} The fully constructed query
  */
CuriousQuery.prototype.toString = function toString() { return this.query(); };

/**
  * Convert this probject to its native value equivalent, returning the complete query string
  *
  * @return {string} The fully constructed query
  */
CuriousQuery.prototype.valueOf = function valueOf() { return this.query(); };

/**
  * Convert this probject to a plain JavaScript object to allow it to be serialized.
  *
  * @return {string} The fully constructed query
  */
CuriousQuery.prototype.toJSON = function toJSON() {
  return {
    terms: this.terms,
    relationships: this.relationships,
    params: this.params,

    // Object Factories can't be serialized directly: they're functions.
    objectFactories: this.objectFactories.map(function (factory) {
      return factory ? factory.toString() : null;
    }),

    // Existing objects are turned into plain objects if possible, or left alone otherwise.
    existingObjects: this.existingObjects.map(function (objectArray) {
      return objectArray.map(function (existingObject) {
        return (
          existingObject.toJSON
            ? existingObject.toJSON()
            : existingObject
        );
      });
    }),
  };
};

/**
  * Extend this query object with another query object: return a new query
  * chain with the current query chain's terms followed
  * by the other query chain's terms.
  *
  * @param {CuriousQuery} extensionQueryObject The query object being added
  * @return {CuriousQuery} The combined query
  */
CuriousQuery.prototype.extend = function extend(extensionQueryObject) {
  var queryObject = this;

  extensionQueryObject.terms.forEach(function (term, termIndex) {
    queryObject._addTerm(
      term,
      extensionQueryObject.relationships[termIndex],
      extensionQueryObject.objectFactories[termIndex]
    );
  });

  return queryObject;
};

/**
  * Return a deep copy of the current query object.
  *
  * @return {CuriousQuery}
  *   A new CuriousQuery object constaining the same terms, relationships,
  *   constructors
  */
CuriousQuery.prototype.clone = function clone() {
  var clonedObject;

  clonedObject = new CuriousQuery();
  clonedObject.extend(this);

  // One-level-deep copies of params and existing objects
  clonedObject.setParams(this.params);
  clonedObject.setExistingObjects(this.existingObjects);

  return clonedObject;
};

/**
  * <p>Add another term to this query: generic method.</p>
  *
  * <p>Consumers should not use this method, as they do not have access to the
  * {@link module:curious~QueryTerm} classes.</p>
  *
  * @private
  *
  * @param {!QueryTerm|Array<QuerryTerm>} termObject
  *   A {@link module:curious~QueryTerm} object to append to the term, or an
  *   array of them
  * @param {!string} relationship
  *   The name of this term in inter-term relationships
  * @param {?function(Object)=} customConstructor
  *   A custom constructor for the resulting objects, if this part of the
  *   query returns new objects
  *
  * @return {CuriousQuery} The query object, with the new term added
  */
CuriousQuery.prototype._addTerm = function _addTerm(
  termObject, relationship, customConstructor
) {
  // Ensure that objectFactories, relationships, and terms always have the
  // same number of elements.

  if (termObject && relationship) {
    this.terms.push(termObject);
    this.relationships.push(relationship);
  } else {
    throw new Error(
      'Must specify a term and a relationship to append to: ('
      + this.query()
      + ')'
    );
  }

  if (customConstructor) {
    this.objectFactories.push(_makeObjectFactory(customConstructor));
  } else {
    this.objectFactories.push(null);
  }

  return this;
};

/**
  * <p>Append more text to the end of the last term: generic method.</p>
  *
  * <p>Consumers should not use this method, as they do not have access to the
  * {@link module:curious~QueryTerm} classes.</p>
  *
  * @private
  *
  * @param {!QueryTerm|Array<!QueryTerm>} termObject
  *   A {@link module:curious~QueryTerm} object (or an array of them), to
  *   append to the previous term
  *
  * @return {CuriousQuery}
  *   The query object, with the term object's string representation appended
  *   to the previous term
  */
CuriousQuery.prototype._appendToPreviousTerm = function _appendToPreviousTerm(termObject) {
  var lastTerm;

  if (this.terms.length) {
    lastTerm = this.terms[this.terms.length - 1];

    // If the last term has not already been turned into an array, prep it
    // first
    if (!(lastTerm instanceof Array)) {
      lastTerm = [lastTerm];
    }

    lastTerm = lastTerm.concat(termObject);

    // modify the actual terms of the object, since lastTerm is just a shallow
    // reference copy
    this.terms[this.terms.length - 1] = lastTerm;
  } else {
    throw new Error('Must add terms before appending "' + termObject + '" to them.');
  }

  return this;
};

/**
  * Add a starting term to this query. Equivalent to passing parameters
  * directly to the constructor.
  *
  * @param {!string} termString
  *   The contents of the starting term
  * @param {!string} relationship
  *   The name of this term in inter-term relationships
  * @param {?function(Object)=} customConstructor
  *   A custom constructor for the resulting objects, if this part of the
  *   query returns new objects
  *
  * @return {CuriousQuery} The query object, with the term appended
  */
CuriousQuery.prototype.start = function start(termString, relationship, customConstructor) {
  return this._addTerm(new QueryTermFollow(termString), relationship, customConstructor);
};

/**
  * Add an inner-join term to this query.
  *
  * @param {!string} termString
  *   The contents of the starting term
  * @param {!string} relationship
  *   The name of this term in inter-term relationships
  * @param {?function(Object)=} customConstructor
  *   A custom constructor function for the resulting objects
  *
  * @return {CuriousQuery} The query object, with the term appended
  */
CuriousQuery.prototype.follow = function follow(termString, relationship, customConstructor) {
  return this._addTerm(new QueryTermFollow(termString), relationship, customConstructor);
};

/**
  * Add a filter term to this query.
  *
  * @param {!string} termString
  *   The subquery to filter by
  *
  * @return {CuriousQuery} The query object, with the term appended
  */
CuriousQuery.prototype.having = function having(termString) {
  return this._appendToPreviousTerm(new QueryTermHaving(termString));
};

/**
  * Add an exclude filter term to this query.
  *
  * @param {!string} termString
  *   The subquery to filter by
  *
  * @return {CuriousQuery} The query object, with the term appended
  */
CuriousQuery.prototype.notHaving = function notHaving(termString) {
  return this._appendToPreviousTerm(new QueryTermNotHaving(termString));
};

/**
  * Add an outer-join term to this query.
  *
  * @param {!string} termString
  *   The contents of the starting term
  * @param {!string} relationship
  *   The name of this term in inter-term relationships
  * @param {?function(Object)=} customConstructor
  *   A custom constructor for the resulting objects, if this part of the
  *   query returns new objects
  *
  * @return {CuriousQuery} The query object, with the term appended
  */
CuriousQuery.prototype.with = function _with(termString, relationship, customConstructor) {
  return this._addTerm(new QueryTermWith(termString), relationship, customConstructor);
};

/**
  * Specify the object constructor to use for the preceding term in the query.
  *
  * @param {?function(Object)=} customConstructor
  *   A constructor to use when instantiating objects from the previous part of
  *   the query
  *
  * @return {CuriousQuery}
  *   The query object, with the new constructor data stored internally
  */
CuriousQuery.prototype.wrapWith = function wrapWith(customConstructor) {
  return this.wrapDynamically(_makeObjectFactory(customConstructor));
};

/**
  * Specify the object factory function to use for the preceding term in the
  * query. Unlike wrapDynamically, which can work with traditional
  * constructors that do not return a value by default, this will only work
  * with factory functions that explicitly return an object.
  *
  * @param {function(Object)} factoryFunction
  *   A factory function that returns an object of the desired wrapping class
  *
  * @return {CuriousQuery}
  *   The query object, with the new constructor data stored internally
  */
CuriousQuery.prototype.wrapDynamically = function wrapDynamically(factoryFunction) {
  if (this.objectFactories.length) {
    this.objectFactories[this.objectFactories.length - 1] = factoryFunction;
  } else {
    throw new Error('Cannot specify custom object constructor before starting a query');
  }

  return this;
};

/**
  * Set the parameters that this query will pass to its curious client when
  * perform is called.
  *
  * @param {!Object} params
  *   An object of parameters to set--see
  *   {@link module:curious.CuriousClient#performQuery} for a full description of the
  *   parameters.
  *
  * @return {CuriousQuery}
  *   The query object with its curious client parameters updated
  */
CuriousQuery.prototype.setParams = function setParams(params) {
  var queryObject = this;

  if (params instanceof Object) {
    if (!queryObject.params) {
      queryObject.params = {};
    }

    Object.keys(params).forEach(function (key) {
      queryObject.params[key] = params[key];
    });
  }
  return queryObject;
};

/**
  * Set the existing objects that this query will use to link the returned
  * objects into.
  *
  * @param {!Array<!Object>} objs The existing objects to set
  *
  * @return {CuriousQuery} The query object with its existing object set
  *                        updated
  */
CuriousQuery.prototype.setExistingObjects = function setExistingObjects(objs) {
  var queryObject = this;

  if (objs && objs.forEach) {
    queryObject.existingObjects = [];

    objs.forEach(function (existingObject, ix) {
      queryObject.existingObjects[ix] = existingObject;
    });
  }

  return queryObject;
};

/**
  * Perform the query using a passed-in CuriousClient object.
  *
  * @param {!CuriousClient} curiousClient
  *   A CuriousClient object that will handle performing the actual query
  *
  * @return {Promise}
  *   A promise, as returned by {@link module:curious.CuriousClient#performQuery}
  *
  */
CuriousQuery.prototype.perform = function perform(curiousClient) {
  var promise;
  var q = this.query();

  promise = curiousClient.performQuery(
    q, this.relationships, this.objectFactories, this.params, this.existingObjects
  );

  // Attach any thenable resolve/reject promise callback pairs to the promise that
  // results from query execution
  this.thens.forEach(function (thenPair) {
    // thenPair looks like [resolved, rejected]
    if (thenPair[0]) {
      // Just like promise = promise.then(resolved, rejected);
      promise = promise.then.apply(promise, thenPair);
    } else if (thenPair.length > 1 && thenPair[1]) {
      // If the first callback is null but the second one isn't, we're looking at a catch
      // situation. We use the same data structure to store both situations, so that they're
      // attached to the promise in the same order they were attached to the Query object
      promise = promise.catch(thenPair[1]);
    }
  });

  return promise;
};

/**
  * Add a (pair of) callback(s) to be called when the promise to perform the query resolves.
  *
  * This can be useful for constructing a query object with known post-processing before
  * actually executing it.
  *
  * @param {function} fulfilled
  *   A function to call when the promise is fulfilled (just like you would pass to
  *   Promise.prototype.then)
  *
  * @param {function=} rejected
  *   A function to call when the promise is rejected (just like you would pass as the
  *   second argument to Promise.prototype.then)
  *
  * @return {CuriousQuery}
  *   The query itself, to allow chaining <code>then</code>s, or any other methods
  */
CuriousQuery.prototype.then = function then(fulfilled, rejected) {
  this.thens.push([fulfilled, rejected]);

  return this;
};

/**
  * Add a callback to be called if the promise to perform the query is rejected.
  *
  * This can be useful for constructing a query object with known error-handling before
  * actually executing it.
  *
  * @param {function} rejected
  *   A function to call when the promise is rejected (just like you would pass to
  *   Promise.prototype.catch)
  *
  * @return {CuriousQuery}
  *   The query itself, to allow chaining <code>then</code>s <code>catch</code>es, or any other methods
  */
CuriousQuery.prototype.catch = function _catch(rejected) {
  this.thens.push([null, rejected]);

  return this;
};

/**
  * Return a function that will always construct an object of the specified
  * class, regardless of whether or not the passed-in constructor needs to
  * be called with `new` or not.
  *
  * @private
  *
  * @param {function} customConstructor
  *   The constructor that will be called with the new keyword to construct
  *   the object
  *
  * @return {function} A factory function that will return a new object
  *                    whenever called
  */
function _makeObjectFactory(customConstructor) {
  var CustomConstructorClass = customConstructor;
  return function CustomConstructorClassFactory() {
    return new CustomConstructorClass();
  };
}


// CURIOUS OBJECTS

/**
  * Utilities for dealing with curious objects
  * @namespace
  * @alias module:curious.CuriousObjects
  */
var CuriousObjects = (function _curiousObjectsModule() {
  /**
    * Base (default) class for an object returned from a Curious query
    *
    * @class
    * @static
    * @alias module:curious.CuriousObjects.defaultType
    *
    * @param {Object} objectData
    *   A plain JavaScript object representing the query data, as parsed from
    *   the returned JSON
    * @param {boolean=} camelCase
    *   If true, construct camel-cased versions the fields in objectData
    */
  function CuriousObject(objectData, camelCase) {
    var newObject = this;

    // Special properties that aren't data-bearing, but are often convenient
    newObject.__url = null;
    newObject.__model = null;

    // Copy over the object data to be properties of the new CuriousObject
    if (objectData instanceof Object && !(objectData instanceof Array)) {
      Object.keys(objectData).forEach(function (key) {
        var newKey = key;

        if (camelCase) {
          newKey = CuriousObjects.makeCamelCase(key);
        }

        newObject[newKey] = objectData[key];
      });
    }

    return newObject;
  }

  /**
    * Serialize CuriousObject instances to JSON effectively if they are passed to JSON.stringify
    *
    * @return {Object} A plain JavaScript object containing the CuriousObject's data
    */
  CuriousObject.prototype.toJSON = function toJSON() {
    var curiousObject = this;
    var serializableObject = {};

    // Copy over the object data to be properties of the new CuriousObject
    Object.keys(curiousObject).forEach(function (key) {
      serializableObject[key] = curiousObject[key];
    });

    return serializableObject;
  };

  /**
    * When parsing a JSON string into objects, instantiate any objects that look like
    * CuriousObject instances as such, instead of plain JavaScript objects.
    *
    * @static
    * @param {string} jsonString A string of JSON-encoded data
    *
    * @return {*} The instantiated JSON-encoded data, with CuriousObjects placed where
    *             appropriate
    */
  CuriousObject.fromJSON = function fromJSON(jsonString) {
    return JSON.parse(jsonString, function (key, value) {
      var parsedValue = value;

      // If a plain object has '__url' and '__model' fields, it's probably a CuriousObject
      if (value && value.hasOwnProperty('__url') && value.hasOwnProperty('__model')) {
        parsedValue = new CuriousObject(value);
      }

      return parsedValue;
    });
  };

  /**
    * When a Curious query is performed, the returned data comes in a set of 3
    * arrays to save space: objects, fields, urls. Assemble that data into a
    * single array of objects, each of which has the appropriate fields. This
    * makes the data much more reasonable to work with.
    *
    * @private
    * @memberof module:curious.CuriousObjects
    *
    * @param {Object<string, Array>} queryData
    *   A plain JavaScript object representing the query data, as parsed from
    *   the returned JSON--this format is not meant to be easy to use, but
    *   takes less space.
    * @param {Array<string>} queryData.fields
    *   The fields every object has
    * @param {Array<Array>} queryData.objects
    *   An array of arrays, where each array is the values of a single object's
    *   properties, in the order specified by <code>queryData.fields</code>
    * @param {Array<string>} queryData.urls
    *   The url of every object, if they have one
    * @param {string} model
    *   The name of the Django model these objects come from
    * @param {?function(Object)} customConstructor
    *   A constructor to use instead of the default CuriousObject constructor
    * @param {boolean=} camelCase
    *   If true, construct camel-cased versions of the JSON objects returned
    *   by the Curious server.
    *
    * @return {Array<CuriousObject|CustomConstructorClass>}
    *   An array of objects that contain the data described in queryData
    */
  function _parseObjects(queryData, model, customConstructor, camelCase) {
    var objects = [];

    if (queryData.objects instanceof Array) {
      queryData.objects.forEach(function (objectDataArray, objectIndex) {
        var url = queryData.urls[objectIndex];
        var objectData = {};
        var obj; // the final constructed object
        var CustomConstructorClass = customConstructor; // Make a properly-capped version

        // Combine the data from the fields
        queryData.fields.forEach(function (fieldName, fieldIndex) {
          objectData[fieldName] = objectDataArray[fieldIndex];
        });

        if (customConstructor) {
          obj = new CustomConstructorClass(objectData);

          // We can't be sure that the custom constructor that was passed in
          // got all the fields assigned, so we should do it ourselves just
          // in case for any fields the constructor might have missed.
          queryData.fields.forEach(function (fieldName) {
            var newFieldName = fieldName;

            if (camelCase) {
              newFieldName = CuriousObjects.makeCamelCase(fieldName);
            }

            // NOTE: don't check for obj.hasOwnProperty - we actually want to
            // override existing fields in obj
            obj[newFieldName] = objectData[fieldName];
          });
        } else {
          // The CuriousObject constructor does this automatically
          obj = new CuriousObject(objectData, camelCase);
        }

        // Set the magic fields
        obj.__url = url;
        obj.__model = model;

        objects.push(obj);
      });
    }

    return objects;
  }

  /**
    * Get objects associated with each subquery. For each subquery, build a
    * hash of ID to object.
    *
    * If existing objects are specified, will build relationships using the
    * existing objects.
    *
    * @memberof module:curious.CuriousObjects
    *
    * @param {Array<string>} relationships
    *   The names of the relationships objects will have to one another
    * @param {Array<function(Object)>} customConstructors
    *   The custom constructors for curious object classes
    * @param {Object} queryJSONResponse
    *   An object of fields holding the query response, as returned and parsed
    *   directly from JSON without any post-processing
    * @param {string} queryJSONResponse.computed_on
    *   The query timestamp
    * @param {string} queryJSONResponse.last_model
    *   The model name of the last set of objects returned
    * @param {Array<Object>} queryJSONResponse.results
    *   An array of objects containing Django object ids and other
    *   meta-information about the query; one element per model
    * @param {string} queryJSONResponse.results[].model
    *   The model name for this part of the query
    * @param {number} queryJSONResponse.results[].join_index
    *   The index of the model this model joins to
    * @param {Array<Array>} queryJSONResponse.results[].objects
    *   The IDs of the objects returned by the query
    * @param {Array<Object>} queryJSONResponse.data
    *   An array of objects containing the other fields of the Django objects, more than just the
    *   IDs--see {@link module:curious.CuriousObjects~_parseObjects} for a description of this data
    *   in the queryData parameter.
    * @param {Array<Object<number, Object>>} existingObjects
    *   The existing objects--each object in the array is a mapping of an id
    *   to its corresponding object.
    * @param {boolean=} camelCase
    *   If true, construct camel-cased versions of the JSON objects returned
    *   by the Curious server.
    *
    * @return {{objects: Array<Object>, trees: Array<Object>}}
    *   The parsed objects--<code>trees</code> holds any hierarchical
    *   relationships, for recursive queries.
    */
  function parse(
    relationships, customConstructors, queryJSONResponse, existingObjects,
    camelCase
  ) {
    var combinedObjects = [];
    var trees = [];

    if (queryJSONResponse.data instanceof Array) {
      queryJSONResponse.data.forEach(function (queryData, queryIndex) {
        var queryObjects; // the objects parsed from this query
        var objectsByID = {};

        // Parse out the objects for this query, passing
        queryObjects = _parseObjects(
          queryData,

          queryJSONResponse.results[queryIndex].model,

          // Only pass in custom constructors if we need to
          (customConstructors instanceof Array)
            ? customConstructors[queryIndex]
            : null,

          camelCase
        );

        queryObjects.forEach(function (object) {
          var id = object.id;

          if (
            existingObjects instanceof Array
            && existingObjects[queryIndex]
            && existingObjects[queryIndex].hasOwnProperty(id)
          ) {
            objectsByID[id] = existingObjects[queryIndex][id];
          } else {
            objectsByID[id] = object;
          }
        });

        combinedObjects.push(objectsByID);
        trees.push(null);
      });


      // For each subquery, add a relationship to the results of the next
      // subquery and then a reverse relationship
      queryJSONResponse.results.forEach(function (queryResult, queryIndex) {
        // An array of pairs: [objectID, srcObjectID], where
        // the srcObjectID points to the ID of the object that this
        // object is joined from (the 'source' of the join)
        var joinIDPairs = queryResult.objects;

        // A model-level join-index: shows which models are joined to
        // which other models.
        // jscs:disable requireCamelCaseOrUpperCaseIdentifiers
        var joinIndex = queryResult.join_index;
        // jscs:enable

        var forwardRelationshipName = relationships[queryIndex];
        var reverseRelationshipName = relationships[joinIndex];

        if (camelCase) {
          forwardRelationshipName = CuriousObjects.makeCamelCase(forwardRelationshipName);
          reverseRelationshipName = CuriousObjects.makeCamelCase(reverseRelationshipName);
        }

        var joinSourceObjects = combinedObjects[joinIndex];
        var joinDestinationObjects = combinedObjects[queryIndex];

        if (joinSourceObjects && joinDestinationObjects) {
          // Initialize empty arrays for relationships
          Object.keys(joinSourceObjects).forEach(function (id) {
            joinSourceObjects[id][forwardRelationshipName] = [];
          });

          Object.keys(joinDestinationObjects).forEach(function (id) {
            joinDestinationObjects[id][reverseRelationshipName] = [];
          });

          // Go through each of the join ID pairs, and make the equvalent
          // reference links in the corresponding object
          joinIDPairs.forEach(function (joinIDPair) {
            var id = joinIDPair[0];
            var srcID = joinIDPair[1]; // the ID of the parent
            var obj;
            var srcObj; // the corresponding objects


            if (srcID) {
              obj = joinDestinationObjects[id];
              srcObj = joinSourceObjects[srcID];

              if (srcObj && obj) {
                // Forward relationship from query to next query
                srcObj[forwardRelationshipName].push(obj);
                // Reverse relationship to previous query
                obj[reverseRelationshipName].push(srcObj);
              }
            }
          });
        }

        // Set the trees (for hierarchical, recursive queries)
        trees[queryIndex] = queryJSONResponse.results[queryIndex].tree;
      });
    }

    return { objects: combinedObjects, trees: trees };
  }

  /**
    * Utility for pulling out all the values contained in an object, in
    * the same order as its keys would come out
    *
    * @memberof module:curious.CuriousObjects
    *
    * @param {Object} obj The object to look at
    *
    * @return {Array} The values of the object, whatever it holds
    */
  function values(obj) {
    return Object.keys(obj).map(function (key) { return obj[key]; });
  }

  /**
    * Take an array of objects that have 'id' fields, and group them into a
    * single object using that field.
    *
    * @memberof module:curious.CuriousObjects
    *
    * @param {Array<Object>} arrayOfObjects The array to turn into an object
    *
    * @return {Array} The values of the object, whatever it holds
    */
  function groupObjectsByID(arrayOfObjects) {
    var objectsByID = {};

    arrayOfObjects.forEach(function (object) {
      if (object.id === 0 || object.id) {
        objectsByID[object.id] = object;
      }
    });

    return objectsByID;
  }

  /**
    * <p>Take an array of objects that have <code>id</code> fields and pull
    * those fields out into a list of only one ID each.</p>
    *
    * <p>Preserves order.</p>
    *
    * @memberof module:curious.CuriousObjects
    *
    * @param {Array<Object>} objects
    *   An array of objects with the <code>id</code> property
    *
    * @return {Array<number>}
    *   The set of IDs, with duplicates removed, in the same order as the
    *   input object list
    */
  function idList(objects) {
    var uniqueIDs = [];
    var idSet = {};

    objects.forEach(function (object) {
      if (object.hasOwnProperty('id')) {
        var id = object.id;

        if (!idSet[id]) {
          uniqueIDs.push(id);
        }

        idSet[id] = true;
      }
    });

    return uniqueIDs;
  }

  /**
    * <p>Take an array of objects that have <code>id</code> fields and pull
    * those fields out into a comma-separated string.</p>
    *
    * <p>Preserves order.</p>
    *
    * @memberof module:curious.CuriousObjects
    *
    * @param {Array<Object>} arrayOfObjects The array to turn into an object
    *
    * @return {string} A comma-separated string containing the objects' IDs,
    *                  with duplicates removed, in the same order as the input
    *                  object list
    */
  function idString(arrayOfObjects) {
    var ids = idList(arrayOfObjects);
    return ids.join(',');
  }

  /**
    * Camel case a string with - or _ separators:
    *
    * @example
    * CuriousObjects.makeCamelCase('this_is-someTHING') === 'thisIsSomething'
    * CuriousObjects.makeCamelCase('_alreadyDone') === '_alreadyDone'
    *
    * @memberof module:curious.CuriousObjects
    *
    * @param {string} input The string to camel-case
    *
    * @return {string} The input, camel-cased
    */
  function makeCamelCase(input) {
    var components;
    var casedComponents;
    var separators;
    var output = input;

    if (input) {
      // For leading/trailing separators
      separators = {
        leading: {
          re: /^[-_]+/g,
          match: null,
          text: '',
        },
        trailing: {
          re: /[-_]+$/g,
          match: null,
          text: '',
        },
      };

      // Match the leading/trailing separators and store the text
      Object.keys(separators).forEach(function (key) {
        var separatorType = separators[key];
        separatorType.match = separatorType.re.exec(input);
        if (separatorType.match) {
          separatorType.text = separatorType.match[0];
        }
      });

      if (separators.leading.text.length === input.length) {
        // Special case: string consists entirely of separators: just return it
        output = input;
      } else {
        // Only split the parts of the string that are not leading/trailing
        // separators
        components = input.substring(
          separators.leading.text.length,
          input.length - separators.trailing.text.length
        ).split(/[_-]/);

        // If we don't have anything to camel-case, just leave the body alone
        if (components.length > 1) {
          casedComponents = components.map(function (component, ix) {
            // Normalize by lowercasing everything
            var casedComponent = component.toLowerCase();

            // Capitalize every word but the first
            if (ix > 0) {
              casedComponent = (
                casedComponent.charAt(0).toUpperCase()
                + casedComponent.slice(1)
              );
            }

            return casedComponent;
          });
        } else {
          casedComponents = components;
        }

        output = (
          separators.leading.text
          + casedComponents.join('')
          + separators.trailing.text
        );
      }
    }

    return output;
  }

  return {
    parse: parse,
    values: values,
    groupObjectsByID: groupObjectsByID,
    idList: idList,
    idString: idString,
    makeCamelCase: makeCamelCase,
    defaultType: CuriousObject,
  };
}());

// QUERY CLIENT

/**
  * Rearrange the results from an array of arrays of objects to an object,
  * where each array of objects is named by its appropriate relationship name.
  *
  * @param {Array<string>} relationships The relationship names
  * @param {Array<Array<Object>>} objects The objects from each relationship
  * @param {boolean=} camelCase Whether or not to camel-case the relationship names
  *
  * @return {Object<string, Array>} The rearranged results
  */
function _convertResultsToOutput(relationships, objects, camelCase) {
  var output = {};

  objects.forEach(function (object, objectIndex) {
    var relationship = relationships[objectIndex];
    var uniqueIndex = 2;

    if (camelCase) {
      relationship = CuriousObjects.makeCamelCase(relationship);
    }

    // If there is already a key in the object with the existing relationship
    // name, add a number after it to make it unique.
    while (output.hasOwnProperty(relationship)) {
      relationship = relationships[objectIndex] + String(uniqueIndex);
      uniqueIndex++;
    }

    output[relationship] = CuriousObjects.values(object);
  });

  return output;
}

/**
  * Get the final args to send to the Curious server, after filtering down
  * through all of the defaults.
  *
  * @param {?Object=} queryArgs Query-specific args
  * @param {?Object=} clientDefaultArgs Client-specific args
  *
  * @return {Object} The args, with all defaults filled in hierarchially
  */
function _getArgs(queryArgs, clientDefaultArgs) {
  var args = {x: 0, fk: 0}; // lowest-priority default args
  var immutableArgs = {d: 1}; // these are always set, no matter what

  // Override lowest-priority default args with client-level defaults
  if (clientDefaultArgs) {
    Object.keys(clientDefaultArgs).forEach(function (key) {
      args[key] = clientDefaultArgs[key];
    });
  }

  // Override app-level defaults with query-level args
  if (queryArgs) {
    Object.keys(queryArgs).forEach(function (key) {
      args[key] = queryArgs[key];
    });
  }

  // Make sure that the immutable args are always set to their required values
  Object.keys(immutableArgs).forEach(function (key) {
    args[key] = immutableArgs[key];
  });

  return args;
}

/**
  * Given an array of array of objects, group each of the arrays of objects by
  * ID
  *
  * @param {Aray<Array<Object>>} arrayOfArraysOfObjects
  *   An array of arrays of objects to group
  *
  * @return {Array<Object>}
  *   Each object corresponds to its array above, but now grouped by ID
  */
function _groupArraysOfObjectsByID(arrayOfArraysOfObjects) {
  return arrayOfArraysOfObjects.map(function (arrayOfObjects) {
    var group = null;

    if (arrayOfObjects) {
      group = CuriousObjects.groupObjectsByID(arrayOfObjects);
    }

    return group;
  });
}

/**
  * Determine the query endpoint URL from the base URL: add in the query endpoint '/q/' if the URL
  * does not already include it, and make sure the URL ends in a '/'.
  *
  * @param {string} url The base URL, maybe also including the query endpoint
  *
  * @return {string} The fully formed query URL, ending in a '/'.
  */
function _getQueryUrl(url) {
  var queryUrl = url;

  // Ensure that we end with a '/'
  if (!queryUrl.endsWith('/')) {
    queryUrl += '/';
  }

  // Ensure that if the last component was not '/q/', it is now;
  if (!queryUrl.endsWith('/q/')) {
    queryUrl += 'q/';
  }

  return queryUrl;
}


/**
  * Tool for making a curious query and returning parsed objects
  *
  * @class
  * @alias module:curious.CuriousClient
  *
  * @param {!string} curiousUrl
  *   <p>The URL of the Curious server. The query is sent to <code>curiousUrl + '/q/'</code>.</p>
  *
  *   <p>XXX: For compatability with legacy clients, the <code>/q/</code> is not added if
  *   <code>curiousUrl</code> has a 'q' as its last path element. However, <em>new code should not
  *   rely on this behavior</em>.</p>
  * @param {function (string, Object): Promise} request
  *   <p>A function that makes a <code>POST</code> request and returns a Promise
  *   (a thenable)--examples are <code>jQuery.post</code>,
  *   <code>axios.post</code>, and Angular's <code>$http.post</code>.</p>
  *
  *   <p>Any function that meets the signature, makes a <code>POST</code> request and
  *   returns a thenable that resolves to the parsed JSON of the curious
  *   server's response will work. Note that axios.post and $http.post wrap the
  *   response in an object, and so require wrapper functions to be used.
  *   See {@link module:curious.CuriousClient.wrappers} for the wrappers.</p>
  * @param {Object=} clientDefaultArgs
  *   Default parameters to send to the serever with every query performed by
  *   this client--see {@link module:curious.CuriousClient#performQuery} for an
  *   explanation of what each parameter means.
  * @param {boolean=} quiet
  *   Unless true, log every query to the console.
  * @param {boolean=} camelCase
  *   If true, construct camel-cased versions of the JSON objects returned
  *   by the Curious server.
  *
  * @return {CuriousClient}
  *   A client object with a single performQuery method
  */
function CuriousClient(curiousUrl, request, clientDefaultArgs, quiet, camelCase) {
  return {
    /** The URL to query */
    queryUrl: _getQueryUrl(curiousUrl),

    /**
      * Perform a Curious query and return back parsed objects.
      *
      * @example
      * // Here's a many-to-many example
      * client.performQuery(
      *   'Document(id__in=[1,2]) ?(Document.entities)',
      *   ['documents', 'entities'],
      * ).then(function (results) {
      *
      *   console.log(results.objects.documents);
      *   // Will show an array of documents, as CuriousObject instances:
      *   // [
      *   //   CuriousObject({
      *   //      __model: 'Document',
      *   //      __url: 'http://somewhere/document/1',
      *   //      id: 1,
      *   //      entities: [
      *   //        // entities associated with document 1
      *   //      ]
      *   //      ... other fields of Document objects ...
      *   //   }),
      *   //  CuriousObject({
      *   //      __model: 'Document',
      *   //      __url: 'http://somewhere/document/2',
      *   //      id: 2,
      *   //      entities: [
      *   //        // entities associated with document 2
      *   //      ]
      *   //      ...
      *   //   }),
      *   // ]
      *
      *   console.log(results.objects.entities);
      *   // Will show an array of entities, as CuriousObject instances:
      *   // [
      *   //   CuriousObject({
      *   //      __model: 'Entity',
      *   //      __url: 'http://somewhere/entity/1',
      *   //      id: 2348,
      *   //      documents: [
      *   //        // documents associated with entity 1
      *   //      ]
      *   //      ... other fields of Entity objects ...
      *   //   }),
      *   //  CuriousObject({
      *   //      __model: 'Entity',
      *   //      __url: 'http://somewhere/entity/2',
      *   //      id: 2725,
      *   //      documents: [
      *   //        // documents associated with entity 2
      *   //      ]
      *   //      ...
      *   //   }),
      *   // ]
      * });
      *
      * @memberof module:curious.CuriousClient
      *
      * @param {!string} q
      *   The query string
      * @param {!Array<string>} relationships
      *   The names of relationships between each joined set of objects
      * @param {?Array<?function(Object)>} constructors
      *   An array of constructors for any custom classes, or null for the
      *   default
      * @param {?Object=} params
      *   Query-specific parameters for the request
      * @param {boolean=} params.x
      *   Whether or not to ignore excludes; defaults to false
      * @param {boolean=} params.c
      *   Whether or not to just do a check of the query syntax; defaults to
      *   false
      * @param {boolean=} params.d
      *   Whether or not return the object data, or just return ids; always
      *   forced to be true for the JavaScript client
      * @param {boolean=} params.fk
      *   Whether or not follow foreign keys: if false, foregin keys will be
      *   IDs, as expecte. If true, foreign keys will be 4-member arrays
      *   that include the ID, name, and URL of the object being pointed to.
      *   Defaults to false.
      * @param {boolean=} params.r
      *   If true, force a refresh of the data not from cache; defaults to
      *   false.
      * @param {boolean=} params.fc
      *   If true, force using the cached data; defaults to false.
      * @param {string=} params.app
      *   If provided, the name of the app, to use for cache key construction.
      * @param {Array<Array<Object>>=} existingObjects
      *   Objects that already exist to be linked into the results returned by
      *   this query
      *
      * @return {Promise<{objects: Array, trees: Array<?Object>}>}
      *   A promise that resolves to an object containing the objects requested by the query
      *   and a tree structure that relates IDs for recursive queries
      *
      */
    performQuery: function performQuery(q, relationships, constructors, params, existingObjects) {
      var args;
      var groupedExistingObjects;

      if (!quiet) {
        /* eslint-disable no-console */
        console.info(q);
        /* eslint-enable no-console */
      }

      if (existingObjects) {
        groupedExistingObjects = _groupArraysOfObjectsByID(existingObjects);
      }

      args = _getArgs(params, clientDefaultArgs);
      args.q = q.replace(/\n+/g, ' ').trim();

      return request(this.queryUrl, args)
        .then(function (response) {
          var parsedResult = CuriousObjects.parse(
            relationships,
            constructors,
            response.result,
            groupedExistingObjects,
            camelCase
          );

          return {
            objects: _convertResultsToOutput(relationships, parsedResult.objects, camelCase),
            trees: parsedResult.trees,
          };
        });
    },
  };
}

/**
  * Common code of convenience functions that make it easier to interact
  * with a variety of http clients: used to set a default module name if one
  * is not provided for the wrapper.
  *
  * @example
  * var axiosWrapper = _unwrapResponseData.bind(this, 'axios');
  *
  * @private
  * @memberof module:curious.CuriousClient.wrappers
  *
  * @param {string} defaultModuleName
  *   The default module object name to use if one is not provided--should
  *   be bound to a string when actually used as a wrapper.
  * @param {?Object=} moduleObjectOrFunction
  *   Either the module to use, like <code>axios</code>/<code>$http</code>, or the posting function
  *   itself, like <code>axios.post</code>.
  * @param {?Object=} options
  *   Additional options to send to the requesting function
  *
  * @return {function(string, Object): Promise}
  *  A function that meets the requirements to make requests in the curious client:
  *  takes the url and arguments, makes a <code>POST</code> request, and returns
  *  a promise that resolves directly to the returned query response (unwrapped)
  */
function _unwrapResponseData(defaultModuleName, moduleObjectOrFunction, options) {
  var mod;
  var postRequestFunction;

  // Prevent code injection
  if (/[^$.\w'"\[\]]/.test(defaultModuleName)) {
    throw new Error('Invalid module name: likely code injection attempt');
  }

  // Default to the provided module name, but if one is not provided,
  // look in the global namespace, then in the this context, and finally,
  // just evaluate the variable with that name and see if it resovles
  // to something.
  // XXX uses `eval`, only as a last resort. there is no way around this.
  /* eslint-disable no-eval */
  mod = moduleObjectOrFunction
    || (typeof module !== 'undefined' && module[defaultModuleName])
    || (typeof exports !== 'undefined' && exports[defaultModuleName])
    || (typeof global !== 'undefined' && global[defaultModuleName])
    || (typeof window !== 'undefined' && window[defaultModuleName])
    || eval(defaultModuleName);
  /* eslint-enable no-eval */

  // If the module provided has a `post` method, use that. Otherwise,
  // just call the "module" itself: this allows passing in either
  // $http or $http.post, for example
  postRequestFunction = mod.post ? mod.post.bind(mod) : mod;

  // axios/angular return the server's response nested within an object
  // (response.data); here we return a tiny filter function to pull that
  // server response out
  return function _postRequestWrapper(url, args) {
    return postRequestFunction(url, args, options || {})
      .then(function (response) {
        return response.data;
      });
  };
}

/**
  * Convenience functions for interfacing with a variety of common HTTP/ajax
  * client libraries.
  *
  * NOTE: None of these client libraries are required by this module—the wrappers are here simply
  * to help CuriousCient interact with code that uses those libraries.
  *
  * NOTE: <code>jQuery.post</code> does not need a wrapper--it, and any other functions that
  * work like it and resolve to the same kind of data structure can be passed directly as the
  * <code>request</code> parameter.
  *
  * @namespace module:curious.CuriousClient.wrappers
  */
CuriousClient.wrappers = {};

/**
  * Convenience function to make it easier to interact with axios responses
  * (axios is not required by this module at all.)
  *
  * @example
  * var client = new CuriousClient(CURIOUS_URL, CuriousClient.wrappers.axios() ...)
  * var client = new CuriousClient(CURIOUS_URL, CuriousClient.wrappers.axios(axios) ...)
  *
  * @function
  * @memberof module:curious.CuriousClient.wrappers
  *
  * @param {?Object} axiosModuleOrFunction
  *   Either the <code>axios</code> module itself, or <code>axios.post</code>--defaults to using
  *   whatever <code>axios</code> resolves to
  * @param {?Object} options
  *   Additional options to send to the requesting function
  *
  * @return {function(string, Object): Promise}
  *  A function that meets the requirements to make requests in the curious client:
  *  takes the url and arguments, makes an axios post request, and returns
  *  a promise that resolves directly to the returned query response (unwrapped).
  */
CuriousClient.wrappers.axios = _unwrapResponseData.bind(this, 'axios');

/**
  * Convenience function to make it easier to interact with AngularJS <code>$http.post</code>
  * responses (AngularJS is not required by this module at all.)
  *
  * @example
  * var client = new CuriousClient(CURIOUS_URL, CuriousClient.wrappers.angular() ...)
  * var client = new CuriousClient(CURIOUS_URL, CuriousClient.wrappers.angular($http) ...)
  *
  * @function
  * @memberof module:curious.CuriousClient.wrappers
  *
  * @param {?Object} angularHttpServiceOrPostFunction
  *   Either the Angular <code>$http</code> service object, or <code>$http.post</code>--defaults
  *   to using whatever <code>$http</code> resolves to.
  * @param {?Object} options
  *   Additional options to send to the requesting function
  *
  * @return {function(string, Object): Promise}
  *  A function that meets the requirements to make requests in the curious client:
  *  takes the url and arguments, makes a <code>POST</code> request and returns
  *  a promise that resolves directly to the returned query response (unwrapped)
  */
CuriousClient.wrappers.angular = _unwrapResponseData.bind(this, '$http');

/**
  * Convenience function to make it easier to interact with Polymer's
  * <code>&lt;iron-ajax&gt;</code> element.
  *
  * @example
  * var client = new CuriousClient(CURIOUS_URL, CuriousClient.wrappers.ironAjax(this.$.xhr) ...)
  *
  * @memberof module:curious.CuriousClient.wrappers
  *
  * @param {!PolymerElement} ironAjaxElement
  *   The <code>iron-ajax</code> element being used to make the request
  * @param {?Object} options
  *   Additional options to send to the requesting function
  *
  * @return {function(string, Object=): Promise}
  *  A function that meets the requirements to make requests in the curious client:
  *  takes the url and arguments, makes a <code>POST</code> request with
  *  <code>ironAjaxElement</code>, and returns a promise that resolves
  *  directly to the returned query response (unwrapped)
  */
CuriousClient.wrappers.ironAjax = function ironAjax(ironAjaxElement, options) {
  return function (url, args) {
    var oldAutoValue;
    var request;

    // Don't make requests while we're setting the properties.
    oldAutoValue = ironAjaxElement.get('auto');
    ironAjaxElement.set('auto', false);

    if (options) {
      Object.keys(options).forEach(function (option) {
        ironAjaxElement.set(option, options[option]);
      });
    }

    ironAjaxElement.set('method', 'POST');
    ironAjaxElement.set('url', url);
    ironAjaxElement.set('contentType', 'application/json');
    ironAjaxElement.set('body', args);

    request = ironAjaxElement.generateRequest();

    // Return auto to its old state
    ironAjaxElement.set('auto', oldAutoValue);

    // Return the promise that gets fired when the XHR completes, but parse
    // out the actual response data, since the original promise resolves to
    // the iron-request object.
    return request.completes.then(function (ironRequestObject) {
      return ironRequestObject.response;
    });
  };
};

export { CuriousObjects, CuriousClient, CuriousQuery };

// vim: sw=2 ts=2 sts=2 et