Home Reference Source

lib/PreparedQuery.js

const constructQuery = require('./constructQuery');
const doJoins = require('./doJoins');

/**
 * Class that presents a simple interface for constructing queries for a
 * relational database using a model map object describing the connections
 * between models.
 *
 * All methods except `exec` are chainable.
 *
 */
class PreparedQuery {

  /**
   * Generally, this shouldn't be called by external code. This object is generated by the main
   * interface when the `generateQueryFor` method is used.
   *
   * @param {String} modelId - The `primary` model to query
   * @param {JoinJsMap} models - The complete schema of the database (after internal processing)
   * @param {KnexInstance} knex - A knex instance
   */
  constructor(modelId, models, knex) {

    // This is safe as the models object is serialisable
    this.models = JSON.parse(JSON.stringify((models)));

    this.knex = knex;
    this.modelId = modelId;
    this.populated = [];
    this.whereClauses = [];

    this.originalModel = this.models.find(model => model.mapId === modelId);
    if (!this.originalModel) {
      throw new Error(`Invalid model ID ${modelId}`);
    }

    return this;
  }

  /**
   * Populate the joins for a collection or association of the primary model of the query.
   *
   * @param {String} name - The name property of the association or collection
   */
  populate(name) {
    this.populated.push(name);
    // Chainable
    return this;
  }

  /**
   * Define the number of elements to skip (useful for pagination).
   *
   * @param {Number} value - The number of elements to skip
   */
  skip(value) {
    this._skip = value;

    // Chainable
    return this;
  }

  /**
   * Define the maximum number of elements to return (useful for pagination).
   *
   * @param {Number} value - The maximum number of elements to return
   */
  limit(value) {
    this._limit = value;

    // Chainable
    return this;
  }

  /**
   * Define a restriction on the result set.
   *
   * @param {Object|Array} clauses - An object (or list of objects) specifying the
   *     predicate that all results must satisfy. Multiple calls to this function
   *     on a single prepared query instance will be appended to the list of clauses
   *     that are combined using boolean AND logic (ie. results must satisfy all
   *     predicates in the list)
   *
   *     Each predicate consists of a `key` (the column name) and a `value` or `values`
   *     list. If a list of values is specifed it is interpreted as a WhereIn operation
   *     such that results can match ANY of the values supplied.
   */
  where(clauses) {
    if (!clauses || clauses.length === 0) {
      return this;
    }

    // Allow a single where clause to be passed in
    if (!Array.isArray(clauses)) {
      clauses = [clauses];
    }

    // Validate the input structure
    clauses.forEach(clause => {
      if (!clause.key) {
        throw new Error(`Where clause missing key: ${clause}`);
      }
      if ((clause.value === undefined) && !Array.isArray(clause.values)) {
        throw new Error(`Where clause missing a value or values (array): ${clause}`);
      }
      if (clause.value !== undefined && Array.isArray(clause.values)) {
        throw new Error(
            'Each predicate in a where clause must specify a value OR a list of values');
      }
    });

    this.whereClauses = this.whereClauses.concat(clauses);

    // Chainable
    return this;
  }

  /**
   * Define the ordering of the results
   *
   * @param {String} attr - The column name to sort based on
   * @param {String} dir - The direction of the sorting. Must be `asc` or `desc`.
   */
  sort(attr, dir) {
    this.sortAttr = attr;
    this.sortDir = dir;

    // Chainable
    return this;
  }

  /**
   * Run the query and construct a nested JS object representation of it.
   *
   * @returns {Promise} - A promise that resolves to the result of the query
   */
  exec() {

    // Note that we don't want to modify the original model
    const modifiedModel = {
      mapId: this.originalModel.mapId,
      viewId: this.originalModel.viewId,
      idProperty: this.originalModel.idProperty,
      properties: this.originalModel.properties,

      // Add a flag to determine which connections will need populating
      associations: this.originalModel.associations.map(assoc => {
        assoc.populate = !!this.populated.find(name => name === assoc.name);
        return assoc;
      }),
      collections: this.originalModel.collections.map(coll => {
        coll.populate = !!this.populated.find(name => name === coll.name);
        return coll;
      })
    };

    // Construct the query, run it and convert it to a nested object
    return constructQuery(
        this.models,
        modifiedModel,
        this.knex,
        this.whereClauses,
        this.sortAttr,
        this.sortDir,
        this._limit,
        this._skip
    ).then(data => doJoins(
        data,
        this.models,
        this.originalModel,
        modifiedModel
    ));
  }
}

module.exports = PreparedQuery;