diff --git a/backbone-query.coffee b/backbone-query.coffee new file mode 100644 index 0000000..e1ebba3 --- /dev/null +++ b/backbone-query.coffee @@ -0,0 +1,131 @@ +### +Backbone Query - A lightweight query API for Backbone Collections +(c)2012 - Dave Tonge +May be freely distributed according to MIT license. +### +((define) -> define 'backbone-query', (require, exports) -> + _ = require('underscore') + require('underscore-query') + Backbone = require('backbone') + + # Sorts models either be a model attribute or with a callback + sortModels = (models, options) -> + # If the sortBy param is a string then we sort according to the model attribute with that string as a key + if _(options.sortBy).isString() + models = _(models).sortBy (model) -> model.get(options.sortBy) + # If a function is supplied then it is passed directly to the sortBy iterator + else if _(options.sortBy).isFunction() + models = _(models).sortBy(options.sortBy) + + # If there is an order property of "desc" then the results can be reversed + # (sortBy provides result in ascending order by default) + if options.order is "desc" then models = models.reverse() + # The sorted models are returned + models + + # Slices the results set according to the supplied options + pageModels = (models, options) -> + # Expects object in the form: {limit: num, offset: num, page: num, pager:callback} + if options.offset then start = options.offset + else if options.page then start = (options.page - 1) * options.limit + else start = 0 + + end = start + options.limit + + # The results are sliced according to the calculated start and end params + sliced_models = models[start...end] + + if options.pager and _.isFunction(options.pager) + total_pages = Math.ceil (models.length / options.limit) + options.pager total_pages, sliced_models + + sliced_models + + # The default Backbone Collection is extended with our query methods + Backbone.QueryCollection = Backbone.Collection.extend + + # Main Query method + query: (params, options) -> + if params + # If a query is provided, then the query is run immediately + models = _.query @models, params, "get" + if options + # Caching is depreciated + if options.cache then throw new Error "Query cache is depreciated in version 0.3.0. Use live collections." + # Optional sorting performed + if options.sortBy then models = sortModels(models, options) + # Options paging performed + if options.limit then models = pageModels(models, options) + # Return the results + models + + else + # If no query is provided then we return a query builder object + _.query.build @models, "get" + + + # Helper method to return a new collection with the filtered models + whereBy: -> throw new Error "Whereby is depreciated in version 0.3.0, please use live collections or chain" + + # This method assists in creating live collections that remain updated + setFilter: (parent, query) -> + # Need a reference to the parent in case the filter is updated + @_query_parent = parent + + # A checking function is created to test models against + # The function is added to the collection instance so that it can later be updated + if query + @_query = _.query.tester(query, "get") + # Any existing models on the parent are filtered and added to this collection + @set _.query(parent.models, @_query, "get") + + else + # No models to be added by default until filter is set + @_query = -> false + # To allow chaining form + # col.setFilter(parent).add(a,b).not(c,d).set() + builder = _.query().getter("get") + builder.set = => + @_query = builder.tester() + # In case the filter is set later we need to ensure any existing models are updated + @set _.query(parent.models, @_query, "get") + + # Listeners are added to the parent collection + @listenTo parent, + # Any model added to the parent, will be added to this collection if it passes the test + add: (model) -> if @._query(model) then @add(model) + # Any model removed from the parent will be removed from this collection + remove: @remove + # Any model that is changed on the parent will be re-tested + change: (model) -> + if @_query(model) then @add(model) else @remove(model) + + + updateFilter: (query) -> + throw new Error "setFiler must be called before updateFilter" unless @_query + if query + @_query = _.query.tester(query, "get") + @set _.query(@_parent.models, @_query, "get") + else + # To allow the form col.updateFilter().and(a,v).set() + builder = _.query().getter("get") + builder.set = => + @_query = builder.tester() + @set _.query(@_parent.models, @_query, "get") + + + # Helper method to return the first filtered model + findOne: (query) -> _.findOne @models, query, "get" + + resetQueryCache: -> throw new Error "Query cache is depreciated in version 0.3.0" + + # On the server the new Query Collection is added to exports + exports.QueryCollection = Backbone.QueryCollection +).call this, if typeof define == 'function' and define.amd then define else (id, factory) -> + unless typeof exports is 'undefined' + factory ((id) -> require id), exports + else + # Load Underscore and backbone. No need to export QueryCollection in an module-less environment + factory ((id) -> this[if id == 'underscore' then '_' else 'Backbone']), {} +return + diff --git a/lib/underscore-query.js b/lib/underscore-query.js index 97dcbe3..5739767 100644 --- a/lib/underscore-query.js +++ b/lib/underscore-query.js @@ -9,9 +9,9 @@ This is small library that provides a query api for JavaScript arrays similar to The aim of the project is to provide a simple, well tested, way of filtering data in JavaScript. */ -var buildQuery, iterator, key, makeTest, parseQuery, parseSubQuery, performQuery, runQuery, testModelAttribute, testQueryValue, utils, _, _i, _len, _ref, - __hasProp = {}.hasOwnProperty, - __indexOf = [].indexOf || function(item) { for (var i = 0, l = this.length; i < l; i++) { if (i in this && this[i] === item) return i; } return -1; }; +var buildQuery, iterator, key, makeTest, parseParamType, parseQuery, parseSubQuery, performQuery, runQuery, testModelAttribute, testQueryValue, utils, _, _i, _len, _ref, + __indexOf = [].indexOf || function(item) { for (var i = 0, l = this.length; i < l; i++) { if (i in this && this[i] === item) return i; } return -1; }, + __hasProp = {}.hasOwnProperty; _ = require('underscore'); @@ -64,8 +64,74 @@ utils.compoundKeys = ["$and", "$not", "$or", "$nor"]; utils.seperator = "."; +utils.$computedGetter = function(model, key) { + return typeof model[key] === "function" ? model[key]() : void 0; +}; + +parseParamType = function(query) { + var o, paramType, queryParam, type, value; + + key = utils.keys(query)[0]; + queryParam = query[key]; + o = { + key: key + }; + if (key.indexOf(utils.seperator) !== -1) { + o.getter = utils.getNested(key.split(utils.seperator)); + } + paramType = utils.getType(queryParam); + switch (paramType) { + case "RegExp": + case "Date": + o.type = "$" + (paramType.toLowerCase()); + o.value = queryParam; + break; + case "Object": + if (__indexOf.call(utils.compoundKeys, key) >= 0) { + o.type = key; + o.value = parseSubQuery(queryParam); + o.key = null; + } else { + for (type in queryParam) { + value = queryParam[type]; + if (testQueryValue(type, value)) { + o.type = type; + switch (type) { + case "$elemMatch": + o.value = parseQuery(value); + break; + case "$endsWith": + o.value = utils.reverseString(value); + break; + case "$likeI": + case "$startsWith": + o.value = value.toLowerCase(); + break; + case "$computed": + o = parseParamType(utils.makeObj(key, value)); + o.getter = utils.$computedGetter; + break; + default: + o.value = value; + } + } else { + throw new Error("Query value doesn't match query type: " + type + ": " + value); + } + } + } + break; + default: + o.type = "$equal"; + o.value = queryParam; + } + if ((o.type === "$equal") && (paramType === "Object" || paramType === "Array")) { + o.type = "$deepEqual"; + } + return o; +}; + parseSubQuery = function(rawQuery) { - var o, paramType, query, queryArray, queryParam, type, val, value, _j, _len1, _results; + var query, queryArray, val, _j, _len1, _results; if (utils.isArray(rawQuery)) { queryArray = rawQuery; @@ -85,61 +151,7 @@ parseSubQuery = function(rawQuery) { _results = []; for (_j = 0, _len1 = queryArray.length; _j < _len1; _j++) { query = queryArray[_j]; - for (key in query) { - if (!__hasProp.call(query, key)) continue; - queryParam = query[key]; - o = { - key: key - }; - if (key.indexOf(utils.seperator) !== -1) { - o.getter = utils.getNested(key.split(utils.seperator)); - } - paramType = utils.getType(queryParam); - switch (paramType) { - case "RegExp": - case "Date": - o.type = "$" + (paramType.toLowerCase()); - o.value = queryParam; - break; - case "Object": - if (__indexOf.call(utils.compoundKeys, key) >= 0) { - o.type = key; - o.value = parseSubQuery(queryParam); - o.key = null; - } else { - for (type in queryParam) { - value = queryParam[type]; - if (testQueryValue(type, value)) { - o.type = type; - switch (type) { - case "$elemMatch": - o.value = parseQuery(value); - break; - case "$endsWith": - o.value = utils.reverseString(value); - break; - case "$likeI": - case "$startsWith": - o.value = value.toLowerCase(); - break; - default: - o.value = value; - } - } else { - throw new Error("Query value doesn't match query type: " + type + ": " + value); - } - } - } - break; - default: - o.type = "$equal"; - o.value = queryParam; - } - if ((o.type === "$equal") && (paramType === "Object" || paramType === "Array")) { - o.type = "$deepEqual"; - } - } - _results.push(o); + _results.push(parseParamType(query)); } return _results; }; @@ -198,7 +210,7 @@ testModelAttribute = function(queryType, value) { } }; -performQuery = function(type, value, attr, model) { +performQuery = function(type, value, attr, model, getter) { switch (type) { case "$equal": if (utils.isArray(attr)) { @@ -265,7 +277,7 @@ performQuery = function(type, value, attr, model) { case "$or": case "$nor": case "$not": - return iterator([model], value, type).length === 1; + return iterator([model], value, type, getter).length === 1; default: return false; } @@ -282,7 +294,7 @@ iterator = function(models, query, type, getter) { for (_j = 0, _len1 = query.length; _j < _len1; _j++) { q = query[_j]; if (q.getter) { - attr = q.getter(model); + attr = q.getter(model, q.key); } else if (getter) { attr = getter(model, q.key); } else { @@ -290,7 +302,7 @@ iterator = function(models, query, type, getter) { } test = testModelAttribute(q.type, attr); if (test) { - test = performQuery(q.type, q.value, attr, model); + test = performQuery(q.type, q.value, attr, model, getter); } if (andOr === test) { return andOr; diff --git a/lib/underscore-query.min.js b/lib/underscore-query.min.js index 38ed09b..84fdb26 100644 --- a/lib/underscore-query.min.js +++ b/lib/underscore-query.min.js @@ -1 +1 @@ -(function(e){var r,t,n,a,u,s,c,i,l,o,$,f,y,h,p,g={}.hasOwnProperty,d=[].indexOf||function(e){for(var r=0,t=this.length;t>r;r++)if(r in this&&this[r]===e)return r;return-1};for(f=e("underscore"),$={},p=["every","some","filter","reject","reduce","intersection","isEqual","keys","isArray","result"],y=0,h=p.length;h>y;y++)n=p[y],$[n]=f[n];$.getType=function(e){var r;return r=Object.prototype.toString.call(e).substr(8),r.substr(0,r.length-1)},$.makeObj=function(e,r){var t;return(t={})[e]=r,t},$.getNested=function(e){return function(r){var t,a,u;for(t=r,a=0,u=e.length;u>a;a++)n=e[a],t&&(t=t[n]);return t}},$.reverseString=function(e){return e.toLowerCase().split("").reverse().join("")},$.compoundKeys=["$and","$not","$or","$nor"],$.seperator=".",s=function(e){var r,t,a,c,i,l,f,y,h,p,v;for(c=$.isArray(e)?e:function(){var r;r=[];for(n in e)g.call(e,n)&&(f=e[n],r.push($.makeObj(n,f)));return r}(),v=[],h=0,p=c.length;p>h;h++){a=c[h];for(n in a)if(g.call(a,n)){switch(i=a[n],r={key:n},-1!==n.indexOf($.seperator)&&(r.getter=$.getNested(n.split($.seperator))),t=$.getType(i)){case"RegExp":case"Date":r.type="$"+t.toLowerCase(),r.value=i;break;case"Object":if(d.call($.compoundKeys,n)>=0)r.type=n,r.value=s(i),r.key=null;else for(l in i){if(y=i[l],!o(l,y))throw Error("Query value doesn't match query type: "+l+": "+y);switch(r.type=l,l){case"$elemMatch":r.value=u(y);break;case"$endsWith":r.value=$.reverseString(y);break;case"$likeI":case"$startsWith":r.value=y.toLowerCase();break;default:r.value=y}}break;default:r.type="$equal",r.value=i}"$equal"!==r.type||"Object"!==t&&"Array"!==t||(r.type="$deepEqual")}v.push(r)}return v},o=function(e,r){var t;switch(t=$.getType(r),e){case"$in":case"$nin":case"$all":case"$any":return"Array"===t;case"$size":return"Number"===t;case"$regex":case"$regexp":return"RegExp"===t;case"$like":case"$likeI":return"String"===t;case"$between":case"$mod":return"Array"===t&&2===r.length;case"$cb":return"Function"===t;default:return!0}},l=function(e,r){var t;switch(t=$.getType(r),e){case"$like":case"$likeI":case"$regex":case"$startsWith":case"$endsWith":return"String"===t;case"$contains":case"$all":case"$any":case"$elemMatch":return"Array"===t;case"$size":return"String"===t||"Array"===t;case"$in":case"$nin":return null!=r;default:return!0}},c=function(e,r,n,a){switch(e){case"$equal":return $.isArray(n)?d.call(n,r)>=0:n===r;case"$deepEqual":return $.isEqual(n,r);case"$contains":return d.call(n,r)>=0;case"$ne":return n!==r;case"$lt":return r>n;case"$gt":return n>r;case"$lte":return r>=n;case"$gte":return n>=r;case"$between":return n>r[0]&&r[1]>n;case"$betweene":return n>=r[0]&&r[1]>=n;case"$in":return d.call(r,n)>=0;case"$nin":return 0>d.call(r,n);case"$all":return $.every(r,function(e){return d.call(n,e)>=0});case"$any":return $.some(n,function(e){return d.call(r,e)>=0});case"$size":return n.length===r;case"$exists":case"$has":return null!=n===r;case"$like":return-1!==n.indexOf(r);case"$likeI":return-1!==n.toLowerCase().indexOf(r);case"$startsWith":return 0===n.toLowerCase().indexOf(r);case"$endsWith":return 0===$.reverseString(n).indexOf(r);case"$type":return typeof n===r;case"$regex":case"$regexp":return r.test(n);case"$cb":return r.call(a,n);case"$mod":return n%r[0]===r[1];case"$elemMatch":return i(n,r,null,!0).length>0;case"$and":case"$or":case"$nor":case"$not":return 1===t([a],r,e).length;default:return!1}},t=function(e,r,t,n){var a,u;return u="$and"===t||"$or"===t?$.filter:$.reject,a="$or"===t||"$nor"===t,u(e,function(e){var t,u,s,i,o;for(i=0,o=r.length;o>i;i++)if(u=r[i],t=u.getter?u.getter(e):n?n(e,u.key):e[u.key],s=l(u.type,t),s&&(s=c(u.type,u.value,t,e)),a===s)return a;return!a})},u=function(e){var r,t,a,u;if(t=$.keys(e),r=$.intersection($.compoundKeys,t),0===r.length)return[{type:"$and",parsedQuery:s(e)}];if(r.length!==t.length){0>d.call(r,"$and")&&(e.$and={},r.unshift("$and"));for(n in e)g.call(e,n)&&(u=e[n],0>d.call($.compoundKeys,n)&&(e.$and[n]=u,delete e[n]))}return function(){var t,n,u;for(u=[],t=0,n=r.length;n>t;t++)a=r[t],u.push({type:a,parsedQuery:s(e[a])});return u}()},r=function(e,r,t){var a,u,s,c,l;for(a={items:e,getter:r,isParsed:t,theQuery:{}},a.all=a.find=a.query=a.run=function(e,r,t){return null==e&&(e=a.items),null==r&&(r=a.getter),null==t&&(t=a.isParsed),i(e,a.theQuery,r,t)},a.first=function(){var e;return null!=(e=a.all.apply(this,arguments))?e[0]:void 0},a.chain=function(){return f.chain(a.all.apply(this,arguments))},l=$.compoundKeys,u=function(e){var r;return r=e.substr(1),a[r]=function(r,t){var n,u;return t&&(r=$.makeObj(r,t)),null==(u=(n=a.theQuery)[e])&&(n[e]=[]),a.theQuery[e].push(r),a}},s=0,c=l.length;c>s;s++)n=l[s],u(n);return a},a=function(e,r){var t;return t=u(e),function(e){return $.isArray(e)||(e=[e]),i(e,t,r,!0).length===e.length}},i=function(e,n,a,s){var c,i;return 2>arguments.length?r.apply(this,arguments):(s||(n=u(n)),"String"===$.getType(a)&&(c=a,a=function(e,r){return e[c](r)}),i=function(e,r){return t(e,r.parsedQuery,r.type,a)},$.reduce(n,i,e))},i.build=r,i.parse=u,i.tester=a,f.mixin({query:i})}).call(this,"undefined"!=typeof exports?require:function(e){return this["underscore"===e?"_":e]}); \ No newline at end of file +(function(e){var r,t,n,a,u,s,c,i,l,o,$,f,y,p,h,d,g=[].indexOf||function(e){for(var r=0,t=this.length;t>r;r++)if(r in this&&this[r]===e)return r;return-1},v={}.hasOwnProperty;for(y=e("underscore"),f={},d=["every","some","filter","reject","reduce","intersection","isEqual","keys","isArray","result"],p=0,h=d.length;h>p;p++)n=d[p],f[n]=y[n];f.getType=function(e){var r;return r=Object.prototype.toString.call(e).substr(8),r.substr(0,r.length-1)},f.makeObj=function(e,r){var t;return(t={})[e]=r,t},f.getNested=function(e){return function(r){var t,a,u;for(t=r,a=0,u=e.length;u>a;a++)n=e[a],t&&(t=t[n]);return t}},f.reverseString=function(e){return e.toLowerCase().split("").reverse().join("")},f.compoundKeys=["$and","$not","$or","$nor"],f.seperator=".",f.$computedGetter=function(e,r){return"function"==typeof e[r]?e[r]():void 0},u=function(e){var r,t,a,i,l;switch(n=f.keys(e)[0],a=e[n],r={key:n},-1!==n.indexOf(f.seperator)&&(r.getter=f.getNested(n.split(f.seperator))),t=f.getType(a)){case"RegExp":case"Date":r.type="$"+t.toLowerCase(),r.value=a;break;case"Object":if(g.call(f.compoundKeys,n)>=0)r.type=n,r.value=c(a),r.key=null;else for(i in a){if(l=a[i],!$(i,l))throw Error("Query value doesn't match query type: "+i+": "+l);switch(r.type=i,i){case"$elemMatch":r.value=s(l);break;case"$endsWith":r.value=f.reverseString(l);break;case"$likeI":case"$startsWith":r.value=l.toLowerCase();break;case"$computed":r=u(f.makeObj(n,l)),r.getter=f.$computedGetter;break;default:r.value=l}}break;default:r.type="$equal",r.value=a}return"$equal"!==r.type||"Object"!==t&&"Array"!==t||(r.type="$deepEqual"),r},c=function(e){var r,t,a,s,c,i;for(t=f.isArray(e)?e:function(){var r;r=[];for(n in e)v.call(e,n)&&(a=e[n],r.push(f.makeObj(n,a)));return r}(),i=[],s=0,c=t.length;c>s;s++)r=t[s],i.push(u(r));return i},$=function(e,r){var t;switch(t=f.getType(r),e){case"$in":case"$nin":case"$all":case"$any":return"Array"===t;case"$size":return"Number"===t;case"$regex":case"$regexp":return"RegExp"===t;case"$like":case"$likeI":return"String"===t;case"$between":case"$mod":return"Array"===t&&2===r.length;case"$cb":return"Function"===t;default:return!0}},o=function(e,r){var t;switch(t=f.getType(r),e){case"$like":case"$likeI":case"$regex":case"$startsWith":case"$endsWith":return"String"===t;case"$contains":case"$all":case"$any":case"$elemMatch":return"Array"===t;case"$size":return"String"===t||"Array"===t;case"$in":case"$nin":return null!=r;default:return!0}},i=function(e,r,n,a,u){switch(e){case"$equal":return f.isArray(n)?g.call(n,r)>=0:n===r;case"$deepEqual":return f.isEqual(n,r);case"$contains":return g.call(n,r)>=0;case"$ne":return n!==r;case"$lt":return r>n;case"$gt":return n>r;case"$lte":return r>=n;case"$gte":return n>=r;case"$between":return n>r[0]&&r[1]>n;case"$betweene":return n>=r[0]&&r[1]>=n;case"$in":return g.call(r,n)>=0;case"$nin":return 0>g.call(r,n);case"$all":return f.every(r,function(e){return g.call(n,e)>=0});case"$any":return f.some(n,function(e){return g.call(r,e)>=0});case"$size":return n.length===r;case"$exists":case"$has":return null!=n===r;case"$like":return-1!==n.indexOf(r);case"$likeI":return-1!==n.toLowerCase().indexOf(r);case"$startsWith":return 0===n.toLowerCase().indexOf(r);case"$endsWith":return 0===f.reverseString(n).indexOf(r);case"$type":return typeof n===r;case"$regex":case"$regexp":return r.test(n);case"$cb":return r.call(a,n);case"$mod":return n%r[0]===r[1];case"$elemMatch":return l(n,r,null,!0).length>0;case"$and":case"$or":case"$nor":case"$not":return 1===t([a],r,e,u).length;default:return!1}},t=function(e,r,t,n){var a,u;return u="$and"===t||"$or"===t?f.filter:f.reject,a="$or"===t||"$nor"===t,u(e,function(e){var t,u,s,c,l;for(c=0,l=r.length;l>c;c++)if(u=r[c],t=u.getter?u.getter(e,u.key):n?n(e,u.key):e[u.key],s=o(u.type,t),s&&(s=i(u.type,u.value,t,e,n)),a===s)return a;return!a})},s=function(e){var r,t,a,u;if(t=f.keys(e),r=f.intersection(f.compoundKeys,t),0===r.length)return[{type:"$and",parsedQuery:c(e)}];if(r.length!==t.length){0>g.call(r,"$and")&&(e.$and={},r.unshift("$and"));for(n in e)v.call(e,n)&&(u=e[n],0>g.call(f.compoundKeys,n)&&(e.$and[n]=u,delete e[n]))}return function(){var t,n,u;for(u=[],t=0,n=r.length;n>t;t++)a=r[t],u.push({type:a,parsedQuery:c(e[a])});return u}()},r=function(e,r,t){var a,u,s,c,i;for(a={items:e,getter:r,isParsed:t,theQuery:{}},a.all=a.find=a.query=a.run=function(e,r,t){return null==e&&(e=a.items),null==r&&(r=a.getter),null==t&&(t=a.isParsed),l(e,a.theQuery,r,t)},a.first=function(){var e;return null!=(e=a.all.apply(this,arguments))?e[0]:void 0},a.chain=function(){return y.chain(a.all.apply(this,arguments))},i=f.compoundKeys,u=function(e){var r;return r=e.substr(1),a[r]=function(r,t){var n,u;return t&&(r=f.makeObj(r,t)),null==(u=(n=a.theQuery)[e])&&(n[e]=[]),a.theQuery[e].push(r),a}},s=0,c=i.length;c>s;s++)n=i[s],u(n);return a},a=function(e,r){var t;return t=s(e),function(e){return f.isArray(e)||(e=[e]),l(e,t,r,!0).length===e.length}},l=function(e,n,a,u){var c,i;return 2>arguments.length?r.apply(this,arguments):(u||(n=s(n)),"String"===f.getType(a)&&(c=a,a=function(e,r){return e[c](r)}),i=function(e,r){return t(e,r.parsedQuery,r.type,a)},f.reduce(n,i,e))},l.build=r,l.parse=s,l.tester=a,y.mixin({query:l})}).call(this,"undefined"!=typeof exports?require:function(e){return this["underscore"===e?"_":e]}); \ No newline at end of file diff --git a/package.json b/package.json index 1cd6361..e11d78e 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "underscore-query", "description": "Lightweight Query API mixin for Underscore", - "version": "0.1.3", + "version": "0.2.0", "author": "Dave Tonge (https://github.com/davidgtonge)", "tags": [ "underscore", diff --git a/src/underscore-query-functional.coffee b/src/underscore-query-functional.coffee new file mode 100644 index 0000000..fe407c4 --- /dev/null +++ b/src/underscore-query-functional.coffee @@ -0,0 +1,297 @@ +### +Underscore Query - A lightweight query API for JavaScript collections +(c)2012 - Dave Tonge +May be freely distributed according to MIT license. + +This is small library that provides a query api for JavaScript arrays similar to *mongo db*. +The aim of the project is to provide a simple, well tested, way of filtering data in JavaScript. +### + +# *underscore* is the only dependency for this project. +_ = require('underscore') + +### UTILS ### +# We assign local references to the underscore methods used. +# This way we can easily remove underscore as a dependecy for other versions of the library +utils = {} +for key in ["every", "some", "filter", "detect", "reject", "reduce", "intersection", "isEqual", "keys", "isArray", "result"] + utils[key] = _[key] + +# Returns a string denoting the type of object +utils.getType = (obj) -> + type = Object.prototype.toString.call(obj).substr(8) + type.substr(0, (type.length - 1)) + +# Utility Function to turn 2 values into an object +utils.makeObj = (key, val)-> + (o = {})[key] = val + o + +# Reverses a string +utils.reverseString = (str) -> str.toLowerCase().split("").reverse().join("") + +# An array of the compound modifers that can be used in queries +utils.compoundKeys = ["$and", "$not", "$or", "$nor"] + +# Returns a getter function that works with dot notation and named functions +utils.makeGetter = (keys) -> + keys = keys.split(".") + (obj) -> + out = obj + for key in keys + if out then out = utils.result(out,key) + out + +parseParamType = (query) -> + key = utils.keys(query)[0] + queryParam = query[key] + o = {key} + + # If the key uses dot notation, then create a getter function + if key.indexOf(".") isnt -1 + o.getter = utils.makeGetter(key) + + paramType = utils.getType(queryParam) + switch paramType + # Test for Regexs and Dates as they can be supplied without an operator + when "RegExp", "Date" + o.type = "$#{paramType.toLowerCase()}" + o.value = queryParam + + when "Object" + # If the key is one of the compound keys, then parse the param as a raw query + if key in utils.compoundKeys + o.type = key + o.value = parseSubQuery queryParam + o.key = null + + # Otherwise extract the key and value + else + for type, value of queryParam + # Before adding the query, its value is checked to make sure it is the right type + if testQueryValue type, value + o.type = type + switch type + when "$elemMatch" then o.value = single(parseQuery(value)) + when "$endsWith" then o.value = utils.reverseString(value) + when "$likeI", "$startsWith" then o.value = value.toLowerCase() + when "$computed" + o = parseParamType(utils.makeObj(key, value)) + o.getter = utils.makeGetter(key) + else o.value = value + else throw new Error("Query value (#{value}) doesn't match query type: (#{type})") + # If the query_param is not an object or a regexp then revert to the default operator: $equal + else + o.type = "$equal" + o.value = queryParam + + # For "$equal" queries with arrays or objects we need to perform a deep equal + if (o.type is "$equal") and (paramType in ["Object","Array"]) + o.type = "$deepEqual" + + # Return the query object + return o + + +# This function parses and normalizes raw queries. +parseSubQuery = (rawQuery) -> + + # Ensure that the query is an array + if utils.isArray(rawQuery) + queryArray = rawQuery + else + queryArray = (utils.makeObj(key, val) for own key, val of rawQuery) + + # Loop through all the different queries + (parseParamType(query) for query in queryArray) + + +# Tests query value, to ensure that it is of the correct type +testQueryValue = (queryType, value) -> + valueType = utils.getType(value) + switch queryType + when "$in","$nin","$all", "$any" then valueType is "Array" + when "$size" then valueType is "Number" + when "$regex", "$regexp" then valueType is "RegExp" + when "$like", "$likeI" then valueType is "String" + when "$between", "$mod" then (valueType is "Array") and (value.length is 2) + when "$cb" then valueType is "Function" + else true + +# Test each attribute that is being tested to ensure that is of the correct type +testModelAttribute = (queryType, value) -> + valueType = utils.getType(value) + switch queryType + when "$like", "$likeI", "$regex", "$startsWith", "$endsWith" then valueType is "String" + when "$contains", "$all", "$any", "$elemMatch" then valueType is "Array" + when "$size" then valueType in ["String","Array"] + when "$in", "$nin" then value? + else true + +# Perform the actual query logic for each query and each model/attribute +performQuery = (type, value, attr, model, getter) -> + switch type + when "$equal" + # If the attribute is an array then search for the query value in the array the same as Mongo + if utils.isArray(attr) then (value in attr) else (attr is value) + when "$deepEqual" then utils.isEqual(attr, value) + when "$contains" then value in attr + when "$ne" then attr isnt value + when "$lt" then attr < value + when "$gt" then attr > value + when "$lte" then attr <= value + when "$gte" then attr >= value + when "$between" then value[0] < attr < value[1] + when "$betweene" then value[0] <= attr <= value[1] + when "$in" then attr in value + when "$nin" then attr not in value + when "$all" then utils.every value, (item) -> item in attr + when "$any" then utils.some attr, (item) -> item in value + when "$size" then attr.length is value + when "$exists", "$has" then attr? is value + when "$like" then attr.indexOf(value) isnt -1 + when "$likeI" then attr.toLowerCase().indexOf(value) isnt -1 + when "$startsWith" then attr.toLowerCase().indexOf(value) is 0 + when "$endsWith" then utils.reverseString(attr).indexOf(value) is 0 + when "$type" then typeof attr is value + when "$regex", "$regexp" then value.test attr + when "$cb" then value.call model, attr + when "$mod" then (attr % value[0]) is value[1] + when "$elemMatch" then (runQuery(attr,value, null, true)) + when "$and", "$or", "$nor", "$not" + performQuerySingle(type, value, getter, model) + else false + +# This function should accept an obj like this: +# $and: [queries], $or: [queries] +# should return false if fails +single = (queries, getter) -> + if utils.getType(getter) is "String" + method = getter + getter = (obj, key) -> obj[method](key) + (model) -> + for queryObj in queries + # Early false return if any of the queries fail + return false unless performQuerySingle(queryObj.type, queryObj.parsedQuery, getter, model) + # All queries passes, so return true + true + +performQuerySingle = (type, query, getter, model) -> + passes = 0 + for q in query + if q.getter + attr = q.getter model, q.key + else if getter + attr = getter model, q.key + else + attr = model[q.key] + # Check if the attribute value is the right type (some operators need a string, or an array) + test = testModelAttribute(q.type, attr) + # If the attribute test is true, perform the query + if test then test = performQuery q.type, q.value, attr, model, getter + if test then passes++ + switch type + when "$and" + # Early false return for $and queries when any test fails + return false unless test + when "$not" + # Early false return for $not queries when any test passes + return false if test + when "$or" + # Early true return for $or queries when any test passes + return true if test + when "$nor" + # Early false return for $nor queries when any test passes + return false if test + + # For not queries, check that all tests have failed + if type is "$not" + passes is 0 + # $or queries have failed as no tests have passed + # $and queries have passed as no tests failed + # $nor queries have passes as no tests passed + else + type isnt "$or" + + +# The main function to parse raw queries. +# Queries are split according to the compound type ($and, $or, etc.) before being parsed with parseSubQuery +parseQuery = (query) -> + queryKeys = utils.keys(query) + compoundQuery = utils.intersection utils.compoundKeys, queryKeys + + # If no compound methods are found then use the "and" iterator + if compoundQuery.length is 0 + return [{type:"$and", parsedQuery:parseSubQuery(query)}] + else + # Detect if there is an implicit $and compundQuery operator + if compoundQuery.length isnt queryKeys.length + # Add the and compund query operator (with a sanity check that it doesn't exist) + if "$and" not in compoundQuery + query.$and = {} + compoundQuery.unshift "$and" + for own key, val of query when key not in utils.compoundKeys + query.$and[key] = val + delete query[key] + return (for type in compoundQuery + {type, parsedQuery:parseSubQuery(query[type])}) + + +class QueryBuilder + constructor: (@items, @_getter) -> + @theQuery = {} + + all: (items = @items) -> + runQuery(items, @theQuery, @_getter) + + chain: -> _.chain(@all.apply(this, arguments)) + + tester: -> makeTest(@theQuery, @_getter) + + first: (items = @items) -> + runQuery(items, @theQuery, @_getter, true) + + getter: (@_getter) -> + this + +addToQuery = (type) -> + (params, qVal) -> + if qVal + params = utils.makeObj params, qVal + @theQuery[type] ?= [] + @theQuery[type].push params + this + +for key in utils.compoundKeys + QueryBuilder::[key.substr(1)] = addToQuery(key) + +QueryBuilder::find = QueryBuilder::query = QueryBuilder::run = QueryBuilder::all + +# Build Query function for progamatically building up queries before running them. +buildQuery = (items, getter) -> new QueryBuilder(items, getter) + +# Create a *test* function that checks if the object or objects match the query +makeTest = (query, getter) -> single(parseQuery(query), getter) + +# Find one function that returns first matching result +findOne = (items, query, getter) -> runQuery(items, query, getter, true) + +# The main function to be mxied into underscore that takes a collection and a raw query +runQuery = (items, query, getter, first) -> + if arguments.length < 2 + # If no arguments or only the items are provided, then use the buildQuery interface + return buildQuery.apply this, arguments + query = single(parseQuery(query), getter) unless (utils.getType(query) is "Function") + fn = if first then utils.detect else utils.filter + fn items, query + + +runQuery.build = buildQuery +runQuery.parse = parseQuery +runQuery.findOne = runQuery.first = findOne +runQuery.tester = runQuery.testWith = makeTest +runQuery.getter = runQuery.pluckWith = utils.makeGetter +_.mixin + query:runQuery + q:runQuery +module?.exports = runQuery \ No newline at end of file diff --git a/src/underscore-query.coffee b/src/underscore-query.coffee index 3e4a072..eef4c61 100644 --- a/src/underscore-query.coffee +++ b/src/underscore-query.coffee @@ -135,7 +135,7 @@ testModelAttribute = (queryType, value) -> else true # Perform the actual query logic for each query and each model/attribute -performQuery = (type, value, attr, model) -> +performQuery = (type, value, attr, model, getter) -> switch type when "$equal" # If the attribute is an array then search for the query value in the array the same as Mongo @@ -165,7 +165,7 @@ performQuery = (type, value, attr, model) -> when "$mod" then (attr % value[0]) is value[1] when "$elemMatch" then (runQuery(attr,value, null, true)).length > 0 when "$and", "$or", "$nor", "$not" - iterator([model], value, type).length is 1 + iterator([model], value, type, getter).length is 1 else false @@ -188,7 +188,7 @@ iterator = (models, query, type, getter) -> # Check if the attribute value is the right type (some operators need a string, or an array) test = testModelAttribute(q.type, attr) # If the attribute test is true, perform the query - if test then test = performQuery q.type, q.value, attr, model + if test then test = performQuery q.type, q.value, attr, model, getter # If the query is an "or" query than as soon as a match is found we return "true" # Whereas if the query is an "and" query then we return "false" as soon as a match isn't found. return andOr if andOr is test diff --git a/test/test.coffee b/test/test.coffee index 96063ff..a5d380e 100644 --- a/test/test.coffee +++ b/test/test.coffee @@ -2,7 +2,7 @@ require "coffee-script" assert = require('assert') _ = require "underscore" -require "../src/underscore-query" +require "../src/underscore-query-functional" collection = [ {title:"Home", colors:["red","yellow","blue"], likes:12, featured:true, content: "Dummy content about coffeescript"}