Home Reference Source

core/query.js

import sift from 'sift';
import isPlainObject from 'lodash/isPlainObject';
import defaults from 'lodash/defaults';
import { QueryError } from './errors';
import { nested, isDefined, isNumber } from './utils';
import { Log } from './log';

const PROTECTED_FIELDS = ['_id', '_acl'];
const UNSUPPORTED_CONDITIONS = ['$nearSphere'];

/**
 * The Query class is used to query for a subset of
 * entities using the Kinvey API.
 *
 * @example
 * var query = new Kinvey.Query();
 * query.equalTo('name', 'Kinvey');
 */
export class Query {
  /**
   * Create an instance of the Query class.
   *
   * @param {Object} options Options
   * @param {string[]} [options.fields=[]] Fields to select.
   * @param {Object} [options.filter={}] MongoDB query.
   * @param {Object} [options.sort={}] The sorting order.
   * @param {?number} [options.limit=null] Number of entities to select.
   * @param {number} [options.skip=0] Number of entities to skip from the start.
   * @return {Query} The query.
   */
  constructor(options) {
    options = defaults(options, {
      fields: [],
      filter: {},
      sort: null,
      limit: null,
      skip: 0
    });

    /**
     * Fields to select.
     *
     * @type {string[]}
     */
    this.fields = options.fields;

    /**
     * The MongoDB query.
     *
     * @type {Object}
     */
    this.filter = options.filter;

    /**
     * The sorting order.
     *
     * @type {Object}
     */
    this.sort = options.sort;

    /**
     * Number of entities to select.
     *
     * @type {?number}
     */
    this.limit = options.limit;

    /**
     * Number of entities to skip from the start.
     *
     * @type {number}
     */
    this.skip = options.skip;

    /**
     * Maintain reference to the parent query in case the query is part of a
     * join.
     *
     * @type {?Query}
     */
    this._parent = null;
  }

  /**
   * @type {string[]}
   */
  get fields() {
    return this._fields;
  }

  /**
   * @type {string[]}
   */
  set fields(fields) {
    fields = fields || [];

    if (!Array.isArray(fields)) {
      throw new QueryError('fields must be an Array');
    }

    if (isDefined(this._parent)) {
      this._parent.fields = fields;
    } else {
      this._fields = fields;
    }
  }

  /**
   * @type {Object}
   */
  get filter() {
    return this._filter;
  }

  /**
   * @type {Object}
   */
  set filter(filter) {
    this._filter = filter;
  }

  /**
   * @type {Object}
   */
  get sort() {
    return this._sort;
  }

  /**
   * @type {Object}
   */
  set sort(sort) {
    if (sort && !isPlainObject(sort)) {
      throw new QueryError('sort must an Object');
    }

    if (isDefined(this._parent)) {
      this._parent.sort = sort;
    } else {
      this._sort = sort || {};
    }
  }

  /**
   * @type {?number}
   */
  get limit() {
    return this._limit;
  }

  /**
   * @type {?number}
   */
  set limit(limit) {
    if (typeof limit === 'string') {
      limit = parseFloat(limit);
    }

    if (isDefined(limit) && isNumber(limit) === false) {
      throw new QueryError('limit must be a number');
    }

    if (this._parent) {
      this._parent.limit = limit;
    } else {
      this._limit = limit;
    }
  }

  /**
   * @type {number}
   */
  get skip() {
    return this._skip;
  }

  /**
   * @type {number}
   */
  set skip(skip) {
    if (typeof skip === 'string') {
      skip = parseFloat(skip);
    }

    if (isNumber(skip) === false) {
      throw new QueryError('skip must be a number');
    }

    if (isDefined(this._parent)) {
      this._parent.skip = skip;
    } else {
      this._skip = skip;
    }
  }

  /**
   * Returns true or false depending on if the query is able to be processed offline.
   *
   * @returns {boolean} True if the query is supported offline otherwise false.
   */
  isSupportedOffline() {
    return Object.keys(this.filter).reduce((supported, key) => {
      if (supported) {
        const value = this.filter[key];
        return UNSUPPORTED_CONDITIONS.some((unsupportedConditions) => {
          if (!value) {
            return true;
          }

          if (typeof value !== 'object') {
            return true;
          }

          return !Object.keys(value).some((condition) => {
            return condition === unsupportedConditions;
          });
        });
      }

      return supported;
    }, true);
  }

  /**
   * @private
   */
  hasSkip() {
    return isNumber(this.skip) && this.skip > 0;
  }

  /**
   * @private
   */
  hasLimit() {
    return isNumber(this.limit);
  }

  /**
   * Adds an equal to filter to the query. Requires `field` to equal `value`.
   * Any existing filters on `field` will be discarded.
   * @see https://docs.mongodb.com/manual/reference/operator/query/#comparison
   *
   * @param {string} field Field
   * @param {*} value Value
   * @returns {Query} The query.
   */
  equalTo(field, value) {
    return this.addFilter(field, value);
  }

  /**
   * Adds a contains filter to the query. Requires `field` to contain at least
   * one of the members of `list`.
   * @see https://docs.mongodb.com/manual/reference/operator/query/in
   *
   * @param {string} field Field
   * @param {array} values List of values.
   * @throws {QueryError} `values` must be of type `Array`.
   * @returns {Query} The query.
   */
  contains(field, values) {
    if (isDefined(values) === false) {
      throw new QueryError('You must supply a value.');
    }

    if (Array.isArray(values) === false) {
      values = [values];
    }

    return this.addFilter(field, '$in', values);
  }

  /**
   * Adds a contains all filter to the query. Requires `field` to contain all
   * members of `list`.
   * @see https://docs.mongodb.com/manual/reference/operator/query/all
   *
   * @param {string} field Field
   * @param {Array} values List of values.
   * @throws {QueryError} `values` must be of type `Array`.
   * @returns {Query} The query.
   */
  containsAll(field, values) {
    if (isDefined(values) === false) {
      throw new QueryError('You must supply a value.');
    }

    if (Array.isArray(values) === false) {
      values = [values];
    }

    return this.addFilter(field, '$all', values);
  }

  /**
   * Adds a greater than filter to the query. Requires `field` to be greater
   * than `value`.
   * @see https://docs.mongodb.com/manual/reference/operator/query/gt
   *
   * @param {string} field Field
   * @param {number|string} value Value
   * @throws {QueryError} `value` must be of type `number` or `string`.
   * @returns {Query} The query.
   */
  greaterThan(field, value) {
    if (isNumber(value) === false && typeof value !== 'string') {
      throw new QueryError('You must supply a number or string.');
    }

    return this.addFilter(field, '$gt', value);
  }

  /**
   * Adds a greater than or equal to filter to the query. Requires `field` to
   * be greater than or equal to `value`.
   * @see https://docs.mongodb.com/manual/reference/operator/query/gte
   *
   * @param {string} field Field.
   * @param {number|string} value Value.
   * @throws {QueryError} `value` must be of type `number` or `string`.
   * @returns {Query} The query.
   */
  greaterThanOrEqualTo(field, value) {
    if (isNumber(value) === false && typeof value !== 'string') {
      throw new QueryError('You must supply a number or string.');
    }

    return this.addFilter(field, '$gte', value);
  }

  /**
   * Adds a less than filter to the query. Requires `field` to be less than
   * `value`.
   * @see https://docs.mongodb.com/manual/reference/operator/query/lt
   *
   * @param {string} field Field
   * @param {number|string} value Value
   * @throws {QueryError} `value` must be of type `number` or `string`.
   * @returns {Query} The query.
   */
  lessThan(field, value) {
    if (isNumber(value) === false && typeof value !== 'string') {
      throw new QueryError('You must supply a number or string.');
    }

    return this.addFilter(field, '$lt', value);
  }

  /**
   * Adds a less than or equal to filter to the query. Requires `field` to be
   * less than or equal to `value`.
   * @see https://docs.mongodb.com/manual/reference/operator/query/lte
   *
   * @param {string} field Field
   * @param {number|string} value Value
   * @throws {QueryError} `value` must be of type `number` or `string`.
   * @returns {Query} The query.
   */
  lessThanOrEqualTo(field, value) {
    if (isNumber(value) === false && typeof value !== 'string') {
      throw new QueryError('You must supply a number or string.');
    }

    return this.addFilter(field, '$lte', value);
  }

  /**
   * Adds a not equal to filter to the query. Requires `field` not to equal
   * `value`.
   * @see https://docs.mongodb.com/manual/reference/operator/query/ne
   *
   * @param {string} field Field
   * @param {*} value Value
   * @returns {Query} The query.
   */
  notEqualTo(field, value) {
    return this.addFilter(field, '$ne', value);
  }

  /**
   * Adds a not contained in filter to the query. Requires `field` not to
   * contain any of the members of `list`.
   * @see https://docs.mongodb.com/manual/reference/operator/query/nin
   *
   * @param {string} field Field
   * @param {Array} values List of values.
   * @throws {QueryError} `values` must be of type `Array`.
   * @returns {Query} The query.
   */
  notContainedIn(field, values) {
    if (Array.isArray(values) === false) {
      values = [values];
    }

    return this.addFilter(field, '$nin', values);
  }

  /**
   * Performs a logical AND operation on the query and the provided queries.
   * @see https://docs.mongodb.com/manual/reference/operator/query/and
   *
   * @param {...Query|...Object} args Queries
   * @throws {QueryError} `query` must be of type `Array<Query>` or `Array<Object>`.
   * @returns {Query} The query.
   */
  and(...args) {
    // AND has highest precedence. Therefore, even if this query is part of a
    // JOIN already, apply it on this query.
    return this.join('$and', args);
  }

  /**
   * Performs a logical NOR operation on the query and the provided queries.
   * @see https://docs.mongodb.com/manual/reference/operator/query/nor
   *
   * @param {...Query|...Object} args Queries
   * @throws {QueryError} `query` must be of type `Array<Query>` or `Array<Object>`.
   * @returns {Query} The query.
   */
  nor(...args) {
    // NOR is preceded by AND. Therefore, if this query is part of an AND-join,
    // apply the NOR onto the parent to make sure AND indeed precedes NOR.
    if (isDefined(this._parent) && Object.hasOwnProperty.call(this._parent.filter, '$and')) {
      return this._parent.nor(...args);
    }

    return this.join('$nor', args);
  }

  /**
   * Performs a logical OR operation on the query and the provided queries.
   * @see https://docs.mongodb.com/manual/reference/operator/query/or
   *
   * @param {...Query|...Object} args Queries.
   * @throws {QueryError} `query` must be of type `Array<Query>` or `Array<Object>`.
   * @returns {Query} The query.
   */
  or(...args) {
    // OR has lowest precedence. Therefore, if this query is part of any join,
    // apply the OR onto the parent to make sure OR has indeed the lowest
    // precedence.
    if (isDefined(this._parent)) {
      return this._parent.or(...args);
    }

    return this.join('$or', args);
  }

  /**
   * Adds an exists filter to the query. Requires `field` to exist if `flag` is
   * `true`, or not to exist if `flag` is `false`.
   * @see https://docs.mongodb.com/manual/reference/operator/query/exists
   *
   * @param {string} field Field
   * @param {boolean} [flag=true] The exists flag.
   * @returns {Query} The query.
   */
  exists(field, flag) {
    flag = typeof flag === 'undefined' ? true : flag || false;
    return this.addFilter(field, '$exists', flag);
  }

  /**
   * Adds a modulus filter to the query. Requires `field` modulo `divisor` to
   * have remainder `remainder`.
   * @see https://docs.mongodb.com/manual/reference/operator/query/mod
   *
   * @param {string} field Field
   * @param {number} divisor Divisor
   * @param {number} [remainder=0] Remainder
   * @throws {QueryError} `divisor` must be of type: `number`.
   * @throws {QueryError} `remainder` must be of type: `number`.
   * @returns {Query} The query.
   */
  mod(field, divisor, remainder = 0) {
    if (typeof divisor === 'string') {
      divisor = parseFloat(divisor);
    }

    if (typeof remainder === 'string') {
      remainder = parseFloat(remainder);
    }

    if (!isNumber(divisor)) {
      throw new QueryError('divisor must be a number');
    }

    if (!isNumber(remainder)) {
      throw new QueryError('remainder must be a number');
    }

    return this.addFilter(field, '$mod', [divisor, remainder]);
  }

  /**
   * Adds a match filter to the query. Requires `field` to match `regExp`.
   * @see https://docs.mongodb.com/manual/reference/operator/query/regex
   *
   * @param {string} field Field
   * @param {RegExp|string} regExp Regular expression.
   * @param {Object} [options] Options
   * @param {boolean} [options.ignoreCase=inherit] Toggles case-insensitivity.
   * @param {boolean} [options.multiline=inherit] Toggles multiline matching.
   * @param {boolean} [options.extended=false] Toggles extended capability.
   * @param {boolean} [options.dotMatchesAll=false] Toggles dot matches all.
   * @returns {Query} The query.
   */
  matches(field, regExp, options = {}) {
    const flags = [];

    if (!(regExp instanceof RegExp)) {
      regExp = new RegExp(regExp);
    }

    if (regExp.source.indexOf('^') !== 0) {
      throw new QueryError('regExp must have \'^\' at the beginning of the expression'
        + ' to make it an anchored expression.');
    }

    if ((regExp.ignoreCase || options.ignoreCase) && options.ignoreCase !== false) {
      throw new QueryError('ignoreCase flag is not supported');
    }

    if ((regExp.multiline || options.multiline) && options.multiline !== false) {
      flags.push('m');
    }

    if (options.extended === true) {
      flags.push('x');
    }

    if (options.dotMatchesAll === true) {
      flags.push('s');
    }

    if (flags.length > 0) {
      this.addFilter(field, '$options', flags.join(''));
    }

    return this.addFilter(field, '$regex', regExp.source);
  }

  /**
   * Adds a near filter to the query. Requires `field` to be a coordinate
   * within `maxDistance` of `coord`. Sorts documents from nearest to farthest.
   * @see https://docs.mongodb.com/manual/reference/operator/query/near
   *
   * @param {string} field The field.
   * @param {Array<number, number>} coord The coordinate (longitude, latitude).
   * @param {number} [maxDistance] The maximum distance (miles).
   * @throws {QueryError} `coord` must be of type `Array<number, number>`.
   * @returns {Query} The query.
   */
  near(field, coord, maxDistance) {
    if (!Array.isArray(coord) || !isNumber(coord[0]) || !isNumber(coord[1])) {
      throw new QueryError('coord must be a [number, number]');
    }

    const result = this.addFilter(field, '$nearSphere', [coord[0], coord[1]]);

    if (isNumber(maxDistance)) {
      this.addFilter(field, '$maxDistance', maxDistance);
    }

    return result;
  }

  /**
   * Adds a within box filter to the query. Requires `field` to be a coordinate
   * within the bounds of the rectangle defined by `bottomLeftCoord`,
   * `bottomRightCoord`.
   * @see https://docs.mongodb.com/manual/reference/operator/query/box
   *
   * @param {string} field The field.
   * @param {Array<number, number>} bottomLeftCoord The bottom left coordinate (longitude, latitude).
   * @param {Array<number, number>} upperRightCoord The bottom right coordinate (longitude, latitude).
   * @throws {QueryError} `bottomLeftCoord` must be of type `Array<number, number>`.
   * @throws {QueryError} `bottomRightCoord` must be of type `Array<number, number>`.
   * @returns {Query} The query.
   */
  withinBox(field, bottomLeftCoord, upperRightCoord) {
    if (!Array.isArray(bottomLeftCoord) || !isNumber(bottomLeftCoord[0]) || !isNumber(bottomLeftCoord[1])) {
      throw new QueryError('bottomLeftCoord must be a [number, number]');
    }

    if (!Array.isArray(upperRightCoord) || !isNumber(upperRightCoord[0]) || !isNumber(upperRightCoord[1])) {
      throw new QueryError('upperRightCoord must be a [number, number]');
    }

    bottomLeftCoord[0] = parseFloat(bottomLeftCoord[0]);
    bottomLeftCoord[1] = parseFloat(bottomLeftCoord[1]);
    upperRightCoord[0] = parseFloat(upperRightCoord[0]);
    upperRightCoord[1] = parseFloat(upperRightCoord[1]);

    const coords = [
      [bottomLeftCoord[0], bottomLeftCoord[1]],
      [upperRightCoord[0], upperRightCoord[1]]
    ];
    return this.addFilter(field, '$within', { $box: coords });
  }

  /**
   * Adds a within polygon filter to the query. Requires `field` to be a
   * coordinate within the bounds of the polygon defined by `coords`.
   * @see https://docs.mongodb.com/manual/reference/operator/query/polygon
   *
   * @param {string} field The field.
   * @param {Array<Array<number, number>>} coords List of coordinates.
   * @throws {QueryError} `coords` must be of type `Array<Array<number, number>>`.
   * @returns {Query} The query.
   */
  withinPolygon(field, coords) {
    if (Array.isArray(coords) === false || coords.length === 0 || coords.length > 3) {
      throw new QueryError('coords must be a [[number, number]]');
    }

    coords = coords.map((coord) => {
      if (isNumber(coord[0]) === false || isNumber(coord[1]) === false) {
        throw new QueryError('coords argument must be a [number, number]');
      }

      return [parseFloat(coord[0]), parseFloat(coord[1])];
    });

    return this.addFilter(field, '$within', { $polygon: coords });
  }

  /**
   * Adds a size filter to the query. Requires `field` to be an `Array` with
   * exactly `size` members.
   * @see https://docs.mongodb.com/manual/reference/operator/query/size
   *
   * @param {string} field Field
   * @param {number} size Size
   * @throws {QueryError} `size` must be of type: `number`.
   * @returns {Query} The query.
   */
  size(field, size) {
    if (typeof size === 'string') {
      size = parseFloat(size);
    }

    if (!isNumber(size)) {
      throw new QueryError('size must be a number');
    }

    return this.addFilter(field, '$size', size);
  }

  /**
   * Adds an ascending sort modifier to the query. Sorts by `field`, ascending.
   *
   * @param {string} field Field
   * @returns {Query} The query.
   */
  ascending(field) {
    if (isDefined(this._parent)) {
      this._parent.ascending(field);
    } else {
      this.sort[field] = 1;
    }

    return this;
  }

  /**
   * Adds an descending sort modifier to the query. Sorts by `field`,
   * descending.
   *
   * @param {string} field Field
   * @returns {Query} The query.
   */
  descending(field) {
    if (isDefined(this._parent)) {
      this._parent.descending(field);
    } else {
      this.sort[field] = -1;
    }

    return this;
  }

  /**
   * Adds a filter to the query.
   *
   * @param {string} field Field
   * @param {string} condition Condition
   * @param {*} values Values
   * @returns {Query} The query.
   */
  addFilter(field, condition, values) {
    if (isDefined(condition)
      && (isDefined(values) || arguments.length === 3)) {
      if (!isPlainObject(this.filter[field])) {
        this.filter[field] = {};
      }

      this.filter[field][condition] = values;
    } else {
      this.filter[field] = condition;
    }

    return this;
  }

  /**
   * @private
   * Joins the current query with another query using an operator.
   *
   * @param {string} operator Operator
   * @param {Query[]|Object[]} queries Queries
   * @throws {QueryError} `query` must be of type `Query[]` or `Object[]`.
   * @returns {Query} The query.
   */
  join(operator, queries) {
    let that = this;
    const currentQuery = {};

    // Cast, validate, and parse arguments. If `queries` are supplied, obtain
    // the `filter` for joining. The eventual return function will be the
    // current query.
    queries = queries.map((query) => {
      if (!(query instanceof Query)) {
        if (isPlainObject(query)) {
          query = new Query(query);
        } else {
          throw new QueryError('query argument must be of type: Kinvey.Query[] or Object[].');
        }
      }

      return query.toPlainObject().filter;
    });

    // If there are no `queries` supplied, create a new (empty) `Query`.
    // This query is the right-hand side of the join expression, and will be
    // returned to allow for a fluent interface.
    if (queries.length === 0) {
      that = new Query();
      queries = [that.toPlainObject().filter];
      that._parent = this; // Required for operator precedence and `toJSON`.
    }

    // Join operators operate on the top-level of `filter`. Since the `toJSON`
    // magic requires `filter` to be passed by reference, we cannot simply re-
    // assign `filter`. Instead, empty it without losing the reference.
    const members = Object.keys(this.filter);
    members.forEach((member) => {
      currentQuery[member] = this.filter[member];
      delete this.filter[member];
    });

    // `currentQuery` is the left-hand side query. Join with `queries`.
    this.filter[operator] = [currentQuery].concat(queries);

    // Return the current query if there are `queries`, and the new (empty)
    // `PrivateQuery` otherwise.
    return that;
  }

  /**
   * @private
   * Processes the data by applying fields, sort, limit, and skip.
   *
   * @param {Array} data The raw data.
   * @throws {QueryError} `data` must be of type `Array`.
   * @returns {Array} The processed data.
   */
  process(data) {
    if (!this.isSupportedOffline()) {
      let message = 'This query is not able to run locally. The following filters are not supported'
        + ' locally:';

      UNSUPPORTED_CONDITIONS.forEach((filter) => {
        message = `${message} ${filter}`;
      });

      Log.error(message);
      throw new QueryError(message);
    }

    // Validate arguments.
    if (!Array.isArray(data)) {
      throw new QueryError('data argument must be of type: Array.');
    }

    Log.debug('Data length before processiong query', data.length);

    // Apply the query
    const json = this.toPlainObject();
    data = sift(json.filter, data);

    Log.debug('Data length after applying query filter', json.filter, data.length);

    /* eslint-disable no-restricted-syntax, no-prototype-builtins  */
    // Sorting.
    if (isDefined(json.sort)) {
      Log.debug('Sorting data', json.sort);
      data.sort((a, b) => {
        for (const field in json.sort) {
          if (json.sort.hasOwnProperty(field)) {
            // Find field in objects.
            const aField = nested(a, field);
            const bField = nested(b, field);
            const modifier = json.sort[field]; // 1 (ascending) or -1 (descending).

            if (isDefined(aField) && isDefined(bField) === false) {
              return 1 * modifier;
            } else if (isDefined(aField) === false && isDefined(bField)) {
              return -1 * modifier;
            } else if (typeof aField === 'undefined' && bField === null) {
              return 0;
            } else if (aField === null && typeof bField === 'undefined') {
              return 0;
            } else if (aField !== bField) {
              return (aField < bField ? -1 : 1) * modifier;
            }
          }
        }

        return 0;
      });
    }
    /* eslint-enable no-restricted-syntax, no-prototype-builtins */

    // Remove fields
    if (Array.isArray(json.fields) && json.fields.length > 0) {
      const fields = [].concat(json.fields, PROTECTED_FIELDS);
      Log.debug('Removing fields from data', json.fields);
      data = data.map((item) => {
        const keys = Object.keys(item);
        keys.forEach((key) => {
          if (fields.indexOf(key) === -1) {
            delete item[key];
          }
        });

        return item;
      });
    }

    // Limit and skip.
    if (isNumber(json.skip)) {
      if (isNumber(json.limit) && json.limit > 0) {
        Log.debug('Skipping and limiting data', json.skip, json.limit);
        return data.slice(json.skip, json.skip + json.limit);
      }

      Log.debug('Skipping data', json.skip);
      return data.slice(json.skip);
    }

    return data;
  }

  /**
   * Returns Object representation of the query.
   *
   * @returns {Object} Object
   */
  toPlainObject() {
    if (isDefined(this._parent)) {
      return this._parent.toPlainObject();
    }

    // Return set of parameters.
    const json = {
      fields: this.fields,
      filter: this.filter,
      sort: this.sort,
      skip: this.skip,
      limit: this.limit
    };

    return json;
  }

  /**
   * Returns query string representation of the query.
   *
   * @returns {Object} Query string object.
   */
  toQueryString() {
    const queryString = {};

    if (Object.keys(this.filter).length > 0) {
      queryString.query = this.filter;
    }

    if (this.fields.length > 0) {
      queryString.fields = this.fields.join(',');
    }

    if (isNumber(this.limit)) {
      queryString.limit = this.limit;
    }

    if (isNumber(this.skip) && this.skip > 0) {
      queryString.skip = this.skip;
    }

    if (Object.keys(this.sort).length > 0) {
      queryString.sort = this.sort;
    }

    const keys = Object.keys(queryString);
    keys.forEach((key) => {
      queryString[key] = typeof queryString[key] === 'string' ? queryString[key] : JSON.stringify(queryString[key]);
    });

    return queryString;
  }

  /**
   * Returns query string representation of the query.
   *
   * @return {string} Query string string.
   */
  toString() {
    return JSON.stringify(this.toQueryString());
  }
}