Home Reference Source

lib/constructQuery.js

const helpers = require('./helpers');

/**
 * Builds up a list of select statements for a collection of the entity being queried. This
 * includes the idProperty, any regular properties and any associations if this collection
 * is populated.
 * @ignore
 */
function addCollectionSelects(coll, model, models) {
  const collModel = models.find(m => m.mapId === coll.mapId);

  let selects = collModel.properties.map(prop =>
      helpers.createRenamedSelectStatement(coll.viewId, prop, coll.mapId)
  );

  selects.push(
      helpers.createRenamedSelectStatement(coll.viewId, collModel.idProperty, coll.mapId)
  );

  if (coll.populate) {
    selects = selects.concat(collModel.associations.map(collAssoc =>
        helpers.createRenamedSelectStatement(coll.viewId, collAssoc.mapId, coll.mapId)
    ));
  }

  return selects;
}

/**
 * Builds up a list of select statements for an association of the entity being queried. This
 * is just the Id from the original entity (eg the foreign key) if this association isn't
 * populated, or all properties if it is.
 * @ignore
 */
function addAssociationSelects(assoc, model, models) {
  if (!assoc.populate) {
    // If an association is not populated, all we need is the regular column
    // in the table
    return [helpers.createSelectStatement(`${model.viewId}-core`, assoc.mapId)];
  }

  const assocModel = models.find(m => m.mapId === assoc.mapId);

  // Get the property fields
  let selects = assocModel.properties.map(prop =>
      helpers.createRenamedSelectStatement(assoc.viewId, prop, assoc.mapId)
  );
  // Get the ID field
  selects.push(
      helpers.createRenamedSelectStatement(assoc.viewId, assocModel.idProperty, assoc.mapId)
  );

  // Get the fields for associations of the populated association
  selects = selects.concat(assocModel.associations.map(assocAssoc =>
      helpers.createRenamedSelectStatement(assoc.viewId, assocAssoc.mapId, assoc.mapId)
  ));

  return selects;
}

/**
 * Builds up a list of select statements for the entity being queried.
 * @ignore
 */
function constructSelects(models, model) {
  // Generate the select statements needed for the joins and result mapping
  const idSelect = helpers.createSelectStatement(
      `${model.viewId}-core`,
      model.idProperty,
      model.mapId
  );

  const propSelects = model.properties.map(prop =>
      helpers.createSelectStatement(`${model.viewId}-core`, prop)
  );

  const assocSelects = model.associations.map(assoc =>
      addAssociationSelects(assoc, model, models)
  );

  const collectionSelects = model.collections.map(coll =>
      addCollectionSelects(coll, model, models)
  );

  const allSelects = [idSelect]
      .concat(propSelects)
      .concat(assocSelects)
      .concat(collectionSelects);

  // Flatten the arrays of select statements
  return [].concat.apply([], allSelects);
}

/**
 * Generates a `core` subquery for the primary entity and applies any filters to it, including
 * limit, skip, sort and where clauses. By applying these filters to a subquery, the sorting
 * and pagination will behave as expected with the extra association and collection fields added
 * after.
 * @ignore
 */
function constructSubQuery(qb, viewId, limit, skip, sortAttr, sortDir, whereClauses) {
  qb = qb.select().from(viewId);
  if (limit) {
    qb = qb.limit(limit);
  }
  if (skip) {
    qb = qb.offset(skip);
  }
  if (sortAttr && sortDir) {
    qb = qb.orderBy(`${viewId}.${sortAttr}`, sortDir);
  }

  // Attach any where clauses we need
  qb = helpers.applyWhereClauses(qb, whereClauses);

  return qb;
}

/**
 * As the where clauses are validated when they are passed into the PreparedQuery
 * instance, we know that there will be exactly one value or list of values. We select
 * the relevant knex method depending on the input structure.
 * @ignore
 */
function getMethodFromClause(clause) {
  if (clause.value !== undefined) {
    return 'where';
  }
  if (clause.values !== undefined) {
    return 'whereIn';
  }

  // This is checked when clauses are supplied to the PreparedQuery instance
  // so we should never get here
  throw new Error('Must pass in value or values parameter for each clause');
}


/**
 * Creates an object with the information required to generate a knex where clause. The
 * object has the following keys:
 *
 * - method: 'where' or 'whereIn'
 * - params: A list of parameters to pass to the method (table.field, value(s))
 * - core: Bool indicating if it should be applied to the `core` subquery, or the outer query
 * @ignore
 */
function preProcessWhereClause(clause, models, model) {

  // Default to the the model itself
  let viewId = model.viewId;
  let core = true;

  // If a model is specified, use its view (outside the core query)
  if (clause.model) {
    const altModel = models.find(m => m.mapId === clause.model);
    if (!altModel) {
      throw new Error(`Invalid modelId value supplied as part of where clause: ${clause}`);
    }
    viewId = altModel.viewId;
    core = false;
  }

  let binding = clause.value;
  if (binding === undefined) {
    binding = clause.values;
  }
  return {
    method: getMethodFromClause(clause),
    params: [`${viewId}.${clause.key}`, binding],
    core
  };
}


/**
 * Contructs a knex object ready to extract the required info to feed into the mapper
 * for auto-populating collections/associations.
 * @ignore
 *
 * @param {JoinJsMap} models
 * @param {Object} model
 * @param {KnexInstance} knex
 * @param {Array} whereClauses - Array of where clauses
 * @param {String} sortAttr - The property to sort based on
 * @param {String} sortDir - asc/desc
 * @param {Int} limit - The max number of results to return
 * @param {Int} skip - The offset from the start of the result set
 */
function constructQuery(models, model, knex, whereClauses, sortAttr, sortDir, limit, skip) {

  const wClauses = whereClauses.map(clause => preProcessWhereClause(clause, models, model));
  const coreWClauses = wClauses.filter(clause => clause.core);

  let queryObj = knex

      // Start with a subquery to apply the limit, skip and order-by
      // on the primary entity for the query
      .with(`${model.viewId}-core`, qb =>
            constructSubQuery(qb, model.viewId, limit, skip, sortAttr, sortDir, coreWClauses))

      .from(`${model.viewId}-core`);

  // Add all the select statements required for the joins and the properties to
  // be returned
  queryObj = queryObj.select(constructSelects(models, model));

  // Generate a list of params to be passed to leftJoin
  const assocJoinParams = model.associations
    .filter(assoc => assoc.populate)
    .map(assoc => helpers.createAssociationJoinParameters(
        `${model.viewId}-core`,
        assoc.mapId,
        assoc.viewId
    ));

  const collJoinParams = model.collections
    .map(coll => helpers.createCollectionJoinParameters(
        model.mapId,
        `${model.viewId}-core`,
        coll.mapId,
        coll.viewId
    ));


  // Remove any null param sets
  const joinParams = assocJoinParams.concat(collJoinParams).filter(p => !!p);

  // Attach the join to the query Object
  queryObj = helpers.attachJoins(queryObj, joinParams);

  queryObj = helpers.applyWhereClauses(queryObj, wClauses.filter(clause => !clause.core));

  if (sortAttr && sortDir) {
    queryObj = queryObj.orderBy(`${model.viewId}-core.${sortAttr}`, sortDir);
  }

  return queryObj;
}

module.exports = constructQuery;

// Export these for unit testing
module.exports._addCollectionSelects = addCollectionSelects;
module.exports._addAssociationSelects = addAssociationSelects;
module.exports._constructSelects = constructSelects;
module.exports._constructSubQuery = constructSubQuery;