Repository: parse-community/Parse-SDK-JS
Pull request #485 (2017-10-01 21:14:57)

Support for Aggregate Queries
Support for `distinct()` and `aggregate()` queries.

Pending: https://github.com/parse-community/parse-server/pull/4207

oallouch: That's really great. All the power of MongoDB in Parse !
Do you have prepared a little documentation update for this ?
dplewis: @oallouch Documentation will be coming soon!
codecov[bot]: # [Codecov](https://codecov.io/gh/parse-community/Parse-SDK-JS/pull/485?src=pr&el=h1) Report
> Merging [#485](https://codecov.io/gh/parse-community/Parse-SDK-JS/pull/485?src=pr&el=desc) into [master](https://codecov.io/gh/parse-community/Parse-SDK-JS/commit/293e2e13fa8eb1c62786fecf6aa6cd4458670b7f?src=pr&el=desc) will **decrease** coverage by `0.63%`.
> The diff coverage is `0%`.

[![Impacted file tree graph](https://codecov.io/gh/parse-community/Parse-SDK-JS/pull/485/graphs/tree.svg?token=8nCENM0QHj&width=650&height=150&src=pr)](https://codecov.io/gh/parse-community/Parse-SDK-JS/pull/485?src=pr&el=tree)

```diff
@@            Coverage Diff             @@
##           master     #485      +/-   ##
==========================================
- Coverage   84.03%   83.39%   -0.64%     
==========================================
  Files          47       47              
  Lines        3795     3824      +29     
  Branches      868      874       +6     
==========================================
  Hits         3189     3189              
- Misses        606      635      +29
```


| [Impacted Files](https://codecov.io/gh/parse-community/Parse-SDK-JS/pull/485?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [src/CoreManager.js](https://codecov.io/gh/parse-community/Parse-SDK-JS/pull/485?src=pr&el=tree#diff-c3JjL0NvcmVNYW5hZ2VyLmpz) | `100% <ø> (ø)` | :arrow_up: |
| [src/ParseQuery.js](https://codecov.io/gh/parse-community/Parse-SDK-JS/pull/485?src=pr&el=tree#diff-c3JjL1BhcnNlUXVlcnkuanM=) | `86.87% <0%> (-7.16%)` | :arrow_down: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/parse-community/Parse-SDK-JS/pull/485?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/parse-community/Parse-SDK-JS/pull/485?src=pr&el=footer). Last update [293e2e1...4f4b389](https://codecov.io/gh/parse-community/Parse-SDK-JS/pull/485?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).

dplewis: @flovilmart Can you check this?
flovilmart: code looks good, but would it be too much to ask for added Jest tests for this class?
flovilmart: Thanks @dplewis for this! 🙏 
dplewis: @flovilmart Quick question. I was lazy and didn't require aggregate in the query controller https://github.com/dplewis/Parse-SDK-JS/blob/aggregate/src/CoreManager.js#L272

Is that ok? I can update all the tests if needed.
flovilmart: That would be better to add those methods now so we don’t have to come back to it later. 
flovilmart: CoreManager-test is failing (it specifically validates the interface)
flovilmart: Perfect! Waiting for CI but it all looks great! 
dplewis: @flovilmart How does this look?
flovilmart: Perfect 👌🏻 

Patch:
diff --git a/integration/package.json b/integration/package.json
index b5a35b9..5927ccb 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/src/CoreManager.js b/src/CoreManager.js
index 020a9b8..90e74f3 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 e4f5586..7bea22e 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:<ul>
+   *   <li>success: Function to call when the count completes successfully.
+   *   <li>error: Function to call when the find fails.
+   *   <li>sessionToken: A valid session token, used for making a request on
+   *       behalf of a specific user.
+   * </ul>
+   *
+   * @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:<ul>
+   *   <li>success: Function to call when the count completes successfully.
+   *   <li>error: Function to call when the find fails.
+   *   <li>sessionToken: A valid session token, used for making a request on
+   *       behalf of a specific user.
+   * </ul>
+   *
+   * @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
+    );
   }
 };
 


Test patch:
diff --git a/integration/test/ParseQueryAggregateTest.js b/integration/test/ParseQueryAggregateTest.js
new file mode 100644
index 0000000..d31d1d7
--- /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/__tests__/CoreManager-test.js b/src/__tests__/CoreManager-test.js
index 5429907..1a30763 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 d1d329b..78c01e5 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(() => {