diff --git a/src/ParseQuery.js b/src/ParseQuery.js index 9904cd232..78c148853 100644 --- a/src/ParseQuery.js +++ b/src/ParseQuery.js @@ -44,6 +44,70 @@ function quote(s: string) { return '\\Q' + s.replace('\\E', '\\E\\\\E\\Q') + '\\E'; } +/** + * Handles pre-populating the result data of a query with select fields, + * making sure that the data object contains keys for all objects that have + * been requested with a select, so that our cached state updates correctly. + */ +function handleSelectResult(data: any, select: Array){ + var serverDataMask = {}; + + select.forEach((field) => { + let hasSubObjectSelect = field.indexOf(".") !== -1; + if (!hasSubObjectSelect && !data.hasOwnProperty(field)){ + // this field was selected, but is missing from the retrieved data + data[field] = undefined + } else if (hasSubObjectSelect) { + // this field references a sub-object, + // so we need to walk down the path components + let pathComponents = field.split("."); + var obj = data; + var serverMask = serverDataMask; + + pathComponents.forEach((component, index, arr) => { + // add keys if the expected data is missing + if (!obj[component]) { + obj[component] = (index == arr.length-1) ? undefined : {}; + } + obj = obj[component]; + + //add this path component to the server mask so we can fill it in later if needed + if (index < arr.length-1) { + if (!serverMask[component]) { + serverMask[component] = {}; + } + } + }); + } + }); + + if (Object.keys(serverDataMask).length > 0) { + // When selecting from sub-objects, we don't want to blow away the missing + // information that we may have retrieved before. We've already added any + // missing selected keys to sub-objects, but we still need to add in the + // data for any previously retrieved sub-objects that were not selected. + + let serverData = CoreManager.getObjectStateController().getServerData({id:data.objectId, className:data.className}); + + function copyMissingDataWithMask(src, dest, mask, copyThisLevel){ + //copy missing elements at this level + if (copyThisLevel) { + for (var key in src) { + if (src.hasOwnProperty(key) && !dest.hasOwnProperty(key)) { + dest[key] = src[key] + } + } + } + for (var key in mask) { + //traverse into objects as needed + copyMissingDataWithMask(src[key], dest[key], mask[key], true); + } + } + + copyMissingDataWithMask(serverData, data, serverDataMask, false); + } +} + /** * Creates a new parse Parse.Query for the given Parse.Object subclass. * @class Parse.Query @@ -273,6 +337,8 @@ export default class ParseQuery { let controller = CoreManager.getQueryController(); + let select = this._select; + return controller.find( this.className, this.toJSON(), @@ -285,7 +351,15 @@ export default class ParseQuery { if (!data.className) { data.className = override; } - return ParseObject.fromJSON(data, true); + + // Make sure the data object contains keys for all objects that + // have been requested with a select, so that our cached state + // updates correctly. + if (select) { + handleSelectResult(data, select); + } + + return ParseObject.fromJSON(data, !select); }); })._thenRunCallbacks(options); } @@ -371,6 +445,8 @@ export default class ParseQuery { var params = this.toJSON(); params.limit = 1; + var select = this._select; + return controller.find( this.className, params, @@ -383,7 +459,15 @@ export default class ParseQuery { if (!objects[0].className) { objects[0].className = this.className; } - return ParseObject.fromJSON(objects[0], true); + + // Make sure the data object contains keys for all objects that + // have been requested with a select, so that our cached state + // updates correctly. + if (select) { + handleSelectResult(objects[0], select); + } + + return ParseObject.fromJSON(objects[0], !select); })._thenRunCallbacks(options); } diff --git a/src/__tests__/ParseQuery-test.js b/src/__tests__/ParseQuery-test.js index 070dc8569..c33458f1e 100644 --- a/src/__tests__/ParseQuery-test.js +++ b/src/__tests__/ParseQuery-test.js @@ -9,10 +9,14 @@ jest.dontMock('../CoreManager'); jest.dontMock('../encode'); +jest.dontMock('../decode'); jest.dontMock('../ParseError'); jest.dontMock('../ParseGeoPoint'); jest.dontMock('../ParsePromise'); jest.dontMock('../ParseQuery'); +jest.dontMock('../SingleInstanceStateController'); +jest.dontMock('../UniqueInstanceStateController'); +jest.dontMock('../ObjectStateMutations'); var mockObject = function(className) { this.className = className; @@ -1332,4 +1336,223 @@ describe('ParseQuery', () => { done(); }); }); + + + + it('overrides cached object with query results', (done) => { + jest.dontMock("../ParseObject"); + jest.resetModules(); + ParseObject = require('../ParseObject').default; + CoreManager = require('../CoreManager'); + ParseQuery = require('../ParseQuery').default; + + ParseObject.enableSingleInstance(); + + var objectToReturn = { + objectId: 'T01', + name: 'Name', + other: 'other', + className:"Thing", + createdAt: '2017-01-10T10:00:00Z' + }; + + CoreManager.setQueryController({ + find(className, params, options) { + return ParsePromise.as({ + results: [objectToReturn] + }); + } + }); + + var q = new ParseQuery("Thing"); + 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(); + }).then((results) => { + expect(results[0].get("name")).toBe("Name2"); + expect(results[0].has("other")).toBe(false); + }).then(() => { + expect(testObject.get("name")).toBe("Name2"); + expect(testObject.has("other")).toBe(false); + done(); + }); + }); + + it('does not override unselected fields with select query results', (done) => { + jest.dontMock("../ParseObject"); + jest.resetModules(); + ParseObject = require('../ParseObject').default; + CoreManager = require('../CoreManager'); + ParseQuery = require('../ParseQuery').default; + + ParseObject.enableSingleInstance(); + + 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({ + find(className, params, options) { + return ParsePromise.as({ + results: [objectToReturn] + }); + } + }); + + var q = new ParseQuery("Thing"); + 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"}}; + return q2.find(); + }).then((results) => { + expect(results[0].get("name")).toBe("Name"); //query didn't select this + expect(results[0].get("other")).toBe("other2"); //query selected and updated this + expect(results[0].has("tbd")).toBe(false); //query selected this and it wasn't returned + //sub-objects should work similarly + expect(results[0].get("subObject").key1).toBe("updatedValue"); + expect(results[0].get("subObject").key2).toBe("value2"); + expect(results[0].get("subObject").key3).toBeUndefined(); + }).then(() => { + expect(testObject.get("name")).toBe("Name"); + expect(testObject.get("other")).toBe("other2"); + 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(); + done(); + }, (error) => { + done.fail(error); + }); + }); + + it('overrides cached object with first() results', (done) => { + jest.dontMock("../ParseObject"); + jest.resetModules(); + ParseObject = require('../ParseObject').default; + CoreManager = require('../CoreManager'); + ParseQuery = require('../ParseQuery').default; + + ParseObject.enableSingleInstance(); + + var objectToReturn = { + objectId: 'T01', + name: 'Name', + other: 'other', + className:"Thing", + createdAt: '2017-01-10T10:00:00Z' + }; + + CoreManager.setQueryController({ + find(className, params, options) { + return ParsePromise.as({ + results: [objectToReturn] + }); + } + }); + + var q = new ParseQuery("Thing"); + 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(); + }).then((result) => { + expect(result.get("name")).toBe("Name2"); + expect(result.has("other")).toBe(false); + }).then(() => { + expect(testObject.get("name")).toBe("Name2"); + expect(testObject.has("other")).toBe(false); + done(); + }); + }); + + it('does not override unselected fields for first() on select query', (done) => { + jest.dontMock("../ParseObject"); + jest.resetModules(); + ParseObject = require('../ParseObject').default; + CoreManager = require('../CoreManager'); + ParseQuery = require('../ParseQuery').default; + + ParseObject.enableSingleInstance(); + + 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({ + find(className, params, options) { + return ParsePromise.as({ + results: [objectToReturn] + }); + } + }); + + var q = new ParseQuery("Thing"); + 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"}}; + return q2.first(); + }).then((result) => { + expect(result.get("name")).toBe("Name"); //query didn't select this + expect(result.get("other")).toBe("other2"); //query selected and updated this + expect(result.has("tbd")).toBe(false); //query selected this and it wasn't returned + //sub-objects should work similarly + expect(result.get("subObject").key1).toBe("updatedValue"); + expect(result.get("subObject").key2).toBe("value2"); + expect(result.get("subObject").key3).toBeUndefined(); + }).then(() => { + expect(testObject.get("name")).toBe("Name"); + expect(testObject.get("other")).toBe("other2"); + 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(); + done(); + }, (error) => { + done.fail(error); + }); + }); });