Repository: parse-community/Parse-SDK-JS (js)

Issue:
Add count flag to Parse.Query.find
REST api supports adding &count=true.
Using JS SDK one needs to run a separate query.count() to get this number

Would be nice to have some optional flag like query.withCount(true/false) to avoid
making separate async calls



Pull request description:
Adds withCount query constraint
Fixes #865 

Patch:
diff --git a/package-lock.json b/package-lock.json
index 593e9b6..53a2358 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -5670,8 +5670,7 @@
         "ansi-regex": {
           "version": "2.1.1",
           "bundled": true,
-          "dev": true,
-          "optional": true
+          "dev": true
         },
         "aproba": {
           "version": "1.2.0",
@@ -5694,15 +5693,13 @@
         "balanced-match": {
           "version": "1.0.0",
           "bundled": true,
-          "dev": true,
-          "optional": true
+          "dev": true
         },
         "brace-expansion": {
           "version": "1.1.11",
           "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
           "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
           "dev": true,
-          "optional": true,
           "requires": {
             "balanced-match": "^1.0.0",
             "concat-map": "0.0.1"
@@ -5718,20 +5715,17 @@
         "code-point-at": {
           "version": "1.1.0",
           "bundled": true,
-          "dev": true,
-          "optional": true
+          "dev": true
         },
         "concat-map": {
           "version": "0.0.1",
           "bundled": true,
-          "dev": true,
-          "optional": true
+          "dev": true
         },
         "console-control-strings": {
           "version": "1.1.0",
           "bundled": true,
-          "dev": true,
-          "optional": true
+          "dev": true
         },
         "core-util-is": {
           "version": "1.0.2",
@@ -5861,8 +5855,7 @@
         "inherits": {
           "version": "2.0.3",
           "bundled": true,
-          "dev": true,
-          "optional": true
+          "dev": true
         },
         "ini": {
           "version": "1.3.5",
@@ -5876,7 +5869,6 @@
           "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz",
           "integrity": "sha1-754xOG8DGn8NZDr4L95QxFfvAMs=",
           "dev": true,
-          "optional": true,
           "requires": {
             "number-is-nan": "^1.0.0"
           }
@@ -5893,7 +5885,6 @@
           "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz",
           "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==",
           "dev": true,
-          "optional": true,
           "requires": {
             "brace-expansion": "^1.1.7"
           }
@@ -5901,15 +5892,13 @@
         "minimist": {
           "version": "0.0.8",
           "bundled": true,
-          "dev": true,
-          "optional": true
+          "dev": true
         },
         "minipass": {
           "version": "2.3.5",
           "resolved": "https://registry.npmjs.org/minipass/-/minipass-2.3.5.tgz",
           "integrity": "sha512-Gi1W4k059gyRbyVUZQ4mEqLm0YIUiGYfvxhF6SIlk3ui1WVxMTGfGdQ2SInh3PDrRTVvPKgULkpJtT4RH10+VA==",
           "dev": true,
-          "optional": true,
           "requires": {
             "safe-buffer": "^5.1.2",
             "yallist": "^3.0.0"
@@ -5930,7 +5919,6 @@
           "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz",
           "integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=",
           "dev": true,
-          "optional": true,
           "requires": {
             "minimist": "0.0.8"
           }
@@ -6018,8 +6006,7 @@
         "number-is-nan": {
           "version": "1.0.1",
           "bundled": true,
-          "dev": true,
-          "optional": true
+          "dev": true
         },
         "object-assign": {
           "version": "4.1.1",
@@ -6033,7 +6020,6 @@
           "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
           "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=",
           "dev": true,
-          "optional": true,
           "requires": {
             "wrappy": "1"
           }
@@ -6128,8 +6114,7 @@
         "safe-buffer": {
           "version": "5.1.2",
           "bundled": true,
-          "dev": true,
-          "optional": true
+          "dev": true
         },
         "safer-buffer": {
           "version": "2.1.2",
@@ -6171,7 +6156,6 @@
           "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz",
           "integrity": "sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=",
           "dev": true,
-          "optional": true,
           "requires": {
             "code-point-at": "^1.0.0",
             "is-fullwidth-code-point": "^1.0.0",
@@ -6193,7 +6177,6 @@
           "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz",
           "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=",
           "dev": true,
-          "optional": true,
           "requires": {
             "ansi-regex": "^2.0.0"
           }
@@ -6241,14 +6224,12 @@
         "wrappy": {
           "version": "1.0.2",
           "bundled": true,
-          "dev": true,
-          "optional": true
+          "dev": true
         },
         "yallist": {
           "version": "3.0.3",
           "bundled": true,
-          "dev": true,
-          "optional": true
+          "dev": true
         }
       }
     },
@@ -9561,7 +9542,6 @@
       "resolved": "https://registry.npmjs.org/minipass/-/minipass-2.3.5.tgz",
       "integrity": "sha512-Gi1W4k059gyRbyVUZQ4mEqLm0YIUiGYfvxhF6SIlk3ui1WVxMTGfGdQ2SInh3PDrRTVvPKgULkpJtT4RH10+VA==",
       "dev": true,
-      "optional": true,
       "requires": {
         "safe-buffer": "^5.1.2",
         "yallist": "^3.0.0"
@@ -9571,8 +9551,7 @@
           "version": "3.0.3",
           "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.0.3.tgz",
           "integrity": "sha512-S+Zk8DEWE6oKpV+vI3qWkaK+jSbIK86pCwe2IF/xwIpQ8jEuxpw9NyaGjmp9+BoJv5FV2piqCDcoCtStppiq2A==",
-          "dev": true,
-          "optional": true
+          "dev": true
         }
       }
     },
diff --git a/src/ParseQuery.js b/src/ParseQuery.js
index 0f6cff4..93eceef 100644
--- a/src/ParseQuery.js
+++ b/src/ParseQuery.js
@@ -224,6 +224,7 @@ class ParseQuery {
   _select: Array<string>;
   _limit: number;
   _skip: number;
+  _count: boolean;
   _order: Array<string>;
   _readPreference: string;
   _includeReadPreference: string;
@@ -260,6 +261,7 @@ class ParseQuery {
     this._where = {};
     this._include = [];
     this._exclude = [];
+    this._count = false;
     this._limit = -1; // negative limit is not sent in the server request
     this._skip = 0;
     this._readPreference = null;
@@ -364,6 +366,12 @@ class ParseQuery {
         return handleOfflineSort(a, b, sorts);
       });
     }
+
+    let count // count total before applying limit/skip
+    if(params.count){
+      count = results.length; // total count from response
+    }
+
     if (params.skip) {
       if (params.skip >= results.length) {
         results = [];
@@ -375,7 +383,13 @@ class ParseQuery {
     if (params.limit !== 0 && params.limit < results.length) {
       limit = params.limit;
     }
+
     results = results.splice(0, limit);
+
+    if(typeof count === 'number'){
+      return {results, count};
+    }
+
     return results;
   }
 
@@ -397,6 +411,9 @@ class ParseQuery {
     if (this._select) {
       params.keys = this._select.join(',');
     }
+    if (this._count) {
+      params.count = 1;
+    }
     if (this._limit >= 0) {
       params.limit = this._limit;
     }
@@ -460,6 +477,10 @@ class ParseQuery {
       this._exclude = json.excludeKeys.split(",");
     }
 
+    if (json.count) {
+      this._count = json.count === 1;
+    }
+
     if (json.limit) {
       this._limit  = json.limit;
     }
@@ -486,7 +507,7 @@ class ParseQuery {
 
     for (const key in json) {
       if (json.hasOwnProperty(key))  {
-        if (["where", "include", "keys", "limit", "skip", "order", "readPreference", "includeReadPreference", "subqueryReadPreference"].indexOf(key) === -1) {
+        if (["where", "include", "keys", "count", "limit", "skip", "order", "readPreference", "includeReadPreference", "subqueryReadPreference"].indexOf(key) === -1) {
           this._extraOptions[key] = json[key];
         }
       }
@@ -588,7 +609,8 @@ class ParseQuery {
       this.toJSON(),
       findOptions
     ).then((response) => {
-      return response.results.map((data) => {
+
+      const results = response.results.map((data) => {
         // In cases of relations, the server may send back a className
         // on the top level of the payload
         const override = response.className || this.className;
@@ -605,6 +627,14 @@ class ParseQuery {
 
         return ParseObject.fromJSON(data, !select);
       });
+
+      const count = response.count;
+
+      if(typeof count === "number"){
+        return {results, count};
+      } else {
+        return results;
+      }
     });
   }
 
@@ -1468,6 +1498,21 @@ class ParseQuery {
     return this;
   }
 
+  /**
+   * Sets the flag to include with response the total number of objects satisfying this query,
+   * despite limits/skip. Might be useful for pagination.
+   * Note that result of this query will be wrapped as an object with
+   *`results`: holding {ParseObject} array and `count`: integer holding total number
+   * @param {boolean} b false - disable, true - enable.
+   * @return {Parse.Query} Returns the query, so you can chain this call.
+   */
+  withCount(includeCount: boolean = true): ParseQuery {
+    if (typeof includeCount !== 'boolean') {
+      throw new Error('You can only set withCount to a boolean value');
+    }
+    this._count = includeCount;
+    return this;
+  }
   /**
    * Includes nested Parse.Objects for the provided key.  You can use dot
    * notation to specify which fields in the included object are also fetched.


Test patch:
diff --git a/integration/test/ParseQueryTest.js b/integration/test/ParseQueryTest.js
index 3760ca3..70916d0 100644
--- a/integration/test/ParseQueryTest.js
+++ b/integration/test/ParseQueryTest.js
@@ -23,6 +23,7 @@ describe('Parse Query', () => {
       .then(() => { done() }, () => { done() });
   });
 
+
   it('can do basic queries', (done) => {
     const baz = new TestObject({ foo: 'baz' });
     const qux = new TestObject({ foo: 'qux' });
@@ -51,6 +52,119 @@ describe('Parse Query', () => {
     }).catch(done.fail);
   });
 
+  it('can do query with count', async () => {
+    const items = [];
+    for (let i = 0; i < 4; i++) {
+      items.push(new TestObject({ countMe: true }));
+    }
+    await Parse.Object.saveAll(items);
+
+    const query = new Parse.Query(TestObject);
+    query.withCount(true);
+    const {results,count} = await query.find();
+
+    assert(typeof count === 'number');
+    assert.equal(results.length, 4);
+    assert.equal(count, 4);
+    for (let i = 0; i < 4; i++) {
+      assert.equal(results[i].className,'TestObject');
+    }
+  });
+
+  it('can do query withCount set to false', async () => {
+    const items = [];
+    for (let i = 0; i < 4; i++) {
+      items.push(new TestObject({ countMe: true }));
+    }
+    await Parse.Object.saveAll(items);
+
+    const query = new Parse.Query(TestObject);
+    query.withCount(false);
+    const results = await query.find();
+
+    assert.equal(results.length, 4);
+    for (let i = 0; i < 4; i++) {
+      assert.equal(results[i].className,'TestObject');
+    }
+  });
+
+  it('can do query with count on empty collection', async () => {
+    const query = new Parse.Query(TestObject);
+    query.withCount(true);
+    const {results,count} = await query.find();
+
+    assert(typeof count == 'number');
+    assert.equal(results.length, 0);
+    assert.equal(count, 0);
+  });
+
+  it('can do query with count and limit', async () => {
+    const items = [];
+    for (let i = 0; i < 4; i++) {
+      items.push(new TestObject({ countMe: 2}));
+    }
+    await Parse.Object.saveAll(items);
+    const query = new Parse.Query(TestObject);
+    query.withCount(true);
+    query.limit(2);
+
+    const {results,count} = await query.find();
+
+    assert(typeof count == 'number');
+    assert.equal(results.length, 2);
+    assert.equal(count, 4);
+  });
+
+  it('can do query withCount and skip', async () => {
+    const items = [];
+    for (let i = 0; i < 4; i++) {
+      items.push(new TestObject({ countMe: 2}));
+    }
+    await Parse.Object.saveAll(items);
+    const query = new Parse.Query(TestObject);
+    query.withCount(true);
+    query.skip(3);
+
+    const {results,count} = await query.find();
+
+    assert(typeof count == 'number');
+    assert.equal(results.length, 1);
+    assert.equal(count, 4);
+  });
+
+  it('can do query when withCount set without arguments', async () => {
+    const items = [];
+    for (let i = 0; i < 4; i++) {
+      items.push(new TestObject({ countMe: 2}));
+    }
+    await Parse.Object.saveAll(items);
+    const query = new Parse.Query(TestObject);
+    query.withCount();
+
+    const {results,count} = await query.find();
+
+    assert(typeof count == 'number');
+    assert.equal(results.length, 4);
+    assert.equal(count, 4);
+  });
+
+  it('can do query when withCount undefined', async () => {
+    const items = [];
+    for (let i = 0; i < 4; i++) {
+      items.push(new TestObject({ countMe: 2}));
+    }
+    await Parse.Object.saveAll(items);
+    const query = new Parse.Query(TestObject);
+    let foo;
+    query.withCount(foo);
+
+    const {results,count} = await query.find();
+
+    assert(typeof count == 'number');
+    assert.equal(results.length, 4);
+    assert.equal(count, 4);
+  });
+
   it('can do containedIn queries with arrays', (done) => {
     const messageList = [];
     for (let i = 0; i < 4; i++) {
diff --git a/src/__tests__/ParseQuery-test.js b/src/__tests__/ParseQuery-test.js
index 20edcb5..0eed519 100644
--- a/src/__tests__/ParseQuery-test.js
+++ b/src/__tests__/ParseQuery-test.js
@@ -898,6 +898,24 @@ describe('ParseQuery', () => {
     });
   });
 
+  it('can set withCount flag in find query', () => {
+    const q = new ParseQuery('Item');
+    expect(q.withCount.bind(q, 'string')).toThrow(
+      'You can only set withCount to a boolean value'
+    );
+
+    q.withCount(true);
+    expect(q.toJSON()).toEqual({
+      where: {},
+      count: 1
+    });
+    q.withCount(false);
+    expect(q.toJSON()).toEqual({
+      where: {}
+    });
+  });
+
+
   it('can generate queries that include full data for pointers', () => {
     const q = new ParseQuery('Item');
     q.greaterThan('inStock', 0);
@@ -1414,6 +1432,44 @@ describe('ParseQuery', () => {
       });
   });
 
+  it('can receive both count and objects from find() using withCount flag', (done) => {
+    CoreManager.setQueryController({
+      aggregate() {},
+      find(className, params, options) {
+        expect(className).toBe('Item');
+        expect(params).toEqual({
+          where: {},
+          count: 1
+        });
+        expect(options).toEqual({
+          useMasterKey: true,
+          sessionToken: '1234'
+        });
+        return Promise.resolve({
+          results:[
+            { objectId: '1', name: 'Product 55' },
+            { objectId: '2', name: 'Product 89' } ],
+          count: 2
+        });
+      }
+    });
+
+    const q = new ParseQuery('Item');
+    q.withCount(true)
+      .find({
+        useMasterKey: true,
+        sessionToken: '1234'
+      })
+      .then((obj) => {
+        expect(obj.results).toBeDefined();
+        expect(obj.results.length).toBe(2);
+        expect(obj.count).toBeDefined();
+        expect(typeof obj.count).toBe('number');
+        done();
+      });
+  });
+
+
   it('can iterate over results with each()', (done) => {
     CoreManager.setQueryController({
       aggregate() {},
@@ -1809,6 +1865,7 @@ describe('ParseQuery', () => {
     q.include('manufacturer');
     q.select('inStock', 'lastPurchase');
     q.limit(10);
+    q.withCount(true);
     q.ascending(['a', 'b', 'c']);
     q.skip(4);
     q.equalTo('size', 'medium');
@@ -1823,6 +1880,7 @@ describe('ParseQuery', () => {
       include: 'manufacturer',
       keys: 'inStock,lastPurchase',
       limit: 10,
+      count: 1,
       order: 'a,b,c',
       skip: 4,
       where: {
@@ -2670,6 +2728,98 @@ describe('ParseQuery LocalDatastore', () => {
     expect(results.length).toEqual(2);
   });
 
+  it('can query offline withCount, skip and limit', async () => {
+    const obj1 = {
+      className: 'Item',
+      objectId: 'objectId1',
+      password: 123,
+      number: 3,
+      string: 'a',
+    };
+
+    const obj2 = {
+      className: 'Item',
+      objectId: 'objectId2',
+      number: 1,
+      string: 'b',
+    };
+
+    const obj3 = {
+      className: 'Item',
+      objectId: 'objectId3',
+      number: 2,
+      string: 'c',
+    };
+
+    const objects = [obj1, obj2, obj3];
+    mockLocalDatastore
+      ._serializeObjectsFromPinName
+      .mockImplementation(() => objects);
+
+    mockLocalDatastore
+      .checkIfEnabled
+      .mockImplementation(() => true);
+
+    let q = new ParseQuery('Item');
+    q.skip(0);
+    q.withCount(true);
+    q.fromLocalDatastore();
+    let result = await q.find();
+    expect(result.results.length).toEqual(3);
+    expect(result.count).toEqual(3);
+
+    q = new ParseQuery('Item');
+    q.skip(1);
+    q.withCount(true);
+    q.fromLocalDatastore();
+    result = await q.find();
+    expect(result.results.length).toEqual(2);
+    expect(result.count).toEqual(3);
+
+    q = new ParseQuery('Item');
+    q.skip(3);
+    q.withCount(true);
+    q.fromLocalDatastore();
+    result = await q.find();
+    expect(result.results.length).toEqual(0);
+    expect(result.count).toEqual(3);
+
+    q = new ParseQuery('Item');
+    q.withCount(true);
+    q.skip(4);
+    q.fromLocalDatastore();
+    result = await q.find();
+    expect(result.results.length).toEqual(0);
+    expect(result.count).toEqual(3);
+
+    q = new ParseQuery('Item');
+    q.limit(1);
+    q.skip(2);
+    q.withCount(true);
+    q.fromLocalDatastore();
+    result = await q.find();
+    expect(result.results.length).toEqual(1);
+    expect(result.count).toEqual(3);
+
+    q = new ParseQuery('Item');
+    q.limit(1);
+    q.skip(1);
+    q.withCount(true);
+    q.fromLocalDatastore();
+    result = await q.find();
+    expect(result.results.length).toEqual(1);
+    expect(result.count).toEqual(3);
+
+    q = new ParseQuery('Item');
+    q.limit(2);
+    q.skip(1);
+    q.withCount(true);
+    q.fromLocalDatastore();
+    result = await q.find();
+    expect(result.results.length).toEqual(2);
+    expect(result.count).toEqual(3);
+  });
+
   it('can query offline select keys', async () => {
     const obj1 = {
       className: 'Item',