From b48f13cf8876a4f4efaa03049eaf84d92e7c2d5b Mon Sep 17 00:00:00 2001 From: Anthony Truskinger Date: Sat, 6 Sep 2014 01:43:23 +1000 Subject: [PATCH] Completed query builder to spec --- src/baw.configuration.tpl.js | 5 + src/common/functions.js | 6 + src/components/services/queryBuilder.js | 353 ++++++++++++------- src/components/services/queryBuilder.spec.js | 313 +++++++++++++--- src/components/services/unitConverter.js | 2 +- 5 files changed, 507 insertions(+), 172 deletions(-) diff --git a/src/baw.configuration.tpl.js b/src/baw.configuration.tpl.js index e2cbe81c..dfe8ad34 100644 --- a/src/baw.configuration.tpl.js +++ b/src/baw.configuration.tpl.js @@ -204,5 +204,10 @@ angular.module('bawApp.configuration', ['url']) }, baseMessage: "Your current internet browser ({name}, version {version}) is {reason}.
Consider updating or try using Google Chrome.", localStorageKey: "browserSupport.checked" + }, + queryBuilder: { + defaultPage: 0, + defaultPageItems: 10, + defaultSortDirection: "asc" } }); \ No newline at end of file diff --git a/src/common/functions.js b/src/common/functions.js index 23ea50bb..6f131b41 100644 --- a/src/common/functions.js +++ b/src/common/functions.js @@ -30,6 +30,12 @@ Math.randomInt = function getRandomInt(min, max) { return Math.floor(Math.random() * (max - min + 1) + min); }; +Math.isInt = function isInt(value) { + return !isNaN(value) && + parseInt(Number(value)) == value && + !isNaN(parseInt(value, 10)); +}; + // from MDN: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math/round#Example:_Decimal_rounding // Closure (function(){ diff --git a/src/components/services/queryBuilder.js b/src/components/services/queryBuilder.js index 9581b5a0..b5828c32 100644 --- a/src/components/services/queryBuilder.js +++ b/src/components/services/queryBuilder.js @@ -1,165 +1,272 @@ var qb = angular.module("bawApp.services.queryBuilder", ["bawApp.configuration"]); +qb.factory("QueryBuilder", ["conf.constants", function(constants) { + + var validCombinators = { + "and": undefined, + "or": undefined, + "not": function (args) { + if (args.length > 1) { + throw "Not combinator only accepts one argument (either combinator or operator)"; + } + } + }; -function Query(currentFieldKey) { - var currentField = currentFieldKey; + var validOperators = { + "equal": undefined, + "eq": undefined, + "notEqual": undefined, + "notEq": undefined, + "lessThan": undefined, + "lt": undefined, + "lessThanOrEqual": undefined, + "lteq": undefined, + "greaterThan": undefined, + "gt": undefined, + "greaterThanOrEqual": undefined, + "gteq": undefined, + "range": undefined, + "in": function (field, value) { + if (!angular.isArray(value)) { + throw "The in function must be given as an array"; + } + }, + "contains": undefined, + "startsWith": undefined, + "endsWith": undefined + }; - this.filter = {}; + function Query(currentFieldKey, rootQuery) { + var currentField = currentFieldKey; + this.root = rootQuery; - this.combinator = function combinator(type, functions) { - if (!functions){ - return this; + this.filter = {}; + + function newInstance(base, field) { + var that = base; + if (base instanceof RootQuery) { + that = new Query(field || base.getField(), base); + } + return that; } - // create a new level of nesting IFF there is already a root combinator - var that = this; - if((this instanceof RootQuery) && (Object.keys(this.filter).length >= 1)) { - that = new Query(this.getField()); + function deepMergeFilter(base, child) { + Object.keys(child).forEach(function (key) { + if (angular.isObject(base[key])) { + deepMergeFilter(base[key], child[key]); + } + else { + base[key] = child[key]; + } + }); } - that.filter[type] = {}; + this.combinator = function combinator(type, functions) { + if (!functions) { + return this; + } - functions.forEach(function(value, key) { - if (!(value instanceof Query)){ - throw "A combinator only accepts Query objects"; + // create a new level of nesting + var that = newInstance(this); + + that.filter[type] = {}; + functions.forEach(function (value, key) { + if (!(value instanceof Query)) { + throw "A combinator only accepts Query objects"; + } + + deepMergeFilter(that.filter[type], value.filter); + }); + + return that; + }; + + this.operator = function operator(operation, value, field) { + var that = newInstance(this), operatorField = field; + + if (operatorField) { + that.setField(operatorField); + } + else { + operatorField = that.getField(); } - Query.deepMergeFilter(that.filter[type], value.filter); - }); + if (!operatorField) { + throw "A field is not set - using an operator does not make sense"; + } - return this; - }; + that.filter[operatorField] = that.filter[operatorField] || {}; + that.filter[operatorField][operation] = value; + + return that; + }; + + this.field = function field(fieldKey) { + return newInstance(this, fieldKey); + }; + + this.setField = function setField(value) { + currentField = value; + }; + this.getField = function getField() { + return currentField; + }; + + this.end = function returnRoot() { + if (this instanceof Query) { + // making an Query root-level + // can only be done once - check + // operator/combinator + if (Object.keys(this.root.filter).length !== 0) { + throw new Error("A root level Query can only be defined once (do not call .end more than once)."); + } + + this.root.filter = this.filter; + return this.root; + } + else { + throw "A Query object must be passed in to returnRoot"; + } + }; - this.operator = function operator(operation, value, field) { - var that = this, operatorField = field; - if (this instanceof RootQuery) { - that = new Query(this.getField()); - } + this.page = function page(pageArguments) { + if (!angular.isObject(pageArguments)) { + throw new Error("The page function expects an object"); + } - if (operatorField) { - that.field(operatorField); - } - else { - operatorField = that.getField(); - } + if (pageArguments.page === undefined) { + pageArguments.page = constants.queryBuilder.defaultPage; + } + else if (!Math.isInt(pageArguments.page)) { + throw new Error("paging.page must be an integer"); + } - if (!operatorField) { - throw "A field is not set - using an operator does not make sense"; - } + if (pageArguments.items === undefined) { + pageArguments.items = constants.queryBuilder.defaultPageItems; + } + else if (!Math.isInt(pageArguments.items)) { + throw new Error("paging.items must be an integer"); + } - that.filter[operatorField] = that.filter[operatorField] || {}; - that.filter[operatorField][operation] = value; + this.root.paging.items = pageArguments.items; + this.root.paging.page = pageArguments.page; - return that; - }; + return this; + }; - this.field = function field(fieldKey) { - currentField = fieldKey; - return this; - }; + this.project = function projection(projectionArguments) { + if (!angular.isObject(projectionArguments)) { + throw new Error("The project function expects an object"); + } - this.getField = function getField() { - return currentField; - }; -} + if (projectionArguments.exclude !== undefined && projectionArguments.include !== undefined) { + throw new Error("A projection only supports include xor exclude"); + } -Query.deepMergeFilter = function mergeFilter(base, child) { - Object.keys(child).forEach(function (key, index) { - if (angular.isObject(base[key])) { - Query.deepMergeFilter(base[key], child[key]); - } - else { - base[key] = child[key]; - } - }); -}; - -var validCombinators = { - "and": undefined, - "or": undefined, - "not": function(args) { - if (args.length > 1) { - throw "Not combinator only accepts one argument (either combinator or operator)"; - } - }}; - -var validOperators = { - "equal": undefined, - "eq": undefined, - "notEqual": undefined, - "notEq": undefined, - "lessThan": undefined, - "lt": undefined, - "lessThanOrEqual": undefined, - "lteq": undefined, - "greaterThan": undefined, - "gt": undefined, - "greaterThanOrEqual": undefined, - "gteq": undefined, - "range": undefined, - "in": function(field, value) { - if (!angular.isArray(value)) { - throw "The in function must be given as an array"; - } - }, - "contains": undefined, - "startsWith": undefined, - "endsWith": undefined -}; + var temp, key; + if (projectionArguments.include) { + temp = projectionArguments.include; + key = "include"; + } else if (projectionArguments.exclude) { + temp = projectionArguments.exclude; + key = "exclude"; + } + if (angular.isArray(temp) && temp.every(angular.isString)) { + this.root.projection[key] = temp; + } + else { + throw new Error("projection." + key + " must be an array of strings"); + } -Object.keys(validCombinators).forEach(function (combinatorKey) { - Query.prototype[combinatorKey] = function() { - var args = Array.prototype.slice.call(arguments, 0); + return this; + }; - var validator = validCombinators[combinatorKey]; - if (validator) { - validator(arguments); - } + this.sort = function sort(sortArguments) { + if (!angular.isObject(sortArguments)) { + throw new Error("The sort function expects an object"); + } - return this.combinator(combinatorKey, args); - }; -}); + if ((sortArguments.orderBy === undefined) || !angular.isString(sortArguments.orderBy)) { + throw new Error("sorting.orderBy must be provided and must be a string"); + } -Object.keys(validOperators).forEach(function(operatorKey) { - Query.prototype[operatorKey] = function(field, value) { - if (arguments.length == 1) { - value = field; - field = undefined; - } + if (sortArguments.direction === undefined) { + sortArguments.direction = constants.queryBuilder.defaultSortDirection; + } + else if (sortArguments.direction !== "asc" && sortArguments.direction !== "desc") { + throw new Error("sort.direction must be 'asc' or 'desc'"); + } + this.root.sorting.orderBy = sortArguments.orderBy; + this.root.sorting.direction = sortArguments.direction; - var validator = validOperators[operatorKey]; - if (validator) { - validator(field, value); - } + return this; + }; + } - return this.operator(operatorKey, value, field); - }; -}); + Object.keys(validCombinators).forEach(function (combinatorKey) { + Query.prototype[combinatorKey] = function () { + var args = Array.prototype.slice.call(arguments, 0); + var validator = validCombinators[combinatorKey]; + if (validator) { + validator(arguments); + } -function RootQuery() { - Query.call(this); + return this.combinator(combinatorKey, args); + }; + }); - this.sorting = {}; - this.paging = {}; + Object.keys(validOperators).forEach(function (operatorKey) { + Query.prototype[operatorKey] = function (field, value) { + if (arguments.length == 1) { + value = field; + field = undefined; + } - this.toJSON = function toJSON(spaces) { - return JSON.stringify({ - filter: this.filter - }, null, spaces); - }; -} -RootQuery.prototype = Object.create(Query.prototype); + var validator = validOperators[operatorKey]; + if (validator) { + validator(field, value); + } + + return this.operator(operatorKey, value, field); + }; + }); + + function RootQuery() { + Query.call(this, undefined, this); + + this.paging = {}; + this.projection = {}; + this.sorting = {}; + + + this.compose = function wrapAsRoot(query) { + return this.end.call(query); + }; + + this.toJSON = function toJSON(spaces) { + var compiledQuery = {}, + that = this; + ["filter", "paging", "projection", "sorting"].forEach(function (value) { + if (Object.keys(that[value]).length > 0) { + compiledQuery[value] = that[value]; + } + }); + return JSON.stringify(compiledQuery, null, spaces); + }; + } + RootQuery.prototype = Object.create(Query.prototype); -qb.factory("QueryBuilder", [function() { return { - create: function() { + Query: Query, + RootQuery: RootQuery, + create: function () { return new RootQuery(); } }; diff --git a/src/components/services/queryBuilder.spec.js b/src/components/services/queryBuilder.spec.js index cb379932..28bdbecb 100644 --- a/src/components/services/queryBuilder.spec.js +++ b/src/components/services/queryBuilder.spec.js @@ -10,7 +10,7 @@ describe("The QueryBuilder", function () { "range", "in", "contains", "startsWith", "endsWith" ]; var rootOperators = [ - "paging", "sorting" + "page", "sort", "project" ]; var spaces = 2; @@ -30,21 +30,21 @@ describe("The QueryBuilder", function () { it("should be able to be created", function () { var q = queryBuilder.create(); - expect(q instanceof RootQuery).toBeTrue(); + expect(q instanceof queryBuilder.RootQuery).toBeTrue(); }); it("should implement the expected interface", function () { var queryInterface = validCombinators.concat(validOperators); var rootInterface = queryInterface.concat(rootOperators); - var query = new Query(); + var query = new queryBuilder.Query(); var expected = {}; queryInterface.forEach(function (value) { expected[value] = undefined; }); expect(query).toImplement(expected); - var root = new RootQuery(); + var root = new queryBuilder.RootQuery(); expected = {}; rootInterface.forEach(function (value) { expected[value] = undefined; @@ -52,10 +52,14 @@ describe("The QueryBuilder", function () { expect(root).toImplement(expected); }); - it("a query combinator should return itself", function () { + it("a query combinator should return a new instance of a Query", function () { var actual = q.and(q.eq("field", 3.0)); - expect(actual).toBe(q); + expect(actual instanceof queryBuilder.Query).toBeTrue(); + expect(q instanceof queryBuilder.RootQuery).toBeTrue(); + expect(actual instanceof queryBuilder.RootQuery).toBeFalse(); + + expect(actual).not.toBe(q); }); it("will throw if a combinator is passed a non-query object", function () { @@ -67,10 +71,9 @@ describe("The QueryBuilder", function () { it("a query operator should return a new instance of a Query", function () { var actual = q.eq("field", 3.0); - expect(actual instanceof Query).toBeTrue(); - expect(q instanceof RootQuery).toBeTrue(); - expect(actual instanceof RootQuery).toBeFalse(); - + expect(actual instanceof queryBuilder.Query).toBeTrue(); + expect(q instanceof queryBuilder.RootQuery).toBeTrue(); + expect(actual instanceof queryBuilder.RootQuery).toBeFalse(); expect(actual).not.toBe(q); }); @@ -83,9 +86,26 @@ describe("The QueryBuilder", function () { }); it("should allow a new field to be set", function() { + var newQ = q.field("test"); + + expect(newQ.getField()).toBe("test"); + expect(q.getField()).toBe(undefined); + }); + + it("should allow a new field to be set with an operation", function() { + var newQ = q.eq("test", 3.0); + expect(newQ.getField()).toBe("test"); + expect(q.getField()).toBe(undefined); }); + it("should be able to produce a bare query", function() { + var expected = {}; + + expect(q.toJSON(spaces)).toBe(j(expected)); + }); + + it("should be able to do basic equality", function () { var expected = { filter: { @@ -97,11 +117,34 @@ describe("The QueryBuilder", function () { } }; - var actual = q.and(q.eq("field", 3.0)); + var actual = q.compose(q.and(q.eq("field", 3.0))); expect(actual.toJSON(spaces)).toBe(j(expected)); }); + it("should ensure .end and .compose are the same", function() { + var expected = { + filter: { + fieldA: { + eq: 3.0 + }, + or: { + fieldB: { + lt: 6.0, + gt: 3.0 + } + } + } + }; + + var actualCompose = q.compose(q.eq("fieldA", 3.0).or(q.field("fieldB").lt(6.0).gt(3.0))); + expect(actualCompose.toJSON(spaces)).toBe(j(expected)); + + q = queryBuilder.create(); + var actualEnd = q.eq("fieldA", 3.0).or(q.field("fieldB").lt(6.0).gt(3.0)).end(); + expect(actualEnd.toJSON(spaces)).toBe(j(expected)); + }); + it("should ensure not is arity:1 only", function () { expect(function () { q.not(q.field("fieldA").eq(3.0), q.field("fieldB").eq(4.0)); @@ -117,16 +160,26 @@ describe("The QueryBuilder", function () { } }; - var actual = q.eq("field", 3.0); + var actual = q.compose(q.eq("field", 3.0)); expect(actual.toJSON(spaces)).toBe(j(expected)); }); - it("should not allow more than one operation at root level", function() { + it("should allow more than one operation at root level", function() { + var expected = { + filter: { + fieldA: { + eq: 3.0 + }, + fieldB: { + eq: 6.0 + } + } + }; - expect(function () { - var actual = q.eq("field", 3.0).eq("fieldB", 6.0); - }).toThrow(); + var actual = q.compose(q.eq("fieldA", 3.0).eq("fieldB", 6.0)); + + expect(actual.toJSON(spaces)).toBe(j(expected)); }); it("should handle a more complex query", function () { @@ -140,13 +193,16 @@ describe("The QueryBuilder", function () { fieldB: { contains: "hello" } + }, + fieldC: { + lt: 17 } } }; - var actual = q.and( + var actual = q.compose(q.and( q.lt("fieldA", 3.0).contains("fieldB", "hello").gt("fieldA", 0.0) - ); + ).lt("fieldC", 17)); expect(actual.toJSON(spaces)).toBe(j(expected)); }); @@ -165,10 +221,10 @@ describe("The QueryBuilder", function () { } }; - var actual = q.and( + var actual = q.compose(q.and( q.lt("fieldA", 3.0).contains("fieldB", "hello"), q.gt("fieldA", 0.0) - ); + )); expect(actual.toJSON(spaces)).toBe(j(expected)); }); @@ -206,7 +262,7 @@ describe("The QueryBuilder", function () { } }; - var actual = q.and( + var actual = q.compose(q.and( q.lt("fieldA", 3.0).contains("fieldB", "hello"), q.gt("fieldA", 0.0), q.or( @@ -216,7 +272,170 @@ describe("The QueryBuilder", function () { q.gt("fieldB", 4.0) ) ) - ); + )); + expect(actual.toJSON(spaces)).toBe(j(expected)); + }); + + it("should allow paging to be set", function() { + var expected = { + paging: { + items: 10, + page: 30 + } + }; + + var actual = q.page({items: 10, page: 30}); + expect(actual.toJSON(spaces)).toBe(j(expected)); + }); + + it("should validate page arguments", function() { + expect(function(){ + q.page(); + }).toThrowError(Error, "The page function expects an object"); + + expect(function(){ + q.page(null); + }).toThrowError(Error, "The page function expects an object"); + + expect(function(){ + q.page({page: "10fsfsfs"}); + }).toThrowError(Error, "paging.page must be an integer"); + + expect(function(){ + q.page({items: "1sdssd0"}); + }).toThrowError(Error, "paging.items must be an integer"); + }); + + it("should always update root with paging...even if on a subquery", function () { + var expected = { + filter: { + fieldA: { + eq: 30 + } + }, + paging: { + items: 5, + page: 2 + } + }; + + var actual = q.eq("fieldA", 30).page({items: 5, page: 2}).end(); + expect(actual.toJSON(spaces)).toBe(j(expected)); + }); + + it("should allow sorting to be set", function() { + var expected = { + sorting: { + orderBy: "durationSeconds", + direction: "desc" + } + }; + + var actual = q.sort({orderBy: "durationSeconds", direction: "desc"}); + expect(actual.toJSON(spaces)).toBe(j(expected)); + }); + + it("should validate sorting arguments", function() { + expect(function(){ + q.sort(); + }).toThrowError(Error, "The sort function expects an object"); + + expect(function(){ + q.sort(null); + }).toThrowError(Error, "The sort function expects an object"); + + expect(function(){ + q.sort({orderBy: 10}); + }).toThrowError(Error, "sorting.orderBy must be provided and must be a string"); + + expect(function(){ + q.sort({orderBy: undefined}); + }).toThrowError(Error, "sorting.orderBy must be provided and must be a string"); + + expect(function(){ + q.sort({orderBy: "", direction: "10"}); + }).toThrowError(Error, "sort.direction must be 'asc' or 'desc'"); + }); + + it("should always update root with sorting...even if on a subquery", function () { + var expected = { + filter: { + fieldB: { + lt: 6.0 + } + }, + sorting: { + orderBy: "durationSeconds", + direction: "asc" + } + }; + + var actual = q.lt("fieldB", 6.0).sort({orderBy: "durationSeconds"}).end(); + expect(actual.toJSON(spaces)).toBe(j(expected)); + }); + + it("should allow projection to be set (whitelist)", function() { + var expected = { + projection: { + include: [ + "durationSeconds", "id" + ] + } + }; + + var actual = q.project({include: ["durationSeconds", "id"]}); + expect(actual.toJSON(spaces)).toBe(j(expected)); + + }); + + it("should allow projection to be set (blacklist)", function() { + var expected = { + projection: { + exclude: [ + "durationSeconds", "id" + ] + } + }; + var actual = q.project({exclude: ["durationSeconds", "id"]}); + expect(actual.toJSON(spaces)).toBe(j(expected)); + }); + + it("should validate projection arguments", function() { + + expect(function(){ + q.project(); + }).toThrowError(Error, "The project function expects an object"); + + expect(function(){ + q.project(null); + }).toThrowError(Error, "The project function expects an object"); + + expect(function(){ + q.project({include: {}}); + }).toThrowError(Error, "projection.include must be an array of strings"); + + expect(function(){ + q.project({exclude: 123}); + }).toThrowError(Error, "projection.exclude must be an array of strings"); + + expect(function(){ + q.project({include: [""], exclude: [""]}); + }).toThrowError(Error, "A projection only supports include xor exclude"); + }); + + it("should always update root with projection...even if on a subquery", function () { + var expected = { + filter: { + fieldC: { + notEq: 7.5 + } + }, + projection: { + include: ["durationSeconds", "fieldC"] + } + }; + + var actual = q.notEq("fieldC", 7.5).project({include: ["durationSeconds", "fieldC"]}).end(); expect(actual.toJSON(spaces)).toBe(j(expected)); }); @@ -225,9 +444,9 @@ describe("The QueryBuilder", function () { var expected = { "filter": { "and": { - "site_id": { - "less_than": 123456, - "greater_than": 9876, + "siteId": { + "lessThan": 123456, + "greaterThan": 9876, "in": [ 1, 2, @@ -239,33 +458,33 @@ describe("The QueryBuilder", function () { } }, "status": { - "greater_than_or_equal": 4567, + "greaterThanOrEqual": 4567, "contains": "contain text", - "starts_with": "starts with text", - "ends_with": "ends with text", + "startsWith": "starts with text", + "endsWith": "ends with text", "range": { "interval": "[123, 128]" } }, "or": { - "duration_seconds": { - "not_eq": 40 + "durationSeconds": { + "notEq": 40 }, "not": { "channels": { - "less_than_or_equal": 9999 + "lessThanOrEqual": 9999 } } } }, "or": { - "recorded_date": { + "recordedDate": { "contains": "Hello" }, - "media_type": { - "ends_with": "world" + "mediaType": { + "endsWith": "world" }, - "duration_seconds": { + "durationSeconds": { "eq": 60, "lteq": 70, "equal": 50, @@ -273,25 +492,25 @@ describe("The QueryBuilder", function () { }, "channels": { "eq": 1, - "less_than_or_equal": 8888 + "lessThanOrEqual": 8888 } }, "not": { - "duration_seconds": { - "not_eq": 140 + "durationSeconds": { + "notEq": 140 } } }/*, "projection": { "include": [ - "recorded_date", - "site_id", - "duration_seconds", - "media_type" + "recordedDate", + "siteId", + "durationSeconds", + "mediaType" ] }, "sort": { - "order_by": "duration_seconds", + "orderBy": "duration_seconds", "direction": "desc" }, "paging": { @@ -300,10 +519,8 @@ describe("The QueryBuilder", function () { }*/ }; - var actual = q - .and( - q - .lessThan("siteId", 123456) + var actual = q.and( + q.lessThan("siteId", 123456) .greaterThan(9876) .in([1,2,3]) .range({from: 100, to: 200}), @@ -330,7 +547,7 @@ describe("The QueryBuilder", function () { q.eq("channels", 1).lessThanOrEqual(8888) ).not( q.notEq("durationSeconds", 140) - ); + ).end(); expect(actual.toJSON(spaces)).toBe(j(expected)); }); diff --git a/src/components/services/unitConverter.js b/src/components/services/unitConverter.js index c88b7519..1591102a 100644 --- a/src/components/services/unitConverter.js +++ b/src/components/services/unitConverter.js @@ -25,7 +25,7 @@ uc.factory("bawApp.unitConverter", ['conf.constants', function (constants) { keysToCheck.forEach(function(key) { if (!angular.isNumber(data[key])) { - throw "Input data field `" + key + "` should be a number!"; + throw new Error("Input data field `" + key + "` should be a number!"); } });