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:
+ * - success: Function to call when the count completes successfully.
+ *
- error: Function to call when the find fails.
+ *
- sessionToken: A valid session token, used for making a request on
+ * behalf of a specific user.
+ *
+ *
+ * @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:
+ * - success: Function to call when the count completes successfully.
+ *
- error: Function to call when the find fails.
+ *
- sessionToken: A valid session token, used for making a request on
+ * behalf of a specific user.
+ *
+ *
+ * @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(() => {