diff --git a/integration/package.json b/integration/package.json index b5a35b928..5927ccbc9 100644 --- a/integration/package.json +++ b/integration/package.json @@ -3,7 +3,7 @@ "dependencies": { "express": "^4.13.4", "mocha": "^2.4.5", - "parse-server": "^2.6.0" + "parse-server": "^2.7.0" }, "scripts": { "test": "mocha --reporter dot -t 5000" diff --git a/integration/test/ParseQueryAggregateTest.js b/integration/test/ParseQueryAggregateTest.js new file mode 100644 index 000000000..d31d1d712 --- /dev/null +++ b/integration/test/ParseQueryAggregateTest.js @@ -0,0 +1,78 @@ +'use strict'; + +const assert = require('assert'); +const clear = require('./clear'); +const mocha = require('mocha'); +const Parse = require('../../node'); + +const TestObject = Parse.Object.extend('TestObject'); + +describe('Parse Aggregate Query', () => { + before((done) => { + Parse.initialize('integration', null, 'notsosecret'); + Parse.CoreManager.set('SERVER_URL', 'http://localhost:1337/parse'); + Parse.Storage._clear(); + clear().then(() => { + const obj1 = new TestObject({score: 10, name: 'foo'}); + const obj2 = new TestObject({score: 10, name: 'foo'}); + const obj3 = new TestObject({score: 10, name: 'bar'}); + const obj4 = new TestObject({score: 20, name: 'dpl'}); + return Parse.Object.saveAll([obj1, obj2, obj3, obj4]); + }).then(() => { + return Parse.User.logOut(); + }) + .then(() => { done() }, () => { done() }); + }); + + it('aggregate pipeline object query', (done) => { + const pipeline = { + group: { objectId: '$name' } + }; + const query = new Parse.Query(TestObject); + query.aggregate(pipeline).then((results) => { + assert.equal(results.length, 3); + done(); + }); + }); + + it('aggregate pipeline array query', (done) => { + const pipeline = [ + { group: { objectId: '$name' } } + ]; + const query = new Parse.Query(TestObject); + query.aggregate(pipeline).then((results) => { + assert.equal(results.length, 3); + done(); + }); + }); + + it('aggregate pipeline invalid query', (done) => { + const pipeline = 1234; + const query = new Parse.Query(TestObject); + try { + query.aggregate(pipeline).then(() => {}); + } catch (e) { + done(); + } + }); + + it('distinct query', (done) => { + const query = new Parse.Query(TestObject); + query.distinct('score').then((results) => { + assert.equal(results.length, 2); + assert.equal(results[0], 10); + assert.equal(results[1], 20); + done(); + }); + }); + + it('distinct equalTo query', (done) => { + const query = new Parse.Query(TestObject); + query.equalTo('name', 'foo') + query.distinct('score').then((results) => { + assert.equal(results.length, 1); + assert.equal(results[0], 10); + done(); + }); + }); +}); diff --git a/src/CoreManager.js b/src/CoreManager.js index 020a9b8ae..90e74f33a 100644 --- a/src/CoreManager.js +++ b/src/CoreManager.js @@ -71,6 +71,7 @@ type PushController = { }; type QueryController = { find: (className: string, params: QueryJSON, options: RequestOptions) => ParsePromise; + aggregate: (className: string, params: any, options: RequestOptions) => ParsePromise; }; type RESTController = { request: (method: string, path: string, data: mixed) => ParsePromise; @@ -268,7 +269,7 @@ module.exports = { }, setQueryController(controller: QueryController) { - requireMethods('QueryController', ['find'], controller); + requireMethods('QueryController', ['find', 'aggregate'], controller); config['QueryController'] = controller; }, diff --git a/src/ParseQuery.js b/src/ParseQuery.js index e4f5586d4..7bea22e7c 100644 --- a/src/ParseQuery.js +++ b/src/ParseQuery.js @@ -484,6 +484,91 @@ class ParseQuery { })._thenRunCallbacks(options); } + /** + * Executes a distinct query and returns unique values + * + * @param {String} key A field to find distinct values + * @param {Object} options A Backbone-style options object. Valid options + * are: + * + * @return {Parse.Promise} A promise that is resolved with the query completes. + */ + distinct(key: string, options?: FullOptions): ParsePromise { + options = options || {}; + + const distinctOptions = { + useMasterKey: true + }; + if (options.hasOwnProperty('sessionToken')) { + distinctOptions.sessionToken = options.sessionToken; + } + const controller = CoreManager.getQueryController(); + const params = { + distinct: key, + where: this._where + }; + + return controller.aggregate( + this.className, + params, + distinctOptions + ).then((results) => { + return results.results; + })._thenRunCallbacks(options); + } + + /** + * Executes an aggregate query and returns aggregate results + * + * @param {Mixed} pipeline Array or Object of stages to process query + * @param {Object} options A Backbone-style options object. Valid options + * are: + * + * @return {Parse.Promise} A promise that is resolved with the query completes. + */ + aggregate(pipeline: mixed, options?: FullOptions): ParsePromise { + options = options || {}; + + const aggregateOptions = { + useMasterKey: true + }; + if (options.hasOwnProperty('sessionToken')) { + aggregateOptions.sessionToken = options.sessionToken; + } + const controller = CoreManager.getQueryController(); + let stages = {}; + + if (Array.isArray(pipeline)) { + pipeline.forEach((stage) => { + for (let op in stage) { + stages[op] = stage[op]; + } + }); + } else if (pipeline && typeof pipeline === 'object') { + stages = pipeline; + } else { + throw new Error('Invalid pipeline must be Array or Object'); + } + + return controller.aggregate( + this.className, + stages, + aggregateOptions + ).then((results) => { + return results.results; + })._thenRunCallbacks(options); + } + /** * Retrieves at most one Parse.Object that satisfies this query. * @@ -1196,6 +1281,17 @@ var DefaultController = { params, options ); + }, + + aggregate(className: string, params: any, options: RequestOptions): ParsePromise { + const RESTController = CoreManager.getRESTController(); + + return RESTController.request( + 'GET', + 'aggregate/' + className, + params, + options + ); } }; diff --git a/src/__tests__/CoreManager-test.js b/src/__tests__/CoreManager-test.js index 5429907e6..1a307637d 100644 --- a/src/__tests__/CoreManager-test.js +++ b/src/__tests__/CoreManager-test.js @@ -220,13 +220,15 @@ describe('CoreManager', () => { ); expect(CoreManager.setQueryController.bind(null, { - find: function() {} + find: function() {}, + aggregate: function() {} })).not.toThrow(); }); it('can set and get QueryController', () => { var controller = { - find: function() {} + find: function() {}, + aggregate: function() {} }; CoreManager.setQueryController(controller); diff --git a/src/__tests__/ParseQuery-test.js b/src/__tests__/ParseQuery-test.js index d1d329b39..78c01e5ab 100644 --- a/src/__tests__/ParseQuery-test.js +++ b/src/__tests__/ParseQuery-test.js @@ -885,6 +885,7 @@ describe('ParseQuery', () => { it('can get the first object of a query', (done) => { CoreManager.setQueryController({ + aggregate() {}, find(className, params, options) { expect(className).toBe('Item'); expect(params).toEqual({ @@ -917,6 +918,7 @@ describe('ParseQuery', () => { it('can pass options to a first() query', (done) => { CoreManager.setQueryController({ + aggregate() {}, find(className, params, options) { expect(className).toBe('Item'); expect(params).toEqual({ @@ -947,6 +949,7 @@ describe('ParseQuery', () => { it('can get a single object by id', (done) => { CoreManager.setQueryController({ + aggregate() {}, find(className, params, options) { expect(className).toBe('Item'); expect(params).toEqual({ @@ -979,6 +982,7 @@ describe('ParseQuery', () => { it('will error when getting a nonexistent object', (done) => { CoreManager.setQueryController({ + aggregate() {}, find(className, params, options) { expect(className).toBe('Item'); expect(params).toEqual({ @@ -1008,6 +1012,7 @@ describe('ParseQuery', () => { it('can pass options to a get() query', (done) => { CoreManager.setQueryController({ + aggregate() {}, find(className, params, options) { expect(className).toBe('Item'); expect(params).toEqual({ @@ -1039,6 +1044,7 @@ describe('ParseQuery', () => { it('can issue a count query', (done) => { CoreManager.setQueryController({ + aggregate() {}, find(className, params, options) { expect(className).toBe('Item'); expect(params).toEqual({ @@ -1065,6 +1071,7 @@ describe('ParseQuery', () => { it('can pass options to a count query', (done) => { CoreManager.setQueryController({ + aggregate() {}, find(className, params, options) { expect(className).toBe('Item'); expect(params).toEqual({ @@ -1085,6 +1092,7 @@ describe('ParseQuery', () => { } }); + var q = new ParseQuery('Item'); q.equalTo('size', 'small').count({ useMasterKey: true, @@ -1097,6 +1105,7 @@ describe('ParseQuery', () => { it('can issue a query to the controller', (done) => { CoreManager.setQueryController({ + aggregate() {}, find(className, params, options) { expect(className).toBe('Item'); expect(params).toEqual({ @@ -1145,6 +1154,7 @@ describe('ParseQuery', () => { it('can pass options to find()', (done) => { CoreManager.setQueryController({ + aggregate() {}, find(className, params, options) { expect(className).toBe('Item'); expect(params).toEqual({ @@ -1178,6 +1188,7 @@ describe('ParseQuery', () => { it('can iterate over results with each()', (done) => { CoreManager.setQueryController({ + aggregate() {}, find(className, params, options) { expect(className).toBe('Item'); expect(params).toEqual({ @@ -1234,6 +1245,7 @@ describe('ParseQuery', () => { it('can pass options to each()', (done) => { CoreManager.setQueryController({ + aggregate() {}, find(className, params, options) { expect(className).toBe('Item'); expect(params).toEqual({ @@ -1302,6 +1314,7 @@ describe('ParseQuery', () => { it('does not override the className if it comes from the server', (done) => { CoreManager.setQueryController({ + aggregate() {}, find(className, params, options) { return ParsePromise.as({ results: [ @@ -1320,6 +1333,7 @@ describe('ParseQuery', () => { it('can override the className with a name from the server', (done) => { CoreManager.setQueryController({ + aggregate() {}, find(className, params, options) { return ParsePromise.as({ results: [ @@ -1338,7 +1352,7 @@ describe('ParseQuery', () => { }); - + it('overrides cached object with query results', (done) => { jest.dontMock("../ParseObject"); jest.resetModules(); @@ -1347,16 +1361,17 @@ describe('ParseQuery', () => { ParseQuery = require('../ParseQuery').default; ParseObject.enableSingleInstance(); - - var objectToReturn = { - objectId: 'T01', - name: 'Name', - other: 'other', - className:"Thing", + + var objectToReturn = { + objectId: 'T01', + name: 'Name', + other: 'other', + className:"Thing", createdAt: '2017-01-10T10:00:00Z' }; CoreManager.setQueryController({ + aggregate() {}, find(className, params, options) { return ParsePromise.as({ results: [objectToReturn] @@ -1368,10 +1383,10 @@ describe('ParseQuery', () => { var testObject; q.find().then((results) => { testObject = results[0]; - + expect(testObject.get("name")).toBe("Name"); expect(testObject.get("other")).toBe("other"); - + objectToReturn = { objectId: 'T01', name: 'Name2'}; var q2 = new ParseQuery("Thing"); return q2.find(); @@ -1393,18 +1408,19 @@ describe('ParseQuery', () => { ParseQuery = require('../ParseQuery').default; ParseObject.enableSingleInstance(); - - var objectToReturn = { - objectId: 'T01', - name: 'Name', - other: 'other', - tbd: 'exists', - className:"Thing", + + var objectToReturn = { + objectId: 'T01', + name: 'Name', + other: 'other', + tbd: 'exists', + className:"Thing", createdAt: '2017-01-10T10:00:00Z', subObject: {key1:"value", key2:"value2", key3:"thisWillGoAway"} }; CoreManager.setQueryController({ + aggregate() {}, find(className, params, options) { return ParsePromise.as({ results: [objectToReturn] @@ -1416,14 +1432,14 @@ describe('ParseQuery', () => { var testObject; return q.find().then((results) => { testObject = results[0]; - + expect(testObject.get("name")).toBe("Name"); expect(testObject.get("other")).toBe("other"); expect(testObject.has("tbd")).toBe(true); expect(testObject.get("subObject").key1).toBe("value"); expect(testObject.get("subObject").key2).toBe("value2"); expect(testObject.get("subObject").key3).toBe("thisWillGoAway"); - + var q2 = new ParseQuery("Thing"); q2.select("other", "tbd", "subObject.key1", "subObject.key3"); objectToReturn = { objectId: 'T01', other: 'other2', subObject:{key1:"updatedValue"}}; @@ -1442,7 +1458,7 @@ describe('ParseQuery', () => { expect(testObject.has("tbd")).toBe(false); expect(testObject.get("subObject").key1).toBe("updatedValue"); expect(testObject.get("subObject").key2).toBe("value2"); - expect(testObject.get("subObject").key3).toBeUndefined(); + expect(testObject.get("subObject").key3).toBeUndefined(); done(); }, (error) => { done.fail(error); @@ -1457,16 +1473,17 @@ describe('ParseQuery', () => { ParseQuery = require('../ParseQuery').default; ParseObject.enableSingleInstance(); - - var objectToReturn = { - objectId: 'T01', - name: 'Name', - other: 'other', - className:"Thing", + + var objectToReturn = { + objectId: 'T01', + name: 'Name', + other: 'other', + className:"Thing", createdAt: '2017-01-10T10:00:00Z' }; CoreManager.setQueryController({ + aggregate() {}, find(className, params, options) { return ParsePromise.as({ results: [objectToReturn] @@ -1478,10 +1495,10 @@ describe('ParseQuery', () => { var testObject; q.first().then((result) => { testObject = result; - + expect(testObject.get("name")).toBe("Name"); expect(testObject.get("other")).toBe("other"); - + objectToReturn = { objectId: 'T01', name: 'Name2'}; var q2 = new ParseQuery("Thing"); return q2.first(); @@ -1503,18 +1520,19 @@ describe('ParseQuery', () => { ParseQuery = require('../ParseQuery').default; ParseObject.enableSingleInstance(); - - var objectToReturn = { - objectId: 'T01', - name: 'Name', - other: 'other', - tbd: 'exists', - className:"Thing", + + var objectToReturn = { + objectId: 'T01', + name: 'Name', + other: 'other', + tbd: 'exists', + className:"Thing", subObject: {key1:"value", key2:"value2", key3:"thisWillGoAway"}, createdAt: '2017-01-10T10:00:00Z', }; CoreManager.setQueryController({ + aggregate() {}, find(className, params, options) { return ParsePromise.as({ results: [objectToReturn] @@ -1526,11 +1544,11 @@ describe('ParseQuery', () => { var testObject; return q.first().then((result) => { testObject = result; - + expect(testObject.get("name")).toBe("Name"); expect(testObject.get("other")).toBe("other"); expect(testObject.has("tbd")).toBe(true); - + var q2 = new ParseQuery("Thing"); q2.select("other", "tbd", "subObject.key1", "subObject.key3"); objectToReturn = { objectId: 'T01', other: 'other2', subObject:{key1:"updatedValue"}}; @@ -1546,10 +1564,10 @@ describe('ParseQuery', () => { }).then(() => { expect(testObject.get("name")).toBe("Name"); expect(testObject.get("other")).toBe("other2"); - expect(testObject.has("tbd")).toBe(false); + expect(testObject.has("tbd")).toBe(false); expect(testObject.get("subObject").key1).toBe("updatedValue"); expect(testObject.get("subObject").key2).toBe("value2"); - expect(testObject.get("subObject").key3).toBeUndefined(); + expect(testObject.get("subObject").key3).toBeUndefined(); done(); }, (error) => { done.fail(error); @@ -1582,7 +1600,166 @@ describe('ParseQuery', () => { size: 'medium' } }); + }); + + it('can issue a distinct query', (done) => { + CoreManager.setQueryController({ + find() {}, + aggregate(className, params, options) { + expect(className).toBe('Item'); + expect(params).toEqual({ + distinct: 'size', + where: { + size: 'small' + } + }); + expect(options).toEqual({ useMasterKey: true }); + return ParsePromise.as({ + results: ['L'], + }); + } + }); + var q = new ParseQuery('Item'); + q.equalTo('size', 'small').distinct('size').then((results) => { + expect(results[0]).toBe('L'); + done(); + }); + }); + + it('can pass options to a distinct query', (done) => { + CoreManager.setQueryController({ + find() {}, + aggregate(className, params, options) { + expect(className).toBe('Item'); + expect(params).toEqual({ + distinct: 'size', + where: { + size: 'small' + } + }); + expect(options).toEqual({ + useMasterKey: true, + sessionToken: '1234' + }); + return ParsePromise.as({ + results: ['L'] + }); + } + }); + + + var q = new ParseQuery('Item'); + q.equalTo('size', 'small').distinct('size', { + sessionToken: '1234' + }).then((results) => { + expect(results[0]).toBe('L'); + done(); + }); + }); + + it('can issue an aggregate query with array pipeline', (done) => { + const pipeline = [ + { group: { objectId: '$name' } } + ]; + CoreManager.setQueryController({ + find() {}, + aggregate(className, params, options) { + expect(className).toBe('Item'); + expect(params).toEqual({ + group: { objectId: '$name' } + }); + expect(options).toEqual({ useMasterKey: true }); + return ParsePromise.as({ + results: [], + }); + } + }); + + var q = new ParseQuery('Item'); + q.aggregate(pipeline).then((results) => { + expect(results).toEqual([]); + done(); + }); + }); + + it('can issue an aggregate query with object pipeline', (done) => { + const pipeline = { + group: { objectId: '$name' } + }; + CoreManager.setQueryController({ + find() {}, + aggregate(className, params, options) { + expect(className).toBe('Item'); + expect(params).toEqual({ + group: { objectId: '$name' } + }); + expect(options).toEqual({ useMasterKey: true }); + return ParsePromise.as({ + results: [], + }); + } + }); + + var q = new ParseQuery('Item'); + q.aggregate(pipeline).then((results) => { + expect(results).toEqual([]); + done(); + }); + }); + + it('cannot issue an aggregate query with invalid pipeline', (done) => { + const pipeline = 1234; + CoreManager.setQueryController({ + find() {}, + aggregate(className, params, options) { + expect(className).toBe('Item'); + expect(params).toEqual({ + group: { objectId: '$name' } + }); + expect(options).toEqual({ useMasterKey: true }); + return ParsePromise.as({ + results: [], + }); + } + }); + + try { + var q = new ParseQuery('Item'); + q.aggregate(pipeline).then(() => {}); + } catch (e) { + done(); + } + }); + + it('can pass options to an aggregate query', (done) => { + const pipeline = [ + { group: { objectId: '$name' } } + ]; + CoreManager.setQueryController({ + find() {}, + aggregate(className, params, options) { + expect(className).toBe('Item'); + expect(params).toEqual({ + group: { objectId: '$name' } + }); + expect(options).toEqual({ + useMasterKey: true, + sessionToken: '1234' + }); + return ParsePromise.as({ + results: [] + }); + } + }); + + var q = new ParseQuery('Item'); + q.aggregate(pipeline, { + sessionToken: '1234' + }).then((results) => { + expect(results).toEqual([]); + done(); + }); }); it('selecting sub-objects does not inject objects when sub-object does not exist', (done) => { @@ -1593,16 +1770,17 @@ describe('ParseQuery', () => { ParseQuery = require('../ParseQuery').default; ParseObject.enableSingleInstance(); - - var objectToReturn = { - objectId: 'T01', - name: 'Name', - tbd: 'exists', - className:"Thing", + + var objectToReturn = { + objectId: 'T01', + name: 'Name', + tbd: 'exists', + className:"Thing", createdAt: '2017-01-10T10:00:00Z' }; CoreManager.setQueryController({ + aggregate() {}, find(className, params, options) { return ParsePromise.as({ results: [objectToReturn] @@ -1615,7 +1793,7 @@ describe('ParseQuery', () => { var testObject; return q.find().then((results) => { testObject = results[0]; - + expect(testObject.get("name")).toBe("Name"); expect(testObject.has("other")).toBe(false); expect(testObject.has("subObject")).toBe(false); @@ -1635,12 +1813,12 @@ describe('ParseQuery', () => { ParseQuery = require('../ParseQuery').default; ParseObject.enableSingleInstance(); - - var objectToReturn = { - objectId: 'T01', - name: 'Name', - tbd: 'exists', - className:"Thing", + + var objectToReturn = { + objectId: 'T01', + name: 'Name', + tbd: 'exists', + className:"Thing", subObject1: {foo:"bar"}, subObject2: {foo:"bar"}, subObject3: {foo:"bar"}, @@ -1649,6 +1827,7 @@ describe('ParseQuery', () => { }; CoreManager.setQueryController({ + aggregate() {}, find(className, params, options) { return ParsePromise.as({ results: [objectToReturn] @@ -1660,7 +1839,7 @@ describe('ParseQuery', () => { var testObject; return q.find().then((results) => { testObject = results[0]; - + expect(testObject.has("subObject1")).toBe(true); expect(testObject.has("subObject2")).toBe(true); expect(testObject.has("subObject3")).toBe(true); @@ -1675,7 +1854,7 @@ describe('ParseQuery', () => { expect(testObject.has("subObject2")).toBe(false); //selected and not returned expect(testObject.has("subObject3")).toBe(true); //not selected, so should still be there expect(testObject.has("subObject4")).toBe(true); //selected and just added - expect(testObject.has("subObject5")).toBe(true); + expect(testObject.has("subObject5")).toBe(true); expect(testObject.get("subObject5").subSubObject).toBeDefined(); expect(testObject.get("subObject5").subSubObject.bar).toBeDefined(); //not selected but a sibiling was, so should still be there }).then(() => {