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.
 *
 * ```javascript
 * import {Query} from 'js-data'
 * ```
 *
 * @class Query
 * @extends Component
 * @param {Collection} collection The collection on which this query operates.
 */
export default Component.extend({
  constructor: function Query (collection) {
    const self = this
    utils.classCallCheck(self, Query)

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

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

  /**
   * Find all entities between two boundaries.
   *
   * Get the users ages 18 to 30
   * ```js
   * const users = query.between(18, 30, { index: 'age' }).run()
   * ```
   * Same as above
   * ```js
   * const users = query.between([18], [30], { index: 'age' }).run()
   * ```
   *
   * @name Query#between
   * @method
   * @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.
   * @return {Query} A reference to itself for chaining.
   */
  between (leftKeys, rightKeys, opts) {
    const self = this
    opts || (opts = {})
    if (self.data) {
      throw utils.err(`${DOMAIN}#between`)(500, 'Cannot access index')
    }
    self.data = self.collection.getIndex(opts.index).between(leftKeys, rightKeys, opts)
    return self
  },

  /**
   * The comparison function used by the Query class.
   *
   * @name Query#compare
   * @method
   * @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.
   * @return {number} -1 if `b` should preceed `a`. 0 if `a` and `b` are equal.
   * 1 if `a` should preceed `b`.
   */
  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 Query class.
   *
   * @name Query#evaluate
   * @method
   * @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.
   * @return {boolean} Whether the value passed the evaluation or not.
   */
  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 entity or entities that match the provided query or pass the
   * provided filter function.
   *
   * #### Example
   *
   * Get the draft posts created less than three months
   * ```js
   * const posts = query.filter({
   *   where: {
   *     status: {
   *       '==': 'draft'
   *     },
   *     created_at_timestamp: {
   *       '>=': (new Date().getTime() - (1000 * 60 * 60 * 24 * 30 * 3)) // 3 months ago
   *     }
   *   }
   * }).run()
   * ```
   * Use a custom filter function
   * ```js
   * const posts = query.filter(function (post) {
   *   return post.isReady()
   * }).run()
   * ```
   *
   * @name Query#filter
   * @method
   * @param {(Object|Function)} [queryOrFn={}] - Selection query or filter
   * function.
   * @param {Function} [thisArg] - Context to which to bind `queryOrFn` if
   * `queryOrFn` is a function.
   * @return {Query} A reference to itself for chaining.
   */
  filter (query, thisArg) {
    const self = this
    query || (query = {})
    self.getData()
    if (utils.isObject(query)) {
      let where = {}
      // Filter
      if (utils.isObject(query.where)) {
        where = query.where
      }
      utils.forOwn(query, function (value, key) {
        if (!(key in reserved) && !(key in where)) {
          where[key] = {
            '==': value
          }
        }
      })

      const fields = []
      const ops = []
      const predicates = []
      utils.forOwn(where, function (clause, field) {
        if (!utils.isObject(clause)) {
          clause = {
            '==': clause
          }
        }
        utils.forOwn(clause, function (expr, op) {
          fields.push(field)
          ops.push(op)
          predicates.push(expr)
        })
      })
      if (fields.length) {
        let i
        let len = fields.length
        self.data = self.data.filter(function (item) {
          let first = true
          let keep = true

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

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

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

      // Apply 'orderBy'
      if (orderBy) {
        let index = 0
        orderBy.forEach(function (def, i) {
          if (utils.isString(def)) {
            orderBy[i] = [def, 'ASC']
          }
        })
        self.data.sort(function (a, b) {
          return self.compare(orderBy, index, a, b)
        })
      }

      // Skip
      if (utils.isNumber(query.skip)) {
        self.skip(query.skip)
      } else if (utils.isNumber(query.offset)) {
        self.skip(query.offset)
      }
      // Limit
      if (utils.isNumber(query.limit)) {
        self.limit(query.limit)
      }
    } else if (utils.isFunction(query)) {
      self.data = self.data.filter(query, thisArg)
    }
    return self
  },

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

  /**
   * Find the entity or entities that match the provided key.
   *
   * #### Example
   *
   * Get the entity whose primary key is 25
   * ```js
   * const entities = query.get(25).run()
   * ```
   * Same as above
   * ```js
   * const entities = query.get([25]).run()
   * ```
   * Get all users who are active and have the "admin" role
   * ```js
   * const activeAdmins = query.get(['active', 'admin'], {
   *   index: 'activityAndRoles'
   * }).run()
   * ```
   * Get all entities that match a certain weather condition
   * ```js
   * const niceDays = query.get(['sunny', 'humid', 'calm'], {
   *   index: 'weatherConditions'
   * }).run()
   * ```
   *
   * @name Query#get
   * @method
   * @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.
   * @return {Query} A reference to itself for chaining.
   */
  get (keyList, opts) {
    const self = this
    keyList || (keyList = [])
    opts || (opts = {})
    if (self.data) {
      throw utils.err(`${DOMAIN}#get`)(500, INDEX_ERR)
    }
    if (keyList && !utils.isArray(keyList)) {
      keyList = [keyList]
    }
    if (!keyList.length) {
      self.getData()
      return self
    }
    self.data = self.collection.getIndex(opts.index).get(keyList)
    return self
  },

  /**
   * Find the entity or entities that match the provided keyLists.
   *
   * #### Example
   *
   * Get the posts where "status" is "draft" or "inReview"
   * ```js
   * const posts = query.getAll('draft', 'inReview', { index: 'status' }).run()
   * ```
   * Same as above
   * ```js
   * const posts = query.getAll(['draft'], ['inReview'], { index: 'status' }).run()
   * ```
   *
   * @name Query#getAll
   * @method
   * @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.
   * @return {Query} A reference to itself for chaining.
   */
  getAll (...args) {
    const self = this
    let opts = {}
    if (self.data) {
      throw utils.err(`${DOMAIN}#getAll`)(500, INDEX_ERR)
    }
    if (!args.length || args.length === 1 && utils.isObject(args[0])) {
      self.getData()
      return self
    } else if (args.length && utils.isObject(args[args.length - 1])) {
      opts = args[args.length - 1]
      args.pop()
    }
    const collection = self.collection
    const index = collection.getIndex(opts.index)
    self.data = []
    args.forEach(function (keyList) {
      self.data = self.data.concat(index.get(keyList))
    })
    return self
  },

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

  like (pattern, flags) {
    return new RegExp(`^${(escape(pattern).replace(percentRegExp, '.*').replace(underscoreRegExp, '.'))}$`, flags)
  },

  /**
   * Limit the result.
   *
   * #### Example
   *
   * Get only the first 10 draft posts
   * ```js
   * const posts = query.get('draft', { index: 'status' }).limit(10).run()
   * ```
   *
   * @name Query#limit
   * @method
   * @param {number} num - The maximum number of entities to keep in the result.
   * @return {Query} A reference to itself for chaining.
   */
  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.
   *
   * @name Query#map
   * @method
   * @param {Function} mapFn - Mapping function.
   * @param {*} [thisArg] - Context to which to bind `mapFn`.
   * @return {Query} A reference to itself for chaining.
   */
  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.
   * @name Query#mapCall
   * @method
   * @param {string} funcName - Name of function to call
   * @parama {...*} [args] - Remaining arguments to be passed to the function.
   * @return {Query} A reference to itself for chaining.
   */
  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.
   *
   * @name Query#run
   * @method
   * @return {Array} The result of executing this query.
   */
  run () {
    const data = this.data
    this.data = null
    return data
  },

  /**
   * Skip a number of results.
   *
   * #### Example
   *
   * Get all but the first 10 draft posts
   * ```js
   * const posts = query.get('draft', { index: 'status' }).skip(10).run()
   * ```
   *
   * @name Query#skip
   * @method
   * @param {number} num - The number of entities to skip.
   * @return {Query} A reference to itself for chaining.
   */
  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
  }
}, {
  /**
   * TODO
   *
   * @name Query.ops
   * @type {Object}
   */
  ops: {
    '==': 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
    }
  }
})