import { addHiddenPropsToTarget, classCallCheck, extend, forOwn, get, intersection, isArray, isFunction, isNull, isNumber, isObject, isString } from './utils' /** * 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}. * * ```javascript * import {Query} from 'js-data' * ``` * * @class Query * @param {Collection} collection - The collection on which this query operates. */ export default function Query (collection) { classCallCheck(this, Query) /** * The collection on which this query operates. * * @name Query#collection * @type {Collection} */ this.collection = collection /** * The data result of this query. * * @name Query#data * @type {Array} */ this.data = null } /** * Create a Query subclass. * * ```javascript * var MyQuery = Query.extend({ * foo: function () { return 'bar' } * }) * var query = new MyQuery() * query.foo() // "bar" * ``` * * @name Query.extend * @method * @param {Object} [props={}] Properties to add to the prototype of the * subclass. * @param {Object} [classProps={}] Static properties to add to the subclass. * @return {Function} Subclass of Query. */ Query.extend = extend const reserved = { skip: '', offset: '', where: '', limit: '', orderBy: '', sort: '' } const escapeRegExp = /([.*+?^=!:${}()|[\]\/\\])/g const percentRegExp = /%/g const underscoreRegExp = /_/g function escape (pattern) { return pattern.replace(escapeRegExp, '\\$1') } /** * TODO * * @name Query.ops * @type {Object} */ Query.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 !intersection((value || []), (predicate || [])).length }, 'isectNotEmpty': function (value, predicate) { return 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 } } addHiddenPropsToTarget(Query.prototype, { compare (orderBy, index, a, b) { const def = orderBy[index] let cA = get(a, def[0]) let cB = get(b, def[0]) if (cA && isString(cA)) { cA = cA.toUpperCase() } if (cB && isString(cB)) { cB = cB.toUpperCase() } a || (a = null) b || (b = null) if (def[1] === 'DESC') { if (cB < cA) { return -1 } else if (cB > cA) { return 1 } else { if (index < orderBy.length - 1) { return this.compare(orderBy, index + 1, a, b) } else { return 0 } } } else { 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 } } } }, evaluate (value, op, predicate) { if (Query.ops[op]) { return Query.ops[op](value, predicate) } if (op.indexOf('like') === 0) { return !isNull(this.like(predicate, op.substr(4)).exec(value)) } else if (op.indexOf('notLike') === 0) { return isNull(this.like(predicate, op.substr(7)).exec(value)) } }, like (pattern, flags) { return new RegExp(`^${(escape(pattern).replace(percentRegExp, '.*').replace(underscoreRegExp, '.'))}$`, flags) }, /** * Return the current data result of this query. * @name Query#getData * @method * @return {Array} The data in this query. */ getData () { if (!this.data) { this.data = this.collection.index.getAll() } return this.data }, /** * 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) { opts || (opts = {}) const collection = this.collection const index = opts.index ? collection.indexes[opts.index] : collection.index if (this.data) { throw new Error('Cannot access index after first operation!') } this.data = index.between(leftKeys, rightKeys, opts) 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) { opts || (opts = {}) if (this.data) { throw new Error('Cannot access index after first operation!') } if (keyList && !isArray(keyList)) { keyList = [keyList] } if (!keyList.length) { this.getData() return this } const collection = this.collection const index = opts.index ? collection.indexes[opts.index] : collection.index this.data = index.get(keyList) return this }, /** * 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) { let opts = {} if (this.data) { throw new Error('Cannot access index after first operation!') } if (!args.length || args.length === 1 && isObject(args[0])) { this.getData() return this } else if (args.length && isObject(args[args.length - 1])) { opts = args[args.length - 1] args.pop() } const collection = this.collection const index = opts.index ? collection.indexes[opts.index] : collection.index this.data = [] args.forEach(keyList => { this.data = this.data.concat(index.get(keyList)) }) return this }, /** * 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 (isObject(query)) { let where = {} // Filter if (isObject(query.where)) { where = query.where } forOwn(query, function (value, key) { if (!(key in reserved) && !(key in where)) { where[key] = { '==': value } } }) const fields = [] const ops = [] const predicates = [] forOwn(where, function (clause, field) { if (!isObject(clause)) { clause = { '==': clause } } 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(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 (isString(orderBy)) { orderBy = [ [orderBy, 'ASC'] ] } if (!isArray(orderBy)) { orderBy = null } // Apply 'orderBy' if (orderBy) { let index = 0 orderBy.forEach(function (def, i) { if (isString(def)) { orderBy[i] = [def, 'ASC'] } }) self.data.sort(function (a, b) { return self.compare(orderBy, index, a, b) }) } // Skip if (isNumber(query.skip)) { self.skip(query.skip) } else if (isNumber(query.offset)) { self.skip(query.offset) } // Limit if (isNumber(query.limit)) { self.limit(query.limit) } } else if (isFunction(query)) { self.data = self.data.filter(query, thisArg) } return self }, /** * 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 (!isNumber(num)) { throw new TypeError(`skip: Expected number but found ${typeof num}!`) } const data = this.getData() if (num < data.length) { this.data = data.slice(num) } else { this.data = [] } return this }, /** * 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 (!isNumber(num)) { throw new TypeError(`limit: Expected number but found ${typeof num}!`) } const data = this.getData() this.data = data.slice(0, Math.min(data.length, num)) return this }, /** * 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 }, /** * 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 } })