diff --git a/integration/test/ParseLocalDatastoreTest.js b/integration/test/ParseLocalDatastoreTest.js index 4d7251cde..9b01139b5 100644 --- a/integration/test/ParseLocalDatastoreTest.js +++ b/integration/test/ParseLocalDatastoreTest.js @@ -802,6 +802,29 @@ function runTest(controller) { assert.equal(localDatastore[`Item_${fetchedItems[0].id}`].foo, 'changed'); assert.equal(localDatastore[`Item_${fetchedItems[1].id}`].foo, 'changed'); }); + + it(`${controller.name} can update Local Datastore from network`, async () => { + const parent = new TestObject(); + const child = new Item(); + const grandchild = new Item(); + child.set('grandchild', grandchild); + parent.set('field', 'test'); + parent.set('child', child); + await Parse.Object.saveAll([parent, child, grandchild]); + await parent.pin(); + + // Updates child with { foo: 'changed' } + const params = { id: child.id }; + await Parse.Cloud.run('TestFetchFromLocalDatastore', params); + + Parse.LocalDatastore.isSyncing = false; + + await Parse.LocalDatastore.updateFromServer(); + + const updatedLDS = await Parse.LocalDatastore._getAllContents(); + const childJSON = updatedLDS[`${child.className}_${child.id}`]; + assert.equal(childJSON.foo, 'changed'); + }); }); describe(`Parse Query Pinning (${controller.name})`, () => { @@ -2344,7 +2367,7 @@ function runTest(controller) { }); }); - it('supports withinPolygon', async () => { + it(`${controller.name} supports withinPolygon`, async () => { const sacramento = new TestObject(); sacramento.set('location', new Parse.GeoPoint(38.52, -121.50)); sacramento.set('name', 'Sacramento'); @@ -2373,7 +2396,7 @@ function runTest(controller) { assert.equal(results.length, 1); }); - it('supports polygonContains', async () => { + it(`${controller.name} supports polygonContains`, async () => { const p1 = [[0,0], [0,1], [1,1], [1,0]]; const p2 = [[0,0], [0,2], [2,2], [2,0]]; const p3 = [[10,10], [10,15], [15,15], [15,10], [10,10]]; diff --git a/src/LocalDatastore.js b/src/LocalDatastore.js index 3b34c1a66..3c6e3a153 100644 --- a/src/LocalDatastore.js +++ b/src/LocalDatastore.js @@ -12,6 +12,7 @@ import CoreManager from './CoreManager'; import type ParseObject from './ParseObject'; +import ParseQuery from './ParseQuery'; const DEFAULT_PIN = '_default'; const PIN_PREFIX = 'parsePin_'; @@ -259,6 +260,65 @@ const LocalDatastore = { } }, + /** + * Updates Local Datastore from Server + * + *
+   * await Parse.LocalDatastore.updateFromServer();
+   * 
+ * + * @static + */ + async updateFromServer() { + if (!this.checkIfEnabled() || this.isSyncing) { + return; + } + const localDatastore = await this._getAllContents(); + const keys = []; + for (const key in localDatastore) { + if (key !== DEFAULT_PIN && !key.startsWith(PIN_PREFIX)) { + keys.push(key); + } + } + if (keys.length === 0) { + return; + } + this.isSyncing = true; + const pointersHash = {}; + for (const key of keys) { + const [className, objectId] = key.split('_'); + if (!(className in pointersHash)) { + pointersHash[className] = new Set(); + } + pointersHash[className].add(objectId); + } + const queryPromises = Object.keys(pointersHash).map(className => { + const objectIds = Array.from(pointersHash[className]); + const query = new ParseQuery(className); + query.limit(objectIds.length); + if (objectIds.length === 1) { + query.equalTo('objectId', objectIds[0]); + } else { + query.containedIn('objectId', objectIds); + } + return query.find(); + }); + try { + const responses = await Promise.all(queryPromises); + const objects = [].concat.apply([], responses); + const pinPromises = objects.map((object) => { + const objectKey = this.getKeyForObject(object); + return this.pinWithName(objectKey, object._toFullJSON()); + }); + await Promise.all(pinPromises); + this.isSyncing = false; + } catch(error) { + console.log('Error syncing LocalDatastore'); // eslint-disable-line + console.log(error); // eslint-disable-line + this.isSyncing = false; + } + }, + getKeyForObject(object: any) { const objectId = object.objectId || object._getId(); return `${object.className}_${objectId}`; @@ -282,6 +342,7 @@ const LocalDatastore = { LocalDatastore.DEFAULT_PIN = DEFAULT_PIN; LocalDatastore.PIN_PREFIX = PIN_PREFIX; LocalDatastore.isEnabled = false; +LocalDatastore.isSyncing = false; module.exports = LocalDatastore; diff --git a/src/__tests__/LocalDatastore-test.js b/src/__tests__/LocalDatastore-test.js index 8ca859e62..debdbf912 100644 --- a/src/__tests__/LocalDatastore-test.js +++ b/src/__tests__/LocalDatastore-test.js @@ -82,9 +82,21 @@ const mockLocalStorageController = { clear: jest.fn(), }; jest.setMock('../ParseObject', MockObject); + +const mockQueryFind = jest.fn(); +jest.mock('../ParseQuery', () => { + return jest.fn().mockImplementation(function () { + this.equalTo = jest.fn(); + this.containedIn = jest.fn(); + this.limit = jest.fn(); + this.find = mockQueryFind; + }); +}); + const CoreManager = require('../CoreManager'); const LocalDatastore = require('../LocalDatastore'); const ParseObject = require('../ParseObject'); +const ParseQuery = require('../ParseQuery'); const RNDatastoreController = require('../LocalDatastoreController.react-native'); const BrowserDatastoreController = require('../LocalDatastoreController.browser'); const DefaultDatastoreController = require('../LocalDatastoreController.default'); @@ -572,6 +584,139 @@ describe('LocalDatastore', () => { LocalDatastore._traverse(object, encountered); expect(encountered).toEqual({ 'Item_1234': object }); }); + + it('do not sync if disabled', async () => { + LocalDatastore.isEnabled = false; + jest.spyOn(mockLocalStorageController, 'getAllContents'); + + await LocalDatastore.updateFromServer(); + expect(LocalDatastore.isSyncing).toBe(false); + expect(mockLocalStorageController.getAllContents).toHaveBeenCalledTimes(0); + }); + + it('do not sync if syncing', async () => { + LocalDatastore.isEnabled = true; + LocalDatastore.isSyncing = true; + + jest.spyOn(mockLocalStorageController, 'getAllContents'); + await LocalDatastore.updateFromServer(); + + expect(LocalDatastore.isSyncing).toBe(true); + expect(mockLocalStorageController.getAllContents).toHaveBeenCalledTimes(0); + }); + + it('updateFromServer empty LDS', async () => { + LocalDatastore.isEnabled = true; + LocalDatastore.isSyncing = false; + const LDS = {}; + + mockLocalStorageController + .getAllContents + .mockImplementationOnce(() => LDS); + + jest.spyOn(mockLocalStorageController, 'pinWithName'); + await LocalDatastore.updateFromServer(); + + expect(mockLocalStorageController.pinWithName).toHaveBeenCalledTimes(0); + }); + + it('updateFromServer on one object', async () => { + LocalDatastore.isEnabled = true; + LocalDatastore.isSyncing = false; + const object = new ParseObject('Item'); + const LDS = { + [`Item_${object.id}`]: object._toFullJSON(), + [`${LocalDatastore.PIN_PREFIX}_testPinName`]: [`Item_${object.id}`], + [LocalDatastore.DEFAULT_PIN]: [`Item_${object.id}`], + }; + + mockLocalStorageController + .getAllContents + .mockImplementationOnce(() => LDS); + + object.set('updatedField', 'foo'); + mockQueryFind.mockImplementationOnce(() => Promise.resolve([object])); + + await LocalDatastore.updateFromServer(); + + expect(mockLocalStorageController.getAllContents).toHaveBeenCalledTimes(1); + expect(ParseQuery).toHaveBeenCalledTimes(1); + const mockQueryInstance = ParseQuery.mock.instances[0]; + + expect(mockQueryInstance.equalTo.mock.calls.length).toBe(1); + expect(mockQueryFind).toHaveBeenCalledTimes(1); + expect(mockLocalStorageController.pinWithName).toHaveBeenCalledTimes(1); + }); + + it('updateFromServer handle error', async () => { + LocalDatastore.isEnabled = true; + LocalDatastore.isSyncing = false; + const object = new ParseObject('Item'); + const LDS = { + [`Item_${object.id}`]: object._toFullJSON(), + [`${LocalDatastore.PIN_PREFIX}_testPinName`]: [`Item_${object.id}`], + [LocalDatastore.DEFAULT_PIN]: [`Item_${object.id}`], + }; + + mockLocalStorageController + .getAllContents + .mockImplementationOnce(() => LDS); + + object.set('updatedField', 'foo'); + mockQueryFind.mockImplementationOnce(() => { + expect(LocalDatastore.isSyncing).toBe(true); + return Promise.reject('Unable to connect to the Parse API') + }); + + jest.spyOn(console, 'log'); + await LocalDatastore.updateFromServer(); + + expect(mockLocalStorageController.getAllContents).toHaveBeenCalledTimes(1); + expect(ParseQuery).toHaveBeenCalledTimes(1); + const mockQueryInstance = ParseQuery.mock.instances[0]; + + expect(mockQueryInstance.equalTo.mock.calls.length).toBe(1); + expect(mockQueryFind).toHaveBeenCalledTimes(1); + expect(mockLocalStorageController.pinWithName).toHaveBeenCalledTimes(0); + expect(console.log).toHaveBeenCalledTimes(2); + expect(LocalDatastore.isSyncing).toBe(false); + }); + + it('updateFromServer on mixed object', async () => { + LocalDatastore.isEnabled = true; + LocalDatastore.isSyncing = false; + const obj1 = new ParseObject('Item'); + const obj2 = new ParseObject('Item'); + const obj3 = new ParseObject('TestObject'); + const LDS = { + [`Item_${obj1.id}`]: obj1._toFullJSON(), + [`Item_${obj2.id}`]: obj2._toFullJSON(), + [`TestObject_${obj3.id}`]: obj3._toFullJSON(), + [`${LocalDatastore.PIN_PREFIX}_testPinName`]: [`Item_${obj1.id}`], + [LocalDatastore.DEFAULT_PIN]: [`Item_${obj1.id}`], + }; + + mockLocalStorageController + .getAllContents + .mockImplementationOnce(() => LDS); + + mockQueryFind + .mockImplementationOnce(() => Promise.resolve([obj1, obj2])) + .mockImplementationOnce(() => Promise.resolve([obj3])); + + await LocalDatastore.updateFromServer(); + + expect(mockLocalStorageController.getAllContents).toHaveBeenCalledTimes(1); + expect(ParseQuery).toHaveBeenCalledTimes(2); + + const mockQueryInstance1 = ParseQuery.mock.instances[0]; + const mockQueryInstance2 = ParseQuery.mock.instances[1]; + + expect(mockQueryInstance1.containedIn.mock.calls.length).toBe(1); + expect(mockQueryInstance2.equalTo.mock.calls.length).toBe(1); + expect(mockQueryFind).toHaveBeenCalledTimes(2); + expect(mockLocalStorageController.pinWithName).toHaveBeenCalledTimes(3); + }); }); describe('BrowserDatastoreController', async () => { diff --git a/src/__tests__/ParseObject-test.js b/src/__tests__/ParseObject-test.js index b4232fbb4..891d988fd 100644 --- a/src/__tests__/ParseObject-test.js +++ b/src/__tests__/ParseObject-test.js @@ -119,6 +119,7 @@ const mockLocalDatastore = { _updateObjectIfPinned: jest.fn(), _destroyObjectIfPinned: jest.fn(), _updateLocalIdForObject: jest.fn(), + updateFromServer: jest.fn(), _clear: jest.fn(), getKeyForObject: jest.fn(), checkIfEnabled: jest.fn(() => {