typhonjs-core-backbone-query/src/BackboneQuery.js
/**
* A fork of Backbone Query...
*
* Backbone Query - A lightweight query API for Backbone Collections
* (c)2012 - Dave Tonge
* May be freely distributed according to MIT license.
* https://github.com/davidgtonge/backbone_query
*
*
* (c)2015-present Michael Leahy
* https://github.com/typhonjs/typhonjs-core-backbone-query
*/
'use strict';
import _ from 'underscore';
/**
* BackboneQuery -- Provides client side sorting based on a query API.
* -------------
*
* Forked from https://github.com/davidgtonge/backbone_query
*
* A lightweight (3KB minified) utility for Backbone projects, that works in the Browser and on the Server. Adds the
* ability to search for models with a Query API similar to MongoDB.
*
* The huge benefit of using BackboneQuery is that queries can be stored as JSON.
*
* Usage
* -----
*
* The major difference of this implementation is that the API is not attached to a collection, but can be run against
* any collection by invoking the methods with a target collection.
*
* Find
* -----
* **_ $equal _**
*
* Performs a strict equality test using ===. If no operator is provided and the query value isn't a regex then `$equal`
* is assumed.
*
* If the attribute in the model is an array then the query value is searched for in the array in the same way as
* `$contains`.
*
* If the query value is an object (including array) then a deep comparison is performed using underscores `_.isEqual`.
*
* ** Example: **
* ```
* BackboneQuery.find(collection, { title: "Test" });
* // Returns all models which have a "title" attribute of "Test"
*
*
* BackboneQuery.find(collection, { title: { $equal: "Test" } }); // Same as above
* BackboneQuery.find(collection, { colors: "red" });
* // Returns models which contain the value "red" in a "colors" attribute that is an array.
*
*
* BackboneQuery.find(collection, { colors: ["red", "yellow"] });
* // Returns models which contain a colors attribute with the array ["red", "yellow"]
* ```
*
* **_ $contains _**
*
* Assumes that the model property is an array and searches for the query value in the array.
*
* ** Example: **
* ```
* BackboneQuery.find(collection, { colors: { $contains: "red" } });
* // Returns models which contain the value "red" in a "colors" attribute that is an array.
* e.g. a model with this attribute colors:["red", "yellow", "blue"] would be returned.
* ```
*
*
* **_ $ne _**
*
* "Not equal", the opposite of $equal, returns all models which don't have the query value
*
* ** Example: **
* ```
* BackboneQuery.find(collection, { title: { $ne: "Test" } });
* // Returns all models which don't have a "title" attribute of "Test"
* ```
*
*
* **_ $lt, $lte, $gt, $gte _**
*
* These conditional operators can be used for greater than and less than comparisons in queries
*
* ** Example: **
* ```
* BackboneQuery.find(collection, { likes: { $lt:10 } });
* // Returns all models which have a "likes" attribute of less than 10
*
*
* BackboneQuery.find(collection, { likes: { $lte:10 } });
* // Returns all models which have a "likes" attribute of less than or equal to 10
*
*
* BackboneQuery.find(collection, { likes: { $gt:10 } });
* // Returns all models which have a "likes" attribute of greater than 10
*
*
* BackboneQuery.find(collection, { likes: { $gte:10 } });
* // Returns all models which have a "likes" attribute of greater than or equal to 10
* ```
*
*
* **_ $between _**
*
* To check if a value is in-between 2 query values use the $between operator and supply an array with the min and max
* value.
*
* ** Example: **
* ```
* BackboneQuery.find(collection, { likes: { $between: [5, 15] } });
* // Returns all models which have a "likes" attribute of greater than 5 and less then 15
* ```
*
*
* **_ $in _**
*
* An array of possible values can be supplied using $in, a model will be returned if any of the supplied values is
* matched.
*
* ** Example: **
* ```
* BackboneQuery.find(collection, { title: { $in: ["About", "Home", "Contact"] } });
* // Returns all models which have a title attribute of either "About", "Home", or "Contact"
* ```
*
*
* **_ $nin _**
*
* "Not in", the opposite of $in. A model will be returned if none of the supplied values is matched.
*
* ** Example: **
* ```
* BackboneQuery.find(collection, { title: { $nin: ["About", "Home", "Contact"] } });
* // Returns all models which don't have a title attribute of either "About", "Home", or "Contact"
* ```
*
*
* **_ $all _**
*
* Assumes the model property is an array and only returns models where all supplied values are matched.
*
* ** Example: **
* ```
* BackboneQuery.find(collection, { colors: { $all: ["red", "yellow"] } });
* // Returns all models which have "red" and "yellow" in their colors attribute.
* // A model with the attribute colors:["red","yellow","blue"] would be returned.
* // But a model with the attribute colors:["red","blue"] would not be returned.
* ```
*
*
* **_ $any _**
*
* Assumes the model property is an array and returns models where any of the supplied values are matched.
*
* ** Example: **
* ```
* BackboneQuery.find(collection, { colors: { $any: ["red", "yellow"] } });
* // Returns models which have either "red" or "yellow" in their colors attribute.
* ```
*
*
* **_ $size _**
*
* Assumes the model property has a length (i.e. is either an array or a string). Only returns models the model
* property's length matches the supplied values.
*
* ** Example: **
* ```
* BackboneQuery.find(collection, { colors: { $size:2 } });
* // Returns all models which 2 values in the colors attribute
* ```
*
*
* $exists or $has
*
* Checks for the existence of an attribute. Can be supplied either true or false.
*
* Example:
*
* BackboneQuery.find(collection, { title: { $exists: true } });
*
* // Returns all models which have a "title" attribute
*
* BackboneQuery.find(collection, { title: { $has: false } });
*
* // Returns all models which don't have a "title" attribute
*
*
*
* $like
*
* Assumes the model attribute is a string and checks if the supplied query value is a substring of the property.
* Uses indexOf rather than regex for performance reasons.
*
* Example:
*
* BackboneQuery.find(collection, { title: { $like: "Test" } });
*
* //Returns all models which have a "title" attribute that
*
* //contains the string "Test", e.g. "Testing", "Tests", "Test", etc.
*
*
*
* $likeI
*
* The same as above but performs a case insensitive search using indexOf and toLowerCase (still faster than Regex).
*
* Example:
*
* BackboneQuery.find(collection, { title: { $likeI: "Test" } });
*
* //Returns all models which have a "title" attribute that
*
* //contains the string "Test", "test", "tEst","tesT", etc.
*
*
*
* $regex
*
* Checks if the model attribute matches the supplied regular expression. The regex query can be supplied without
* the `$regex` keyword.
*
* Example:
*
* BackboneQuery.find(collection, { content: { $regex: /coffeescript/gi } });
*
* // Checks for a regex match in the content attribute
*
* BackboneQuery.find(collection, { content: /coffeescript/gi });
*
* // Same as above
*
*
*
* $cb
*
* A callback function can be supplied as a test. The callback will receive the attribute and should return either
* true or false. `this` will be set to the current model, this can help with tests against computed properties.
*
* Example:
*
* BackboneQuery.find(collection, { title: { $cb: function(attr){ return attr.charAt(0) === "c"; } } });
*
* // Returns all models that have a title attribute that starts with "c"
*
* BackboneQuery.find(collection, { computed_test: { $cb: function(){ return this.computed_property() > 10; } } });
*
* // Returns all models where the computed_property method returns a value greater than 10.
*
* For callbacks that use `this` rather than the model attribute, the key name supplied is arbitrary and has no
* effect on the results. If the only test you were performing was like the above test it would make more sense to
* simply use `Collection.filter`. However if you are performing other tests or are using the
* paging / sorting / caching options of backbone query, then this functionality is useful.
*
*
*
* $elemMatch
*
* This operator allows you to perform queries in nested arrays similar to MongoDB For example you may have a
* collection of models in with this kind of data structure:
*
* Example:
*
* let posts = new Collection([
*
* {title: "Home", comments:[
*
* {text:"I like this post"},
*
* {text:"I love this post"},
*
* {text:"I hate this post"}
*
* ]},
*
* {title: "About", comments:[
*
* {text:"I like this page"},
*
* {text:"I love this page"},
*
* {text:"I really like this page"}
*
* ]}
*
* ]);
*
*
* To search for posts which have the text "really" in any of the comments you could search like this:
*
* BackboneQuery.find(posts, {
*
* comments: {
*
* $elemMatch: {
*
* text: /really/i
*
* }
*
* }
*
* });
*
*
* All of the operators above can be performed on `$elemMatch` queries, e.g. `$all`, `$size` or `$lt`. `$elemMatch`
* queries also accept compound operators, for example this query searches for all posts that have at least one
* comment without the word "really" and with the word "totally".
*
* BackboneQuery.find(posts, {
*
* comments: {
*
* $elemMatch: {
*
* $not: {
*
* text: /really/i
*
* },
*
* $and: {
*
* text: /totally/i
*
* }
* }
*
* }
*
* });
*
*
*
* $computed
*
* This operator allows you to perform queries on computed properties. For example you may want to perform a query
* for a persons full name, even though the first and last name are stored separately in your db / model. For
* example:
*
* Example:
*
* class TestModel extends Backbone.Model {
*
* full_name() {
*
* return (this.get('first_name')) + " " + (this.get('last_name'));
*
* }
*
* });
*
* let a = new TestModel({
*
* first_name: "Dave",
*
* last_name: "Tonge"
*
* });
*
* let b = new TestModel({
*
* first_name: "John",
*
* last_name: "Smith"
*
* });
*
* let collection = new Collection([a, b]);
*
* BackboneQuery.find(collection, { full_name: { $computed: "Dave Tonge" } });
*
* // Returns the model with the computed `full_name` equal to Dave Tonge
*
* BackboneQuery.find(collection, { full_name: { $computed: { $likeI: "john smi" } } });
*
* // Any of the previous operators can be used (including elemMatch is required)
*
*
*
* Combined Queries
* ----------------
* Multiple queries can be combined together. By default all supplied queries use the `$and` operator. However it is
* possible to specify either `$or`, `$nor`, `$not` to implement alternate logic.
*
*
* $and
*
* BackboneQuery.find(collection, { $and: { title: { $like: "News" }, likes: { $gt: 10 }}});
*
* // Returns all models that contain "News" in the title and have more than 10 likes.
*
* BackboneQuery.find(collection, { title: { $like: "News" }, likes: { $gt: 10 } });
*
* // Same as above as $and is assumed if not supplied
*
*
*
* $or
*
* BackboneQuery.find(collection, { $or: { title: { $like: "News" }, likes: { $gt: 10 } } });
*
* // Returns all models that contain "News" in the title OR have more than 10 likes.
*
*
* $nor
*
* The opposite of `$or`
*
* BackboneQuery.find(collection, { $nor: { title: { $like: "News" }, likes: { $gt: 10 } } });
*
* // Returns all models that don't contain "News" in the title NOR have more than 10 likes.
*
*
* $not
*
* The opposite of `$and`
*
* BackboneQuery.find(collection, { $not: { title: { $like: "News" }, likes: { $gt: 10 } } });
*
* // Returns all models that don't contain "News" in the title AND DON'T have more than 10 likes.
*
*
* If you need to perform multiple queries on the same key, then you can supply the query as an array:
*
* BackboneQuery.find(collection, {
*
* $or:[
*
* {title:"News"},
*
* {title:"About"}
*
* ]
*
* });
*
* // Returns all models with the title "News" or "About".
*
*
* Compound Queries
* ----------------
* It is possible to use multiple combined queries, for example searching for models that have a specific title
* attribute, and either a category of "abc" or a tag of "xyz".
*
* BackboneQuery.find(collection, {
*
* $and: { title: { $like: "News" } },
*
* $or: {likes: { $gt: 10 }, color: { $contains:"red" } }
*
* });
*
* //Returns models that have "News" in their title and either have more than 10 likes or contain the color red.
*
*
* Sorting
* -------
* Optional `sortBy` and `order` attributes can be supplied as part of an options object. `sortBy` can either be a
* model key or a callback function which will be called with each model in the array.
*
* BackboneQuery.find(collection, { title: { $like: "News" } }, { sortBy: "likes" });
*
* // Returns all models that contain "News" in the title, sorted according to their "likes" attribute (ascending)
*
* BackboneQuery.find(collection, { title: { $like: "News" } }, { sortBy: "likes", order: "desc" });
*
* // Same as above, but "descending"
*
* BackboneQuery.find(collection,
*
* { title: { $like: "News" } },
*
* { sortBy: function(model){ return model.get("title").charAt(1); } }
*
* );
*
* // Results sorted according to 2nd character of the title attribute
*
*
*
* Paging
* ------
* To return only a subset of the results paging properties can be supplied as part of an options object. A limit
* property must be supplied and optionally a offset or a page property can be supplied.
*
* BackboneQuery.find(collection, { likes:{ $gt: 10 } }, { limit: 10 });
*
* // Returns the first 10 models that have more than 10 likes.
*
* BackboneQuery.find(collection, { likes:{ $gt: 10 } }, { limit: 10, offset: 5 });
*
* // Returns 10 models that have more than 10 likes starting at the 6th model in the results.
*
* BackboneQuery.find(collection, { likes: { $gt: 10 } }, { limit: 10, page: 2 });
*
* // Returns 10 models that have more than 10 likes starting at the 11th model in the results (page 2).
*
*
* When using the paging functionality, you will normally need to know the number of pages so that you can render
* the correct interface for the user. Backbone Query can send the number of pages of results to a supplied callback.
* The callback should be passed as a pager property on the options object. This callback will also receive the
* sliced models as a second variable.
*
* Here is an example of a simple paging setup using the pager callback option:
*
* TODO Provide example!
*
* Caching Results
* ---------------
* To enable caching set the cache flag to true in the options object. This can greatly improve performance when
* paging through results as the unpaged results will be saved. This options is not enabled by default as if models
* are changed, added to, or removed from the collection, then the query cache will be out of date. If you know that
* your data is static and won't change then caching can be enabled without any problems. If your data is dynamic
* (as in most Backbone Apps) then a helper cache reset method is provided: `reset_query_cache`. This method should
* be bound to your collections change, add and remove events (depending on how your data can be changed).
*
* Cache will be saved in a `_query_cache` property on each collection where a cache query is performed.
*
* @example
* BackboneQuery.find(collection, { likes:{ $gt: 10 } }, { limit: 10, page: 1, cache: true });
* //The first query will operate as normal and return the first page of results
*
* BackboneQuery.find(collection, { likes:{ $gt: 10 } }, { limit:10, page: 2, cache: true });
* //The second query has an identical query object to the first query, so therefore the results will be retrieved
* //from the cache, before the paging parameters are applied.
*
* // Binding the reset_query_cache method
* MyCollection extends Backbone.Collection {
* initialize() {
* this.bind("change", () => { BackboneQuery.resetQueryCache(this) }, this);
* }
* });
*/
export default class BackboneQuery
{
/**
* Returns a sorted array of models from the collection that match the query.
*
* @param {Collection} collection - Target collection
* @param {string} query - Query string
* @param {Object} options - Optional parameters
* @returns {*}
*/
static find(collection, query, options = {})
{
let models;
if (options.cache)
{
models = s_GET_CACHE(collection, query, options);
}
else
{
models = s_GET_SORTED_MODELS(collection, query, options);
}
if (options.limit)
{
models = s_PAGE_MODELS(models, options);
}
return models;
}
/**
* Returns the first model that matches the query.
*
* @param {Collection} collection - Target collection
* @param {string} query - Query string
* @returns {*}
*/
static findOne(collection, query)
{
return BackboneQuery.find(collection, query)[0];
}
/**
* Resets the query cache of the target collection.
*
* @param {Collection} collection - Target collection
*/
static resetQueryCache(collection)
{
collection._queryCache = {};
}
/**
* Returns a sorted array of all models from the collection that match the query.
*
* @param {Collection} collection - Target collection
* @param {string} query - Query string
* @returns {Array<*>}
*/
static sortAll(collection, query)
{
return s_SORT_MODELS(collection.models, query);
}
/**
* Runs a query and returns a new collection with the results. Useful for chaining.
*
* @param {Collection} collection - Target collection
* @param {string} query - Query string
* @param {Object} queryOptions - Optional parameters for query.
* @param {Object} options - Optional parameters (used to construct the new collection).
* @returns {Collection}
*/
static whereBy(collection, query, queryOptions = {}, options = {})
{
return new collection.constructor(BackboneQuery.find(collection, query, queryOptions), options);
}
}
// Private / internal methods ---------------------------------------------------------------------------------------
const __slice = [].slice;
const __hasProp = {}.hasOwnProperty;
const __indexOf = [].indexOf || function(item)
{
for (let i = 0, l = this.length; i < l; i++)
{
if (i in this && this[i] === item) { return i; }
}
return -1;
};
/**
* Detects if any value in the array matches a test.
*
* @param {Array<*>} array - An array to detect.
* @param {function} test - A test function.
* @returns {boolean}
*/
const s_DETECT = function(array, test)
{
let _i, _len, val;
for (_i = 0, _len = array.length; _i < _len; _i++)
{
val = array[_i];
if (test(val))
{
return true;
}
}
return false;
};
/**
* Filters an array only adding results that `test` passes.
*
* @param {Array<*>} array - An array to filter.
* @param {function} test - A test function.
* @returns {Array<*>}
*/
const s_FILTER = function(array, test)
{
const _results = [];
let _i, _len, val;
for (_i = 0, _len = array.length; _i < _len; _i++)
{
val = array[_i];
if (test(val))
{
_results.push(val);
}
}
return _results;
};
/**
* Gets the query cache from a collection.
*
* @param {Collection} collection - Target collection
* @param {string} query - A query
* @param {Object} options - Optional parameters
* @returns {*}
*/
const s_GET_CACHE = function(collection, query, options)
{
let _ref, cache, models, queryString;
queryString = JSON.stringify(query);
cache = (_ref = collection._queryCache) !== null ? _ref : collection._queryCache = {};
models = cache[queryString];
if (!models)
{
models = s_GET_SORTED_MODELS(collection, query, options);
cache[queryString] = models;
}
return models;
};
/**
* Runs a query then sorts the models.
*
* @param {Collection} collection - Target collection
* @param {string} query - A query
* @param {Object} options - Optional parameters
* @returns {*}
*/
const s_GET_SORTED_MODELS = function(collection, query, options)
{
let models;
models = s_RUN_QUERY(collection.models, query);
if (options.sortBy)
{
models = s_SORT_MODELS(models, options);
}
return models;
};
/**
* Tests an item and returns a string representation of the type or `false` if no type matched.
*
* @param {*} item - Item to test.
* @returns {string|boolean}
*/
const s_GET_TYPE = function(item)
{
if (_.isRegExp(item))
{
return '$regex';
}
if (_.isDate(item))
{
return '$date';
}
if (_.isObject(item) && !_.isArray(item))
{
return 'object';
}
if (_.isArray(item))
{
return 'array';
}
if (_.isString(item))
{
return 'string';
}
if (_.isNumber(item))
{
return 'number';
}
if (_.isBoolean(item))
{
return 'boolean';
}
if (_.isFunction(item))
{
return 'function';
}
return false;
};
/**
*
* @param {Array<Model>} models -
* @param {Array<*>} query - An array of sub-queries.
* @param {boolean} andOr -
* @param {function} filterFunction -
* @param {string} itemType -
* @returns {*}
*/
const s_ITERATOR = function(models, query, andOr, filterFunction, itemType)
{
if (itemType === null)
{
itemType = false;
}
return filterFunction(models, (model) =>
{
let _i, _len, attr, q, test;
for (_i = 0, _len = query.length; _i < _len; _i++)
{
q = query[_i];
attr = (function()
{
switch (itemType)
{
case 'elemMatch':
return model[q.key];
case 'computed':
return model[q.key]();
default:
return model.get(q.key);
}
})();
test = s_TEST_MODEL_ATTRIBUTE(q.type, attr);
if (test)
{
test = s_PERFORM_QUERY(q.type, q.value, attr, model, q.key);
}
if (andOr === test)
{
return andOr;
}
}
return !andOr;
});
};
/**
* @returns {{}|*}
*/
const s_MAKE_OBJ = function()
{
let args, current, key, o, val;
args = 1 <= arguments.length ? __slice.call(arguments, 0) : [];
o = {};
current = o;
while (args.length)
{
key = args.shift();
val = (args.length === 1 ? args.shift() : {});
current = current[key] = val;
}
return o;
};
/**
* Pages models
*
* @param {Array<Model>} models - Array of models to page.
* @param {Object} options - Optional parameters
* @returns {*}
*/
const s_PAGE_MODELS = function(models, options)
{
let end, sliced_models, start, total_pages;
if (options.offset)
{
start = options.offset;
}
else if (options.page)
{
start = (options.page - 1) * options.limit;
}
else
{
start = 0;
}
end = start + options.limit;
sliced_models = models.slice(start, end);
if (options.pager && _.isFunction(options.pager))
{
total_pages = Math.ceil(models.length / options.limit);
options.pager(total_pages, sliced_models);
}
return sliced_models;
};
/**
* Function to parse raw queries
*
* Allows queries of the following forms:
* query
* name: "test"
* id: $gte: 10
*
* query [
* {name:"test"}
* {id:$gte:10}
* ]
*
* @param {*} rawQuery - raw query
* @return {array} parsed query
*/
const s_PARSE_SUB_QUERY = function(rawQuery)
{
let _i, _len, _results, key, o, paramType, q, query, queryArray, queryParam, type, val, value;
if (_.isArray(rawQuery))
{
queryArray = rawQuery;
}
else
{
queryArray = (function()
{
let _results;
_results = [];
for (key in rawQuery)
{
if (!__hasProp.call(rawQuery, key)) { continue; }
val = rawQuery[key];
_results.push(s_MAKE_OBJ(key, val));
}
return _results;
})();
}
_results = [];
for (_i = 0, _len = queryArray.length; _i < _len; _i++)
{
query = queryArray[_i];
for (key in query)
{
if (!__hasProp.call(query, key)) { continue; }
queryParam = query[key];
o = { key };
paramType = s_GET_TYPE(queryParam);
switch (paramType)
{
case '$regex':
case '$date':
o.type = paramType;
o.value = queryParam;
break;
case 'object':
if (key === '$and' || key === '$or' || key === '$nor' || key === '$not')
{
o.value = s_PARSE_SUB_QUERY(queryParam);
o.type = key;
o.key = null;
}
else
{
for (type in queryParam)
{
value = queryParam[type];
if (s_TEST_QUERY_VALUE(type, value))
{
o.type = type;
switch (type)
{
case '$elemMatch':
case '$relationMatch':
o.value = s_PARSE_QUERY(value);
break;
case '$computed':
q = s_MAKE_OBJ(key, value);
o.value = s_PARSE_SUB_QUERY(q);
break;
default:
o.value = value;
}
}
}
}
break;
default:
o.type = '$equal';
o.value = queryParam;
}
if ((o.type === '$equal') && (paramType === 'object' || paramType === 'array'))
{
o.type = '$oEqual';
}
}
_results.push(o);
}
return _results;
};
/**
* Parses query string.
*
* @param {string} query - A query
* @returns {*[]}
*/
const s_PARSE_QUERY = function(query)
{
let compoundKeys, compoundQuery, key, queryKeys, type, val;
queryKeys = _(query).keys();
compoundKeys = ["$and", "$not", "$or", "$nor"];
compoundQuery = _.intersection(compoundKeys, queryKeys);
if (compoundQuery.length === 0)
{
return [
{
type: "$and",
parsedQuery: s_PARSE_SUB_QUERY(query)
}
];
}
else
{
if (compoundQuery.length !== queryKeys.length)
{
if (__indexOf.call(compoundQuery, "$and") < 0)
{
query.$and = {};
compoundQuery.unshift("$and");
}
for (key in query)
{
if (!__hasProp.call(query, key)) { continue; }
val = query[key];
if (!(__indexOf.call(compoundKeys, key) < 0)) { continue; }
query.$and[key] = val;
delete query[key];
}
}
return (function()
{
let _i, _len, _results;
_results = [];
for (_i = 0, _len = compoundQuery.length; _i < _len; _i++)
{
type = compoundQuery[_i];
_results.push({
type,
parsedQuery: s_PARSE_SUB_QUERY(query[type])
});
}
return _results;
})();
}
};
/**
* Performs a query
*
* @param {string} type -
* @param {*} value -
* @param {*} attr -
* @param {*} model -
* @returns {*}
*/
const s_PERFORM_QUERY = function(type, value, attr, model)
{
switch (type)
{
case '$equal':
if (_(attr).isArray())
{
return __indexOf.call(attr, value) >= 0;
}
else
{
return attr === value;
}
break;
case '$oEqual':
return _(attr).isEqual(value);
case '$contains':
return __indexOf.call(attr, value) >= 0;
case '$ne':
return attr !== value;
case '$lt':
return attr < value;
case '$gt':
return attr > value;
case '$lte':
return attr <= value;
case '$gte':
return attr >= value;
case '$between':
return (value[0] < attr && attr < value[1]);
case '$in':
return __indexOf.call(value, attr) >= 0;
case '$nin':
return __indexOf.call(value, attr) < 0;
case '$all':
return _(value).all((item) =>
{
return __indexOf.call(attr, item) >= 0;
});
case '$any':
return _(attr).any((item) =>
{
return __indexOf.call(value, item) >= 0;
});
case '$size':
return attr.length === value;
case '$exists':
case '$has':
return (attr !== null) === value;
case '$like':
return attr.includes(value);
case '$likeI':
return attr.toLowerCase().includes(value.toLowerCase());
case '$regex':
return value.test(attr);
case '$cb':
return value.call(model, attr);
case '$elemMatch':
return (s_RUN_QUERY(attr, value, 'elemMatch')).length > 0;
case '$relationMatch':
return (s_RUN_QUERY(attr.models, value, 'relationMatch')).length > 0;
case '$computed':
return s_ITERATOR([model], value, false, s_DETECT, 'computed');
case '$and':
case '$or':
case '$nor':
case '$not':
return (s_PROCESS_QUERY[type]([model], value)).length === 1;
default:
return false;
}
};
/**
* @type {{$and: Function, $or: Function, $nor: Function, $not: Function}}
*/
const s_PROCESS_QUERY =
{
$and: function(models, query, itemType)
{
return s_ITERATOR(models, query, false, s_FILTER, itemType);
},
$or: function(models, query, itemType)
{
return s_ITERATOR(models, query, true, s_FILTER, itemType);
},
$nor: function(models, query, itemType)
{
return s_ITERATOR(models, query, true, s_REJECT, itemType);
},
$not: function(models, query, itemType)
{
return s_ITERATOR(models, query, false, s_REJECT, itemType);
}
};
/**
* Creates an array of rejected values of an array that doesn't match a test function.
*
* @param {Array<*>} array - An array to reject.
* @param {function} test - A test function.
* @returns {Array<*>}
*/
const s_REJECT = function(array, test)
{
const _results = [];
let _i, _len, val;
for (_i = 0, _len = array.length; _i < _len; _i++)
{
val = array[_i];
if (!test(val))
{
_results.push(val);
}
}
return _results;
};
/**
* Runs a query.
*
* @param {*} items -
* @param {string} query - A query
* @param {*} itemType -
* @returns {*}
*/
const s_RUN_QUERY = function(items, query, itemType)
{
let reduceIterator;
if (!itemType)
{
query = s_PARSE_QUERY(query);
}
reduceIterator = function(memo, queryItem)
{
return s_PROCESS_QUERY[queryItem.type](memo, queryItem.parsedQuery, itemType);
};
return _.reduce(query, reduceIterator, items);
};
/**
* Sorts models.
*
* @param {Array<Model>} models -
* @param {string} query - A query
* @returns {*}
*/
const s_SORT_MODELS = function(models, query)
{
if (_(query.sortBy).isString())
{
const first = _(models).first();
if (_.isUndefined(first) || first === null) { return []; }
const firstValue = first.get(query.sortBy);
if (_.isString(firstValue))
{
models = _(models).sortBy((model) =>
{
return model.get(query.sortBy).toLocaleLowerCase();
});
}
else
{
models = _(models).sortBy((model) =>
{
return model.get(query.sortBy);
});
}
}
else if (_(query.sortBy).isFunction())
{
models = _(models).sortBy(query.sortBy);
}
if (query.order === 'desc')
{
models = models.reverse();
}
else if (query.order === false)
{
models = models.reverse();
}
return models;
};
/**
* Tests a model attribute based on the query type.
*
* @param {string} type - Query type
* @param {*} value - A value
* @returns {*}
*/
const s_TEST_MODEL_ATTRIBUTE = function(type, value)
{
switch (type)
{
case '$like':
case '$likeI':
case '$regex':
return _(value).isString();
case '$contains':
case '$all':
case '$any':
case '$elemMatch':
return _(value).isArray();
case '$size':
return _(value).isArray() || _(value).isString();
case '$in':
case '$nin':
return value !== null;
case '$relationMatch':
return (value !== null) && value.models;
default:
return true;
}
};
/**
* Tests a value based on the query type.
*
* @param {string} type - Query type
* @param {*} value - A value
* @returns {*}
*/
const s_TEST_QUERY_VALUE = function(type, value)
{
switch (type)
{
case '$in':
case '$nin':
case '$all':
case '$any':
return _(value).isArray();
case '$size':
return _(value).isNumber();
case '$regex':
return _(value).isRegExp();
case '$like':
case '$likeI':
return _(value).isString();
case '$between':
return _(value).isArray() && (value.length === 2);
case '$cb':
return _(value).isFunction();
default:
return true;
}
};