Repository: parse-community/Parse-SDK-JS
Pull request #1225 (2020-09-12 23:36:29)

fix(LocalDatastore): Allow Pin of unsaved Parse.Object
Closes: https://github.com/parse-community/Parse-SDK-JS/issues/1118

Let me know if more tests are needed. Can be improved in the future.

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

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

```diff
@@            Coverage Diff             @@
##           master    #1225      +/-   ##
==========================================
- Coverage   92.36%   92.28%   -0.09%     
==========================================
  Files          54       54              
  Lines        5307     5313       +6     
  Branches     1195     1197       +2     
==========================================
+ Hits         4902     4903       +1     
- Misses        405      410       +5     
```


| [Impacted Files](https://codecov.io/gh/parse-community/Parse-SDK-JS/pull/1225?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [src/ParseObject.js](https://codecov.io/gh/parse-community/Parse-SDK-JS/pull/1225/diff?src=pr&el=tree#diff-c3JjL1BhcnNlT2JqZWN0Lmpz) | `90.07% <55.55%> (-0.40%)` | :arrow_down: |
| [src/encode.js](https://codecov.io/gh/parse-community/Parse-SDK-JS/pull/1225/diff?src=pr&el=tree#diff-c3JjL2VuY29kZS5qcw==) | `97.72% <85.71%> (-2.28%)` | :arrow_down: |
| [src/LocalDatastore.js](https://codecov.io/gh/parse-community/Parse-SDK-JS/pull/1225/diff?src=pr&el=tree#diff-c3JjL0xvY2FsRGF0YXN0b3JlLmpz) | `99.13% <100.00%> (ø)` | |
| [src/ParseOp.js](https://codecov.io/gh/parse-community/Parse-SDK-JS/pull/1225/diff?src=pr&el=tree#diff-c3JjL1BhcnNlT3AuanM=) | `82.07% <100.00%> (ø)` | |

------

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


Patch:
diff --git a/src/LocalDatastore.js b/src/LocalDatastore.js
index 6e64237..aaa0262 100644
--- a/src/LocalDatastore.js
+++ b/src/LocalDatastore.js
@@ -79,7 +79,7 @@ const LocalDatastore = {
     for (const parent of objects) {
       const children = this._getChildren(parent);
       const parentKey = this.getKeyForObject(parent);
-      const json = parent._toFullJSON();
+      const json = parent._toFullJSON(undefined, true);
       if (parent._localId) {
         json._localId = parent._localId;
       }
@@ -139,7 +139,7 @@ const LocalDatastore = {
   // Retrieve all pointer fields from object recursively
   _getChildren(object: ParseObject) {
     const encountered = {};
-    const json = object._toFullJSON();
+    const json = object._toFullJSON(undefined, true);
     for (const key in json) {
       if (json[key] && json[key].__type && json[key].__type === 'Object') {
         this._traverse(json[key], encountered);
diff --git a/src/ParseObject.js b/src/ParseObject.js
index 3ae07cc..7580040 100644
--- a/src/ParseObject.js
+++ b/src/ParseObject.js
@@ -273,8 +273,8 @@ class ParseObject {
     return dirty;
   }
 
-  _toFullJSON(seen?: Array<any>): AttributeMap {
-    const json: { [key: string]: mixed } = this.toJSON(seen);
+  _toFullJSON(seen?: Array<any>, offline?: boolean): AttributeMap {
+    const json: { [key: string]: mixed } = this.toJSON(seen, offline);
     json.__type = 'Object';
     json.className = this.className;
     return json;
@@ -434,7 +434,7 @@ class ParseObject {
    * Returns a JSON version of the object suitable for saving to Parse.
    * @return {Object}
    */
-  toJSON(seen: Array<any> | void): AttributeMap {
+  toJSON(seen: Array<any> | void, offline?: boolean): AttributeMap {
     const seenEntry = this.id ? this.className + ':' + this.id : this;
     seen = seen || [seenEntry];
     const json = {};
@@ -443,12 +443,12 @@ class ParseObject {
       if ((attr === 'createdAt' || attr === 'updatedAt') && attrs[attr].toJSON) {
         json[attr] = attrs[attr].toJSON();
       } else {
-        json[attr] = encode(attrs[attr], false, false, seen);
+        json[attr] = encode(attrs[attr], false, false, seen, offline);
       }
     }
     const pending = this._getPendingOps();
     for (const attr in pending[0]) {
-      json[attr] = pending[0][attr].toJSON();
+      json[attr] = pending[0][attr].toJSON(offline);
     }
 
     if (this.id) {
@@ -550,6 +550,21 @@ class ParseObject {
     };
   }
 
+  /**
+   * Gets a Pointer referencing this Object.
+   * @return {Pointer}
+   */
+  toOfflinePointer(): Pointer {
+    if (!this._localId) {
+      throw new Error('Cannot create a offline pointer to a saved ParseObject');
+    }
+    return {
+      __type: 'Object',
+      className: this.className,
+      _localId: this._localId
+    };
+  }
+
   /**
    * Gets the value of an attribute.
    * @param {String} attr The string name of an attribute.
diff --git a/src/ParseOp.js b/src/ParseOp.js
index 45454a8..87ca744 100644
--- a/src/ParseOp.js
+++ b/src/ParseOp.js
@@ -84,8 +84,8 @@ export class SetOp extends Op {
     return new SetOp(this._value);
   }
 
-  toJSON() {
-    return encode(this._value, false, true);
+  toJSON(offline?: boolean) {
+    return encode(this._value, false, true, undefined, offline);
   }
 }
 
diff --git a/src/encode.js b/src/encode.js
index ea022e6..2067789 100644
--- a/src/encode.js
+++ b/src/encode.js
@@ -19,7 +19,7 @@ import ParseRelation from './ParseRelation';
 
 const toString = Object.prototype.toString;
 
-function encode(value: mixed, disallowObjects: boolean, forcePointers: boolean, seen: Array<mixed>): any {
+function encode(value: mixed, disallowObjects: boolean, forcePointers: boolean, seen: Array<mixed>, offline: boolean): any {
   if (value instanceof ParseObject) {
     if (disallowObjects) {
       throw new Error('Parse Objects not allowed here');
@@ -31,10 +31,13 @@ function encode(value: mixed, disallowObjects: boolean, forcePointers: boolean,
       value.dirty() ||
       Object.keys(value._getServerData()).length < 1
     ) {
+      if (offline && value._getId().startsWith('local')) {
+        return value.toOfflinePointer();
+      }
       return value.toPointer();
     }
     seen = seen.concat(seenEntry);
-    return value._toFullJSON(seen);
+    return value._toFullJSON(seen, offline);
   }
   if (value instanceof Op ||
       value instanceof ParseACL ||
@@ -62,14 +65,14 @@ function encode(value: mixed, disallowObjects: boolean, forcePointers: boolean,
 
   if (Array.isArray(value)) {
     return value.map((v) => {
-      return encode(v, disallowObjects, forcePointers, seen);
+      return encode(v, disallowObjects, forcePointers, seen, offline);
     });
   }
 
   if (value && typeof value === 'object') {
     const output = {};
     for (const k in value) {
-      output[k] = encode(value[k], disallowObjects, forcePointers, seen);
+      output[k] = encode(value[k], disallowObjects, forcePointers, seen, offline);
     }
     return output;
   }
@@ -77,6 +80,6 @@ function encode(value: mixed, disallowObjects: boolean, forcePointers: boolean,
   return value;
 }
 
-export default function(value: mixed, disallowObjects?: boolean, forcePointers?: boolean, seen?: Array<mixed>): any {
-  return encode(value, !!disallowObjects, !!forcePointers, seen || []);
+export default function(value: mixed, disallowObjects?: boolean, forcePointers?: boolean, seen?: Array<mixed>, offline?: boolean): any {
+  return encode(value, !!disallowObjects, !!forcePointers, seen || [], offline);
 }


Test patch:
diff --git a/integration/test/ParseLocalDatastoreTest.js b/integration/test/ParseLocalDatastoreTest.js
index 29c5b29..ecfa957 100644
--- a/integration/test/ParseLocalDatastoreTest.js
+++ b/integration/test/ParseLocalDatastoreTest.js
@@ -139,15 +139,11 @@ function runTest(controller) {
       assert.equal(pinnedObject._localId, undefined);
     });
 
-    it(`${controller.name} cannot pin unsaved pointer`, async () => {
-      try {
-        const object = new TestObject();
-        const pointer = new Item();
-        object.set('child', pointer);
-        await object.pin();
-      } catch (e) {
-        assert.equal(e.message, 'Cannot create a pointer to an unsaved ParseObject');
-      }
+    it(`${controller.name} can pin unsaved pointer`, async () => {
+      const object = new TestObject();
+      const pointer = new Item();
+      object.set('child', pointer);
+      await object.pin();
     });
 
     it(`${controller.name} can pin user (unsaved)`, async () => {
@@ -2681,6 +2677,54 @@ function runTest(controller) {
       const results = await query.find();
       assert.equal(results.length, 2);
     });
+
+    it(`${controller.name} can query from pin subclass`, async () => {
+      class ClassA extends Parse.Object {
+        constructor() {
+          super('ClassA');
+        }
+        get name() { return this.get('name'); }
+        set name(value) { this.set('name', value); }
+      }
+      Parse.Object.registerSubclass('ClassA', ClassA);
+
+      class ClassB extends Parse.Object {
+        constructor() {
+          super('ClassB');
+        }
+        get name() { return this.get('name'); }
+        set name(value) { this.set('name', value); }
+
+        get classA() { return this.get('classA'); }
+        set classA(value) { this.set('classA', value); }
+      }
+      Parse.Object.registerSubclass('ClassB', ClassB);
+
+      const testClassA = new ClassA();
+      testClassA.name = 'ABC';
+      await testClassA.pin();
+
+      const query = new Parse.Query(ClassA);
+      query.fromLocalDatastore();
+      query.equalTo('name', 'ABC');
+      const result = await query.first();
+      expect(result.get('name')).toBe('ABC');
+
+      const testClassB = new ClassB();
+      testClassB.name = 'XYZ';
+      testClassB.classA = testClassA;
+      await testClassB.pin();
+
+      let localDatastore = await Parse.LocalDatastore._getAllContents();
+      expect(localDatastore[LDS_KEY(testClassB)][0].classA).toEqual(testClassA.toOfflinePointer());
+
+      await testClassB.save();
+      expect(testClassB.classA).toBe(testClassA);
+      expect(testClassA.id).toBeDefined();
+
+      localDatastore = await Parse.LocalDatastore._getAllContents();
+      expect(localDatastore[LDS_KEY(testClassB)][0].classA.objectId).toEqual(testClassA.id);
+    });
   });
 }
 
diff --git a/src/__tests__/ParseObject-test.js b/src/__tests__/ParseObject-test.js
index d487e91..47c250b 100644
--- a/src/__tests__/ParseObject-test.js
+++ b/src/__tests__/ParseObject-test.js
@@ -338,6 +338,20 @@ describe('ParseObject', () => {
     });
   });
 
+  it('can convert to a offline pointer', () => {
+    const o = new ParseObject('Item');
+    o.id = 'AnObjectId';
+    expect(function() {o.toOfflinePointer();}).toThrow(
+      'Cannot create a offline pointer to a saved ParseObject'
+    );
+    o._localId = 'local1234';
+    expect(o.toOfflinePointer()).toEqual({
+      __type: 'Object',
+      className: 'Item',
+      _localId: 'local1234'
+    });
+  });
+
   it('can test equality against another ParseObject', () => {
     const a = new ParseObject('Item');
     expect(a.equals(a)).toBe(true);
diff --git a/src/__tests__/encode-test.js b/src/__tests__/encode-test.js
index 79d37ca..5d369a1 100644
--- a/src/__tests__/encode-test.js
+++ b/src/__tests__/encode-test.js
@@ -23,17 +23,23 @@ mockObject.prototype = {
   toPointer() {
     return 'POINTER';
   },
+  toOfflinePointer() {
+    return 'OFFLINE_POINTER';
+  },
+  _getId() {
+    return 'local1234';
+  },
   dirty() {},
   toJSON() {
     return this.attributes;
   },
-  _toFullJSON(seen) {
+  _toFullJSON(seen, offline) {
     const json = {
       __type: 'Object',
       className: this.className
     };
     for (const attr in this.attributes) {
-      json[attr] = encode(this.attributes[attr], false, false, seen.concat(this));
+      json[attr] = encode(this.attributes[attr], false, false, seen.concat(this), offline);
     }
     return json;
   }
@@ -139,6 +145,28 @@ describe('encode', () => {
     });
   });
 
+  it('encodes ParseObjects offline', () => {
+    const obj = new ParseObject('Item');
+    obj._serverData = {};
+    expect(encode(obj, false, false, undefined, true)).toEqual('OFFLINE_POINTER');
+    obj._serverData = obj.attributes = {
+      str: 'string',
+      date: new Date(Date.UTC(2015, 1, 1))
+    };
+    obj.attributes.self = obj;
+
+    expect(encode(obj, false, false, undefined, true)).toEqual({
+      __type: 'Object',
+      className: 'Item',
+      str: 'string',
+      date: {
+        __type: 'Date',
+        iso: '2015-02-01T00:00:00.000Z'
+      },
+      self: 'OFFLINE_POINTER'
+    });
+  });
+
   it('does not encode ParseObjects when they are disallowed', () => {
     const obj = new ParseObject('Item');
     expect(encode.bind(null, obj, true)).toThrow(