Source: Query.js

import utils from './utils'
import Component from './Component'

const DOMAIN = 'Query'
const INDEX_ERR = 'Index inaccessible after first operation'

// Reserved words used by JSData's Query Syntax
const reserved = {
  limit: '',
  offset: '',
  orderBy: '',
  skip: '',
  sort: '',
  where: ''
}

// Used by our JavaScript implementation of the LIKE operator
const escapeRegExp = /([.*+?^=!:${}()|[\]\/\\])/g
const percentRegExp = /%/g
const underscoreRegExp = /_/g
const escape = function (pattern) {
  return pattern.replace(escapeRegExp, '\\$1')
}

/**
 * A class used by the {@link Collection} class to build queries to be executed
 * against the collection's data. An instance of `Query` is returned by
 * {@link Collection#query}. Query instances are typically short-lived, and you
 * shouldn't have to create them yourself. Just use {@link Collection#query}.
 *
 * ```javascript
 * import {Query} from 'js-data'
 * ```
 *
 * @example
 * const posts = store.query('post').filter({ status: 'draft' }).limit(2).run()
 *
 * @class Query
 * @extends Component
 * @param {Collection} collection The collection on which this query operates.
 * @since 3.0.0
 */
export default Component.extend({
  constructor: function Query (collection) {
    utils.classCallCheck(this, Query)

    /**
     * The {@link Collection} on which this query operates.
     *
     * @name Query#collection
     * @since 3.0.0
     * @type {Collection}
     */
    this.collection = collection

    /**
     * The current data result of this query.
     *
     * @name Query#data
     * @since 3.0.0
     * @type {Array}
     */
    this.data = null
  },

  _applyWhereFromObject (where) {
    const fields = []
    const ops = []
    const predicates = []
    utils.forOwn(where, (clause, field) => {
      if (!utils.isObject(clause)) {
        clause = {
          '==': clause
        }
      }
      utils.forOwn(clause, (expr, op) => {
        fields.push(field)
        ops.push(op)
        predicates.push(expr)
      })
    })
    return {
      fields,
      ops,
      predicates
    }
  },

  _applyWhereFromArray (where) {
    const groups = []
    where.forEach((_where, i) => {
      if (utils.isString(_where)) {
        return
      }
      const prev = where[i - 1]
      const parser = utils.isArray(_where) ? this._applyWhereFromArray : this._applyWhereFromObject
      const group = parser.call(this, _where)
      if (prev === 'or') {
        group.isOr = true
      }
      groups.push(group)
    })
    groups.isArray = true
    return groups
  },

  _testObjectGroup (keep, first, group, item) {
    let i
    const fields = group.fields
    const ops = group.ops
    const predicates = group.predicates
    const len = ops.length
    for (i = 0; i < len; i++) {
      let op = ops[i]
      const isOr = op.charAt(0) === '|'
      op = isOr ? op.substr(1) : op
      const expr = this.evaluate(utils.get(item, fields[i]), op, predicates[i])
      if (expr !== undefined) {
        keep = first ? expr : (isOr ? keep || expr : keep && expr)
      }
      first = false
    }
    return { keep, first }
  },

  _testArrayGroup (keep, first, groups, item) {
    let i
    const len = groups.length
    for (i = 0; i < len; i++) {
      const group = groups[i]
      const parser = group.isArray ? this._testArrayGroup : this._testObjectGroup
      const result = parser.call(this, true, true, group, item)
      if (groups[i - 1]) {
        if (group.isOr) {
          keep = keep || result.keep
        } else {
          keep = keep && result.keep
        }
      } else {
        keep = result.keep
      }
      first = result.first
    }
    return { keep, first }
  },

  /**
   * Find all entities between two boundaries.
   *
   * @example <caption>Get the users ages 18 to 30.</caption>
   * const users = query.between(18, 30, { index: 'age' }).run()
   *
   * @example <caption>Same as above.</caption>
   * const users = query.between([18], [30], { index: 'age' }).run()
   *
   * @method Query#between
   * @param {Array} leftKeys Keys defining the left boundary.
   * @param {Array} rightKeys Keys defining the right boundary.
   * @param {Object} [opts] Configuration options.
   * @param {string} [opts.index] Name of the secondary index to use in the
   * query. If no index is specified, the main index is used.
   * @param {boolean} [opts.leftInclusive=true] Whether to include entities
   * on the left boundary.
   * @param {boolean} [opts.rightInclusive=false] Whether to include entities
   * on the left boundary.
   * @param {boolean} [opts.limit] Limit the result to a certain number.
   * @param {boolean} [opts.offset] The number of resulting entities to skip.
   * @returns {Query} A reference to itself for chaining.
   * @since 3.0.0
   */
  between (leftKeys, rightKeys, opts) {
    opts || (opts = {})
    if (this.data) {
      throw utils.err(`${DOMAIN}#between`)(500, 'Cannot access index')
    }
    this.data = this.collection.getIndex(opts.index).between(leftKeys, rightKeys, opts)
    return this
  },

  /**
   * The comparison function used by the {@link Query} class.
   *
   * @method Query#compare
   * @param {Array} orderBy An orderBy clause used for sorting and sub-sorting.
   * @param {number} index The index of the current orderBy clause being used.
   * @param {*} a The first item in the comparison.
   * @param {*} b The second item in the comparison.
   * @returns {number} -1 if `b` should preceed `a`. 0 if `a` and `b` are equal.
   * 1 if `a` should preceed `b`.
   * @since 3.0.0
   */
  compare (orderBy, index, a, b) {
    const def = orderBy[index]
    let cA = utils.get(a, def[0])
    let cB = utils.get(b, def[0])
    if (cA && utils.isString(cA)) {
      cA = cA.toUpperCase()
    }
    if (cB && utils.isString(cB)) {
      cB = cB.toUpperCase()
    }
    if (a === undefined) {
      a = null
    }
    if (b === undefined) {
      b = null
    }
    if (def[1].toUpperCase() === 'DESC') {
      const temp = cB
      cB = cA
      cA = temp
    }
    if (cA < cB) {
      return -1
    } else if (cA > cB) {
      return 1
    } else {
      if (index < orderBy.length - 1) {
        return this.compare(orderBy, index + 1, a, b)
      } else {
        return 0
      }
    }
  },

  /**
   * Predicate evaluation function used by the {@link Query} class.
   *
   * @method Query#evaluate
   * @param {*} value The value to evaluate.
   * @param {string} op The operator to use in this evaluation.
   * @param {*} predicate The predicate to use in this evaluation.
   * @returns {boolean} Whether the value passed the evaluation or not.
   * @since 3.0.0
   */
  evaluate (value, op, predicate) {
    const ops = this.constructor.ops
    if (ops[op]) {
      return ops[op](value, predicate)
    }
    if (op.indexOf('like') === 0) {
      return !utils.isNull(this.like(predicate, op.substr(4)).exec(value))
    } else if (op.indexOf('notLike') === 0) {
      return utils.isNull(this.like(predicate, op.substr(7)).exec(value))
    }
  },

  /**
   * Find the record or records that match the provided query or are accepted by
   * the provided filter function.
   *
   * @example <caption>Get the draft posts created less than three months</caption>
   * const posts = query.filter({
   *   where: {
   *     status: {
   *       '==': 'draft'
   *     },
   *     created_at_timestamp: {
   *       '>=': (new Date().getTime() (1000 * 60 * 60 * 24 * 30 * 3)) // 3 months ago
   *     }
   *   }
   * }).run()
   *
   * @example <caption>Use a custom filter function</caption>
   * const posts = query.filter(function (post) {
   *   return post.isReady()
   * }).run()
   *
   * @method Query#filter
   * @param {(Object|Function)} [queryOrFn={}] Selection query or filter
   * function.
   * @param {Function} [thisArg] Context to which to bind `queryOrFn` if
   * `queryOrFn` is a function.
   * @returns {Query} A reference to itself for chaining.
   * @since 3.0.0
   */
  filter (query, thisArg) {
    /**
     * Selection query as defined by JSData's [Query Syntax][querysyntax].
     *
     * [querysyntax]: http://www.js-data.io/v3.0/docs/query-syntax
     *
     * @example <caption>Empty "findAll" query</caption>
     * store.findAll('post').then((posts) => {
     *   console.log(posts) // [...]
     * })
     *
     * @example <caption>Empty "filter" query</caption>
     * const posts = store.filter('post')
     * console.log(posts) // [...]
     *
     * @example <caption>Complex "findAll" query</caption>
     * const PAGE_SIZE = 10
     * let currentPage = 3
     *
     * // Retrieve a filtered page of blog posts
     * store.findAll('post', {
     *   where: {
     *     status: {
     *       // WHERE status = 'published'
     *       '==': 'published'
     *     },
     *     author: {
     *       // AND author IN ('bob', 'alice')
     *       'in': ['bob', 'alice'],
     *       // OR author IN ('karen')
     *       '|in': ['karen']
     *     }
     *   },
     *   orderBy: [
     *     // ORDER BY date_published DESC,
     *     ['date_published', 'DESC'],
     *     // ORDER BY title ASC
     *     ['title', 'ASC']
     *   ],
     *   // LIMIT 10
     *   limit: PAGE_SIZE,
     *   // SKIP 20
     *   offset: PAGE_SIZE * (currentPage 1)
     * }).then((posts) => {
     *   console.log(posts) // [...]
     * })
     *
     * @namespace query
     * @property {number} [limit] See {@link query.limit}.
     * @property {number} [offset] See {@link query.offset}.
     * @property {string|Array[]} [orderBy] See {@link query.orderBy}.
     * @property {number} [skip] Alias for {@link query.offset}.
     * @property {string|Array[]} [sort] Alias for {@link query.orderBy}.
     * @property {Object} [where] See {@link query.where}.
     * @since 3.0.0
     * @tutorial ["http://www.js-data.io/v3.0/docs/query-syntax","JSData's Query Syntax"]
     */
    query || (query = {})
    this.getData()
    if (utils.isObject(query)) {
      let where = {}

      /**
       * Filtering criteria. Records that do not meet this criteria will be exluded
       * from the result.
       *
       * @example
       * TODO
       *
       * @name query.where
       * @type {Object}
       * @see http://www.js-data.io/v3.0/docs/query-syntax
       * @since 3.0.0
       */
      if (utils.isObject(query.where) || utils.isArray(query.where)) {
        where = query.where
      }
      utils.forOwn(query, function (value, key) {
        if (!(key in reserved) && !(key in where)) {
          where[key] = {
            '==': value
          }
        }
      })
      let groups

      // Apply filter for each field
      if (utils.isObject(where) && Object.keys(where).length !== 0) {
        groups = this._applyWhereFromArray([where])
      } else if (utils.isArray(where)) {
        groups = this._applyWhereFromArray(where)
      }

      if (groups) {
        this.data = this.data.filter((item, i) => this._testArrayGroup(true, true, groups, item).keep)
      }

      // Sort
      let orderBy = query.orderBy || query.sort

      if (utils.isString(orderBy)) {
        orderBy = [
          [orderBy, 'ASC']
        ]
      }
      if (!utils.isArray(orderBy)) {
        orderBy = null
      }

      /**
       * Determines how records should be ordered in the result.
       *
       * @example
       * TODO
       *
       * @name query.orderBy
       * @type {string|Array[]}
       * @see http://www.js-data.io/v3.0/docs/query-syntax
       * @since 3.0.0
       */
      if (orderBy) {
        let index = 0
        orderBy.forEach(function (def, i) {
          if (utils.isString(def)) {
            orderBy[i] = [def, 'ASC']
          }
        })
        this.data.sort((a, b) => this.compare(orderBy, index, a, b))
      }

      /**
       * Number of records to skip.
       *
       * @example <caption>Retrieve the first "page" of blog posts</caption>
       * const PAGE_SIZE = 10
       * let currentPage = 1
       * PostService.findAll({
       *   offset: PAGE_SIZE * (currentPage 1)
       *   limit: PAGE_SIZE
       * })
       *
       * @name query.offset
       * @type {number}
       * @see http://www.js-data.io/v3.0/docs/query-syntax
       * @since 3.0.0
       */
      if (utils.isNumber(query.skip)) {
        this.skip(query.skip)
      } else if (utils.isNumber(query.offset)) {
        this.skip(query.offset)
      }

      /**
       * Maximum number of records to retrieve.
       *
       * @example <caption>Retrieve the first "page" of blog posts</caption>
       * const PAGE_SIZE = 10
       * let currentPage = 1
       * PostService.findAll({
       *   offset: PAGE_SIZE * (currentPage 1)
       *   limit: PAGE_SIZE
       * })
       *
       * @name query.limit
       * @type {number}
       * @see http://www.js-data.io/v3.0/docs/query-syntax
       * @since 3.0.0
       */
      if (utils.isNumber(query.limit)) {
        this.limit(query.limit)
      }
    } else if (utils.isFunction(query)) {
      this.data = this.data.filter(query, thisArg)
    }
    return this
  },

  /**
   * Iterate over all entities.
   *
   * @method Query#forEach
   * @param {Function} forEachFn Iteration function.
   * @param {*} [thisArg] Context to which to bind `forEachFn`.
   * @returns {Query} A reference to itself for chaining.
   * @since 3.0.0
   */
  forEach (forEachFn, thisArg) {
    this.getData().forEach(forEachFn, thisArg)
    return this
  },

  /**
   * Find the entity or entities that match the provided key.
   *
   * @example <caption>Get the entity whose primary key is 25.</caption>
   * const entities = query.get(25).run()
   *
   * @example <caption>Same as above.</caption>
   * const entities = query.get([25]).run()
   *
   * @example <caption>Get all users who are active and have the "admin" role.</caption>
   * const activeAdmins = query.get(['active', 'admin'], {
   *   index: 'activityAndRoles'
   * }).run()
   *
   * @example <caption>Get all entities that match a certain weather condition.</caption>
   * const niceDays = query.get(['sunny', 'humid', 'calm'], {
   *   index: 'weatherConditions'
   * }).run()
   *
   * @method Query#get
   * @param {Array} keyList Key(s) defining the entity to retrieve. If
   * `keyList` is not an array (i.e. for a single-value key), it will be
   * wrapped in an array.
   * @param {Object} [opts] Configuration options.
   * @param {string} [opts.string] Name of the secondary index to use in the
   * query. If no index is specified, the main index is used.
   * @returns {Query} A reference to itself for chaining.
   * @since 3.0.0
   */
  get (keyList, opts) {
    keyList || (keyList = [])
    opts || (opts = {})
    if (this.data) {
      throw utils.err(`${DOMAIN}#get`)(500, INDEX_ERR)
    }
    if (keyList && !utils.isArray(keyList)) {
      keyList = [keyList]
    }
    if (!keyList.length) {
      this.getData()
      return this
    }
    this.data = this.collection.getIndex(opts.index).get(keyList)
    return this
  },

  /**
   * Find the entity or entities that match the provided keyLists.
   *
   * @example <caption>Get the posts where "status" is "draft" or "inReview".</caption>
   * const posts = query.getAll('draft', 'inReview', { index: 'status' }).run()
   *
   * @example <caption>Same as above.</caption>
   * const posts = query.getAll(['draft'], ['inReview'], { index: 'status' }).run()
   *
   * @method Query#getAll
   * @param {...Array} [keyList] Provide one or more keyLists, and all
   * entities matching each keyList will be retrieved. If no keyLists are
   * provided, all entities will be returned.
   * @param {Object} [opts] Configuration options.
   * @param {string} [opts.index] Name of the secondary index to use in the
   * query. If no index is specified, the main index is used.
   * @returns {Query} A reference to itself for chaining.
   * @since 3.0.0
   */
  getAll (...args) {
    let opts = {}
    if (this.data) {
      throw utils.err(`${DOMAIN}#getAll`)(500, INDEX_ERR)
    }
    if (!args.length || args.length === 1 && utils.isObject(args[0])) {
      this.getData()
      return this
    } else if (args.length && utils.isObject(args[args.length - 1])) {
      opts = args[args.length - 1]
      args.pop()
    }
    const collection = this.collection
    const index = collection.getIndex(opts.index)
    this.data = []
    args.forEach((keyList) => {
      this.data = this.data.concat(index.get(keyList))
    })
    return this
  },

  /**
   * Return the current data result of this query.
   *
   * @method Query#getData
   * @returns {Array} The data in this query.
   * @since 3.0.0
   */
  getData () {
    if (!this.data) {
      this.data = this.collection.index.getAll()
    }
    return this.data
  },

  /**
   * Implementation used by the `like` operator. Takes a pattern and flags and
   * returns a `RegExp` instance that can test strings.
   *
   * @method Query#like
   * @param {string} pattern Testing pattern.
   * @param {string} flags Flags for the regular expression.
   * @returns {RegExp} Regular expression for testing strings.
   * @since 3.0.0
   */
  like (pattern, flags) {
    return new RegExp(`^${(escape(pattern).replace(percentRegExp, '.*').replace(underscoreRegExp, '.'))}$`, flags)
  },

  /**
   * Limit the result.
   *
   * @example <caption>Get only the first 10 draft posts.</caption>
   * const posts = query.get('draft', { index: 'status' }).limit(10).run()
   *
   * @method Query#limit
   * @param {number} num The maximum number of entities to keep in the result.
   * @returns {Query} A reference to itself for chaining.
   * @since 3.0.0
   */
  limit (num) {
    if (!utils.isNumber(num)) {
      throw utils.err(`${DOMAIN}#limit`, 'num')(400, 'number', num)
    }
    const data = this.getData()
    this.data = data.slice(0, Math.min(data.length, num))
    return this
  },

  /**
   * Apply a mapping function to the result data.
   *
   * @example
   * const ages = UserCollection.query().map((user) => {
   *   return user.age
   * }).run()
   *
   * @method Query#map
   * @param {Function} mapFn Mapping function.
   * @param {*} [thisArg] Context to which to bind `mapFn`.
   * @returns {Query} A reference to itself for chaining.
   * @since 3.0.0
   */
  map (mapFn, thisArg) {
    this.data = this.getData().map(mapFn, thisArg)
    return this
  },

  /**
   * Return the result of calling the specified function on each item in this
   * collection's main index.
   *
   * @example
   * const stringAges = UserCollection.query().mapCall('toString').run()
   *
   * @method Query#mapCall
   * @param {string} funcName Name of function to call
   * @parama {...*} [args] Remaining arguments to be passed to the function.
   * @returns {Query} A reference to itself for chaining.
   * @since 3.0.0
   */
  mapCall (funcName, ...args) {
    this.data = this.getData().map(function (item) {
      return item[funcName](...args)
    })
    return this
  },

  /**
   * Complete the execution of the query and return the resulting data.
   *
   * @method Query#run
   * @returns {Array} The result of executing this query.
   * @since 3.0.0
   */
  run () {
    const data = this.data
    this.data = null
    return data
  },

  /**
   * Skip a number of results.
   *
   * @example <caption>Get all but the first 10 draft posts.</caption>
   * const posts = query.get('draft', { index: 'status' }).skip(10).run()
   *
   * @method Query#skip
   * @param {number} num The number of entities to skip.
   * @returns {Query} A reference to itself for chaining.
   * @since 3.0.0
   */
  skip (num) {
    if (!utils.isNumber(num)) {
      throw utils.err(`${DOMAIN}#skip`, 'num')(400, 'number', num)
    }
    const data = this.getData()
    if (num < data.length) {
      this.data = data.slice(num)
    } else {
      this.data = []
    }
    return this
  }
}, {
  /**
   * The filtering operators supported by {@link Query#filter}, and which are
   * implemented by adapters (for the most part).
   *
   * @example <caption>Variant 1</caption>
   * const publishedPosts = store.filter('post', {
   *   status: 'published',
   *   limit: 2
   * })
   *
   * @example <caption>Variant 2</caption>
   * const publishedPosts = store.filter('post', {
   *   where: {
   *     status: {
   *       '==': 'published'
   *     }
   *   },
   *   limit: 2
   * })
   *
   * @example <caption>Variant 3</caption>
   * const publishedPosts = store.query('post').filter({
   *   status: 'published'
   * }).limit(2).run()
   *
   * @example <caption>Variant 4</caption>
   * const publishedPosts = store.query('post').filter({
   *   where: {
   *     status: {
   *       '==': 'published'
   *     }
   *   }
   * }).limit(2).run()
   *
   * @example <caption>Multiple operators</caption>
   * const myPublishedPosts = store.filter('post', {
   *   where: {
   *     status: {
   *       '==': 'published'
   *     },
   *     user_id: {
   *       '==': currentUser.id
   *     }
   *   }
   * })
   *
   * @name Query.ops
   * @property {Function} == Equality operator.
   * @property {Function} != Inequality operator.
   * @property {Function} > Greater than operator.
   * @property {Function} >= Greater than (inclusive) operator.
   * @property {Function} < Less than operator.
   * @property {Function} <= Less than (inclusive) operator.
   * @property {Function} isectEmpty Operator that asserts that the intersection
   * between two arrays is empty.
   * @property {Function} isectNotEmpty Operator that asserts that the
   * intersection between two arrays is __not__ empty.
   * @property {Function} in Operator that asserts whether a value is in an
   * array.
   * @property {Function} notIn Operator that asserts whether a value is __not__
   * in an array.
   * @property {Function} contains Operator that asserts whether an array
   * contains a value.
   * @property {Function} notContains Operator that asserts whether an array
   * does __not__ contain a value.
   * @since 3.0.0
   * @type {Object}
   */
  ops: {
    '=': function (value, predicate) {
      return value == predicate // eslint-disable-line
    },
    '==': function (value, predicate) {
      return value == predicate // eslint-disable-line
    },
    '===': function (value, predicate) {
      return value === predicate
    },
    '!=': function (value, predicate) {
      return value != predicate // eslint-disable-line
    },
    '!==': function (value, predicate) {
      return value !== predicate
    },
    '>': function (value, predicate) {
      return value > predicate
    },
    '>=': function (value, predicate) {
      return value >= predicate
    },
    '<': function (value, predicate) {
      return value < predicate
    },
    '<=': function (value, predicate) {
      return value <= predicate
    },
    'isectEmpty': function (value, predicate) {
      return !utils.intersection((value || []), (predicate || [])).length
    },
    'isectNotEmpty': function (value, predicate) {
      return utils.intersection((value || []), (predicate || [])).length
    },
    'in': function (value, predicate) {
      return predicate.indexOf(value) !== -1
    },
    'notIn': function (value, predicate) {
      return predicate.indexOf(value) === -1
    },
    'contains': function (value, predicate) {
      return (value || []).indexOf(predicate) !== -1
    },
    'notContains': function (value, predicate) {
      return (value || []).indexOf(predicate) === -1
    }
  }
})

/**
 * Create a subclass of this Query.
 *
 * @example <caption>Extend the class in a cross-browser manner.</caption>
 * import {Query} from 'js-data'
 * const CustomQueryClass = Query.extend({
 *   foo () { return 'bar' }
 * })
 * const customQuery = new CustomQueryClass({ name: 'test' })
 * console.log(customQuery.foo()) // "bar"
 *
 * @example <caption>Extend the class using ES2015 class syntax.</caption>
 * class CustomQueryClass extends Query {
 *   foo () { return 'bar' }
 * }
 * const customQuery = new CustomQueryClass({ name: 'test' })
 * console.log(customQuery.foo()) // "bar"
 *
 * @method Query.extend
 * @param {Object} [props={}] Properties to add to the prototype of the
 * subclass.
 * @param {Object} [classProps={}] Static properties to add to the subclass.
 * @returns {Constructor} Subclass of this Query.
 * @since 3.0.0
 */