Repository: parse-community/Parse-SDK-JS
Pull request #1003 (2019-11-20 19:03:45)

Support query.cancel()
Closes: https://github.com/parse-community/Parse-SDK-JS/issues/997

Taken from Android API [cancel](http://parseplatform.org/Parse-SDK-Android/api/com/parse/ParseQuery.html#cancel--)

Can be improved in the future.

codecov[bot]: # [Codecov](https://codecov.io/gh/parse-community/Parse-SDK-JS/pull/1003?src=pr&el=h1) Report
> Merging [#1003](https://codecov.io/gh/parse-community/Parse-SDK-JS/pull/1003?src=pr&el=desc) into [master](https://codecov.io/gh/parse-community/Parse-SDK-JS/commit/a9c3000feb43d01e6735ab79214dbdd529ec7bf1?src=pr&el=desc) will **decrease** coverage by `0.1%`.
> The diff coverage is `33.33%`.

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

```diff
@@            Coverage Diff             @@
##           master    #1003      +/-   ##
==========================================
- Coverage   91.91%   91.81%   -0.11%     
==========================================
  Files          53       53              
  Lines        5094     5104      +10     
  Branches     1145     1148       +3     
==========================================
+ Hits         4682     4686       +4     
- Misses        412      418       +6
```


| [Impacted Files](https://codecov.io/gh/parse-community/Parse-SDK-JS/pull/1003?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [src/ParseQuery.js](https://codecov.io/gh/parse-community/Parse-SDK-JS/pull/1003/diff?src=pr&el=tree#diff-c3JjL1BhcnNlUXVlcnkuanM=) | `95.09% <33.33%> (-0.92%)` | :arrow_down: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/parse-community/Parse-SDK-JS/pull/1003?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/1003?src=pr&el=footer). Last update [a9c3000...0d61e10](https://codecov.io/gh/parse-community/Parse-SDK-JS/pull/1003?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).

dplewis: @acinader @davimacedo This is hard to test in a node environment as the node-xmlhttprequest package (hasn't been published in 4 years) is lacking proper support for abort.

It should work in browser though.
dplewis: @acinader @davimacedo I updated my comment. Thoughts?

Patch:
diff --git a/src/ParseQuery.js b/src/ParseQuery.js
index 0391b2a..b84407d 100644
--- a/src/ParseQuery.js
+++ b/src/ParseQuery.js
@@ -232,6 +232,7 @@ class ParseQuery {
   _queriesLocalDatastore: boolean;
   _localDatastorePinName: any;
   _extraOptions: { [key: string]: mixed };
+  _xhrRequest: any;
 
   /**
    * @param {(String|Parse.Object)} objectClass An instance of a subclass of Parse.Object, or a Parse className string.
@@ -270,6 +271,10 @@ class ParseQuery {
     this._queriesLocalDatastore = false;
     this._localDatastorePinName = null;
     this._extraOptions = {};
+    this._xhrRequest = {
+      task: null,
+      onchange: () => {},
+    }
   }
 
   /**
@@ -596,6 +601,7 @@ class ParseQuery {
     if (options.hasOwnProperty('sessionToken')) {
       findOptions.sessionToken = options.sessionToken;
     }
+    this._setRequestTask(findOptions);
 
     const controller = CoreManager.getQueryController();
 
@@ -664,6 +670,7 @@ class ParseQuery {
     if (options.hasOwnProperty('sessionToken')) {
       findOptions.sessionToken = options.sessionToken;
     }
+    this._setRequestTask(findOptions);
 
     const controller = CoreManager.getQueryController();
 
@@ -701,12 +708,13 @@ class ParseQuery {
     if (options.hasOwnProperty('sessionToken')) {
       distinctOptions.sessionToken = options.sessionToken;
     }
+    this._setRequestTask(distinctOptions);
+
     const controller = CoreManager.getQueryController();
     const params = {
       distinct: key,
       where: this._where
     };
-
     return controller.aggregate(
       this.className,
       params,
@@ -736,6 +744,8 @@ class ParseQuery {
     if (options.hasOwnProperty('sessionToken')) {
       aggregateOptions.sessionToken = options.sessionToken;
     }
+    this._setRequestTask(aggregateOptions);
+
     const controller = CoreManager.getQueryController();
 
     if (!Array.isArray(pipeline) && typeof pipeline !== 'object') {
@@ -779,6 +789,7 @@ class ParseQuery {
     if (options.hasOwnProperty('sessionToken')) {
       findOptions.sessionToken = options.sessionToken;
     }
+    this._setRequestTask(findOptions);
 
     const controller = CoreManager.getQueryController();
 
@@ -1817,6 +1828,27 @@ class ParseQuery {
     }
     return this;
   }
+
+  /**
+   * Cancels the current network request (if any is running).
+   * Note: Support varies based on xmlhttprequest module used. (Will support browser)
+   *
+   * @return {Parse.Query} Returns the query, so you can chain this call.
+   */
+  cancel(): ParseQuery {
+    if (this._xhrRequest.task && typeof this._xhrRequest.task.abort === 'function') {
+      this._xhrRequest.task.abort();
+    }
+    this._xhrRequest.task = null;
+    return this;
+  }
+
+  _setRequestTask(options) {
+    options.requestTask = (task) => {
+      this._xhrRequest.task = task;
+      this._xhrRequest.onchange();
+    };
+  }
 }
 
 const DefaultController = {


Test patch:
diff --git a/src/__tests__/ParseQuery-test.js b/src/__tests__/ParseQuery-test.js
index 5038ce3..4452ec9 100644
--- a/src/__tests__/ParseQuery-test.js
+++ b/src/__tests__/ParseQuery-test.js
@@ -1140,7 +1140,7 @@ describe('ParseQuery', () => {
             size: 'small'
           }
         });
-        expect(options).toEqual({});
+        expect(options.requestTask).toBeDefined();
         return Promise.resolve({
           results: [
             { objectId: 'I1', size: 'small', name: 'Product 3' }
@@ -1173,10 +1173,8 @@ describe('ParseQuery', () => {
             size: 'small'
           }
         });
-        expect(options).toEqual({
-          useMasterKey: true,
-          sessionToken: '1234'
-        });
+        expect(options.useMasterKey).toEqual(true);
+        expect(options.sessionToken).toEqual('1234');
         return Promise.resolve({
           results: []
         });
@@ -1204,7 +1202,7 @@ describe('ParseQuery', () => {
             objectId: 'I27'
           }
         });
-        expect(options).toEqual({});
+        expect(options.requestTask).toBeDefined();
         return Promise.resolve({
           results: [
             { objectId: 'I27', size: 'large', name: 'Product 27' }
@@ -1237,7 +1235,7 @@ describe('ParseQuery', () => {
             objectId: 'I28'
           }
         });
-        expect(options).toEqual({});
+        expect(options.requestTask).toBeDefined();
         return Promise.resolve({
           results: []
         });
@@ -1267,10 +1265,8 @@ describe('ParseQuery', () => {
             objectId: 'I27'
           }
         });
-        expect(options).toEqual({
-          useMasterKey: true,
-          sessionToken: '1234'
-        });
+        expect(options.useMasterKey).toEqual(true);
+        expect(options.sessionToken).toEqual('1234');
         return Promise.resolve({
           results: [
             { objectId: 'I27', size: 'large', name: 'Product 27' }
@@ -1300,7 +1296,7 @@ describe('ParseQuery', () => {
             size: 'small'
           }
         });
-        expect(options).toEqual({});
+        expect(options.requestTask).toBeDefined();
         return Promise.resolve({
           results: [],
           count: 145
@@ -1327,10 +1323,8 @@ describe('ParseQuery', () => {
             size: 'small'
           }
         });
-        expect(options).toEqual({
-          useMasterKey: true,
-          sessionToken: '1234'
-        });
+        expect(options.useMasterKey).toEqual(true);
+        expect(options.sessionToken).toEqual('1234');
         return Promise.resolve({
           results: [],
           count: 145
@@ -1368,7 +1362,7 @@ describe('ParseQuery', () => {
           includeReadPreference: 'SECONDARY',
           subqueryReadPreference: 'SECONDARY_PREFERRED',
         });
-        expect(options).toEqual({});
+        expect(options.requestTask).toBeDefined();
         return Promise.resolve({
           results: [
             { objectId: 'I55', size: 'medium', name: 'Product 55' },
@@ -1414,10 +1408,8 @@ describe('ParseQuery', () => {
             }
           }
         });
-        expect(options).toEqual({
-          useMasterKey: true,
-          sessionToken: '1234'
-        });
+        expect(options.useMasterKey).toEqual(true);
+        expect(options.sessionToken).toEqual('1234');
         return Promise.resolve({
           results: []
         });
@@ -1445,10 +1437,8 @@ describe('ParseQuery', () => {
           where: {},
           count: 1
         });
-        expect(options).toEqual({
-          useMasterKey: true,
-          sessionToken: '1234'
-        });
+        expect(options.useMasterKey).toEqual(true);
+        expect(options.sessionToken).toEqual('1234');
         return Promise.resolve({
           results:[
             { objectId: '1', name: 'Product 55' },
@@ -1502,7 +1492,7 @@ describe('ParseQuery', () => {
             valid: true
           }
         });
-        expect(options).toEqual({});
+        expect(options.requestTask).toBeDefined();
         return Promise.resolve({
           results: [
             { objectId: 'I55', size: 'medium', name: 'Product 55' },
@@ -1549,10 +1539,8 @@ describe('ParseQuery', () => {
             valid: true
           }
         });
-        expect(options).toEqual({
-          useMasterKey: true,
-          sessionToken: '1234'
-        });
+        expect(options.useMasterKey).toEqual(true);
+        expect(options.sessionToken).toEqual('1234');
         return Promise.resolve({
           results: [
             { objectId: 'I55', size: 'medium', name: 'Product 55' },
@@ -1964,7 +1952,8 @@ describe('ParseQuery', () => {
             size: 'small'
           }
         });
-        expect(options).toEqual({ useMasterKey: true });
+        expect(options.useMasterKey).toEqual(true);
+        expect(options.requestTask).toBeDefined();
         return Promise.resolve({
           results: ['L'],
         });
@@ -1989,10 +1978,9 @@ describe('ParseQuery', () => {
             size: 'small'
           }
         });
-        expect(options).toEqual({
-          useMasterKey: true,
-          sessionToken: '1234'
-        });
+        expect(options.useMasterKey).toEqual(true);
+        expect(options.sessionToken).toEqual('1234');
+        expect(options.requestTask).toBeDefined();
         return Promise.resolve({
           results: ['L']
         });
@@ -2020,7 +2008,8 @@ describe('ParseQuery', () => {
         expect(params).toEqual({
           pipeline: [{ group: { objectId: '$name' } }]
         });
-        expect(options).toEqual({ useMasterKey: true });
+        expect(options.useMasterKey).toEqual(true);
+        expect(options.requestTask).toBeDefined();
         return Promise.resolve({
           results: [],
         });
@@ -2045,7 +2034,8 @@ describe('ParseQuery', () => {
         expect(params).toEqual({
           pipeline: { group: { objectId: '$name' } }
         });
-        expect(options).toEqual({ useMasterKey: true });
+        expect(options.useMasterKey).toEqual(true);
+        expect(options.requestTask).toBeDefined();
         return Promise.resolve({
           results: [],
         });
@@ -2068,7 +2058,8 @@ describe('ParseQuery', () => {
         expect(params).toEqual({
           group: { objectId: '$name' }
         });
-        expect(options).toEqual({ useMasterKey: true });
+        expect(options.useMasterKey).toEqual(true);
+        expect(options.requestTask).toBeDefined();
         return Promise.resolve({
           results: [],
         });
@@ -2094,10 +2085,8 @@ describe('ParseQuery', () => {
         expect(params).toEqual({
           pipeline: [{ group: { objectId: '$name' } }]
         });
-        expect(options).toEqual({
-          useMasterKey: true,
-          sessionToken: '1234'
-        });
+        expect(options.useMasterKey).toEqual(true);
+        expect(options.sessionToken).toEqual('1234');
         return Promise.resolve({
           results: []
         });
@@ -2113,6 +2102,33 @@ describe('ParseQuery', () => {
     });
   });
 
+  it('can cancel query', async () => {
+    const mockRequestTask = {
+      abort: () => {},
+    };
+
+    CoreManager.setQueryController({
+      find: function(name, params, options) {
+        options.requestTask(mockRequestTask);
+        return Promise.resolve({
+          results: []
+        });
+      },
+      aggregate: () => {},
+    });
+    const query = new ParseQuery('TestCancel');
+
+    jest.spyOn(mockRequestTask, 'abort');
+    query.cancel();
+    expect(mockRequestTask.abort).toHaveBeenCalledTimes(0);
+
+    await query.find();
+
+    expect(query._xhrRequest.task).toEqual(mockRequestTask);
+    query.cancel();
+    expect(mockRequestTask.abort).toHaveBeenCalledTimes(1);
+  });
+
   it('selecting sub-objects does not inject objects when sub-object does not exist', (done) => {
     jest.dontMock("../ParseObject");
     jest.resetModules();