diff --git a/integration/test/ParseObjectTest.js b/integration/test/ParseObjectTest.js index 425071672..84df7e350 100644 --- a/integration/test/ParseObjectTest.js +++ b/integration/test/ParseObjectTest.js @@ -1340,6 +1340,93 @@ describe('Parse Object', () => { }); }); + it('can fetchWithInclude', async () => { + const parent = new TestObject(); + const child = new TestObject(); + child.set('field', 'isChild'); + parent.set('child', child); + await parent.save(); + + const obj1 = TestObject.createWithoutData(parent.id); + const fetchedObj1 = await obj1.fetchWithInclude('child'); + assert.equal(obj1.get('child').get('field'), 'isChild'); + assert.equal(fetchedObj1.get('child').get('field'), 'isChild'); + + const obj2 = TestObject.createWithoutData(parent.id); + const fetchedObj2 = await obj2.fetchWithInclude(['child']); + assert.equal(obj2.get('child').get('field'), 'isChild'); + assert.equal(fetchedObj2.get('child').get('field'), 'isChild'); + + const obj3 = TestObject.createWithoutData(parent.id); + const fetchedObj3 = await obj3.fetchWithInclude([ ['child'] ]); + assert.equal(obj3.get('child').get('field'), 'isChild'); + assert.equal(fetchedObj3.get('child').get('field'), 'isChild'); + }); + + it('can fetchWithInclude dot notation', async () => { + const parent = new TestObject(); + const child = new TestObject(); + const grandchild = new TestObject(); + grandchild.set('field', 'isGrandchild'); + child.set('grandchild', grandchild); + parent.set('child', child); + await Parse.Object.saveAll([parent, child, grandchild]); + + const obj1 = TestObject.createWithoutData(parent.id); + await obj1.fetchWithInclude('child.grandchild'); + assert.equal(obj1.get('child').get('grandchild').get('field'), 'isGrandchild'); + + const obj2 = TestObject.createWithoutData(parent.id); + await obj2.fetchWithInclude(['child.grandchild']); + assert.equal(obj2.get('child').get('grandchild').get('field'), 'isGrandchild'); + + const obj3 = TestObject.createWithoutData(parent.id); + await obj3.fetchWithInclude([ ['child.grandchild'] ]); + assert.equal(obj3.get('child').get('grandchild').get('field'), 'isGrandchild'); + }); + + it('can fetchAllWithInclude', async () => { + const parent = new TestObject(); + const child = new TestObject(); + child.set('field', 'isChild'); + parent.set('child', child); + await parent.save(); + + const obj1 = TestObject.createWithoutData(parent.id); + await Parse.Object.fetchAllWithInclude([obj1], 'child'); + assert.equal(obj1.get('child').get('field'), 'isChild'); + + const obj2 = TestObject.createWithoutData(parent.id); + await Parse.Object.fetchAllWithInclude([obj2], ['child']); + assert.equal(obj2.get('child').get('field'), 'isChild'); + + const obj3 = TestObject.createWithoutData(parent.id); + await Parse.Object.fetchAllWithInclude([obj3], [ ['child'] ]); + assert.equal(obj3.get('child').get('field'), 'isChild'); + }); + + it('can fetchAllWithInclude dot notation', async () => { + const parent = new TestObject(); + const child = new TestObject(); + const grandchild = new TestObject(); + grandchild.set('field', 'isGrandchild'); + child.set('grandchild', grandchild); + parent.set('child', child); + await Parse.Object.saveAll([parent, child, grandchild]); + + const obj1 = TestObject.createWithoutData(parent.id); + await Parse.Object.fetchAllWithInclude([obj1], 'child.grandchild'); + assert.equal(obj1.get('child').get('grandchild').get('field'), 'isGrandchild'); + + const obj2 = TestObject.createWithoutData(parent.id); + await Parse.Object.fetchAllWithInclude([obj2], ['child.grandchild']); + assert.equal(obj2.get('child').get('grandchild').get('field'), 'isGrandchild'); + + const obj3 = TestObject.createWithoutData(parent.id); + await Parse.Object.fetchAllWithInclude([obj3], [ ['child.grandchild'] ]); + assert.equal(obj3.get('child').get('grandchild').get('field'), 'isGrandchild'); + }); + it('fires errors when readonly attributes are changed', (done) => { let LimitedObject = Parse.Object.extend('LimitedObject'); LimitedObject.readOnlyAttributes = function() { diff --git a/integration/test/ParseUserTest.js b/integration/test/ParseUserTest.js index af976fa4c..85bc4251e 100644 --- a/integration/test/ParseUserTest.js +++ b/integration/test/ParseUserTest.js @@ -193,6 +193,55 @@ describe('Parse User', () => { }); }); + it('can fetch non-auth user with include', async () => { + Parse.User.enableUnsafeCurrentUser(); + + const child = new Parse.Object('TestObject'); + child.set('field', 'test'); + let user = new Parse.User(); + user.set('password', 'asdf'); + user.set('email', 'asdf@exxample.com'); + user.set('username', 'zxcv'); + user.set('child', child); + await user.signUp(); + + const query = new Parse.Query(Parse.User); + const userNotAuthed = await query.get(user.id); + + assert.equal(userNotAuthed.get('child').get('field'), undefined); + + const fetchedUser = await userNotAuthed.fetchWithInclude('child'); + + assert.equal(userNotAuthed.get('child').get('field'), 'test'); + assert.equal(fetchedUser.get('child').get('field'), 'test'); + }); + + it('can fetch auth user with include', async () => { + Parse.User.enableUnsafeCurrentUser(); + + const child = new Parse.Object('TestObject'); + child.set('field', 'test'); + let user = new Parse.User(); + user.set('password', 'asdf'); + user.set('email', 'asdf@exxample.com'); + user.set('username', 'zxcv'); + user.set('child', child); + await user.signUp(); + + user = await Parse.User.logIn('zxcv', 'asdf'); + + assert.equal(user.get('child').get('field'), undefined); + assert.equal(Parse.User.current().get('child').get('field'), undefined); + + const fetchedUser = await user.fetchWithInclude('child'); + const current = await Parse.User.currentAsync(); + + assert.equal(user.get('child').get('field'), 'test'); + assert.equal(current.get('child').get('field'), 'test'); + assert.equal(fetchedUser.get('child').get('field'), 'test'); + assert.equal(Parse.User.current().get('child').get('field'), 'test'); + }); + it('can store the current user', (done) => { Parse.User.enableUnsafeCurrentUser(); let user = new Parse.User(); diff --git a/src/ParseObject.js b/src/ParseObject.js index a156eae65..fbd4c4eee 100644 --- a/src/ParseObject.js +++ b/src/ParseObject.js @@ -970,6 +970,8 @@ class ParseObject { * be used for this request. *
  • sessionToken: A valid session token, used for making a request on * behalf of a specific user. + *
  • include: The name(s) of the key(s) to include. Can be a string, an array of strings, + * or an array of array of strings. * * @return {Promise} A promise that is fulfilled when the fetch * completes. @@ -983,10 +985,48 @@ class ParseObject { if (options.hasOwnProperty('sessionToken')) { fetchOptions.sessionToken = options.sessionToken; } + if (options.hasOwnProperty('include')) { + fetchOptions.include = []; + if (Array.isArray(options.include)) { + options.include.forEach((key) => { + if (Array.isArray(key)) { + fetchOptions.include = fetchOptions.include.concat(key); + } else { + fetchOptions.include.push(key); + } + }); + } else { + fetchOptions.include.push(options.include); + } + } var controller = CoreManager.getObjectController(); return controller.fetch(this, true, fetchOptions); } + /** + * Fetch the model from the server. If the server's representation of the + * model differs from its current attributes, they will be overriden. + * + * Includes nested Parse.Objects for the provided key. You can use dot + * notation to specify which fields in the included object are also fetched. + * + * @param {String|Array>} keys The name(s) of the key(s) to include. + * @param {Object} options + * Valid options are: + * @return {Promise} A promise that is fulfilled when the fetch + * completes. + */ + fetchWithInclude(keys: String|Array>, options: RequestOptions): Promise { + options = options || {}; + options.include = keys; + return this.fetch(options); + } + /** * Set a hash of model attributes, and save the model to the server. * updatedAt will be updated when the request returns. @@ -1133,9 +1173,17 @@ class ParseObject { * * @param {Array} list A list of Parse.Object. * @param {Object} options + * Valid options are:
      + *
    • useMasterKey: In Cloud Code and Node only, causes the Master Key to + * be used for this request. + *
    • sessionToken: A valid session token, used for making a request on + * behalf of a specific user. + *
    • include: The name(s) of the key(s) to include. Can be a string, an array of strings, + * or an array of array of strings. + *
    * @static */ - static fetchAll(list: Array, options) { + static fetchAll(list: Array, options: RequestOptions) { var options = options || {}; var queryOptions = {}; @@ -1145,6 +1193,20 @@ class ParseObject { if (options.hasOwnProperty('sessionToken')) { queryOptions.sessionToken = options.sessionToken; } + if (options.hasOwnProperty('include')) { + queryOptions.include = []; + if (Array.isArray(options.include)) { + options.include.forEach((key) => { + if (Array.isArray(key)) { + queryOptions.include = queryOptions.include.concat(key); + } else { + queryOptions.include.push(key); + } + }); + } else { + queryOptions.include.push(options.include); + } + } return CoreManager.getObjectController().fetch( list, true, @@ -1152,6 +1214,40 @@ class ParseObject { ); } + /** + * Fetches the given list of Parse.Object. + * + * Includes nested Parse.Objects for the provided key. You can use dot + * notation to specify which fields in the included object are also fetched. + * + * If any error is encountered, stops and calls the error handler. + * + *
    +   *   Parse.Object.fetchAllWithInclude([object1, object2, ...], [pointer1, pointer2, ...])
    +   *    .then((list) => {
    +   *      // All the objects were fetched.
    +   *    }, (error) => {
    +   *      // An error occurred while fetching one of the objects.
    +   *    });
    +   * 
    + * + * @param {Array} list A list of Parse.Object. + * @param {String|Array>} keys The name(s) of the key(s) to include. + * @param {Object} options + * Valid options are:
      + *
    • useMasterKey: In Cloud Code and Node only, causes the Master Key to + * be used for this request. + *
    • sessionToken: A valid session token, used for making a request on + * behalf of a specific user. + *
    + * @static + */ + static fetchAllWithInclude(list: Array, keys: String|Array>, options: RequestOptions) { + options = options || {}; + options.include = keys; + return ParseObject.fetchAll(list, options); + } + /** * Fetches the given list of Parse.Object if needed. * If any error is encountered, stops and calls the error handler. @@ -1570,6 +1666,9 @@ var DefaultController = { } var query = new ParseQuery(className); query.containedIn('objectId', ids); + if (options && options.include) { + query.include(options.include); + } query._limit = ids.length; return query.find(options).then((objects) => { var idMap = {}; @@ -1604,10 +1703,14 @@ var DefaultController = { }); } else { var RESTController = CoreManager.getRESTController(); + const params = {}; + if (options && options.include) { + params.include = options.include.join(); + } return RESTController.request( 'GET', 'classes/' + target.className + '/' + target._getId(), - {}, + params, options ).then((response, status, xhr) => { if (target instanceof ParseObject) { diff --git a/src/ParseUser.js b/src/ParseUser.js index 8e73e72e3..d2612e5ff 100644 --- a/src/ParseUser.js +++ b/src/ParseUser.js @@ -463,6 +463,19 @@ class ParseUser extends ParseObject { }); } + /** + * Wrap the default fetchWithInclude behavior with functionality to save to local + * storage if this is current user. + */ + fetchWithInclude(...args: Array): Promise { + return super.fetchWithInclude.apply(this, args).then(() => { + if (this.isCurrent()) { + return CoreManager.getUserController().updateUserOnDisk(this); + } + return this; + }); + } + static readOnlyAttributes() { return ['sessionToken']; } diff --git a/src/RESTController.js b/src/RESTController.js index 225dea5d6..200411e06 100644 --- a/src/RESTController.js +++ b/src/RESTController.js @@ -17,6 +17,7 @@ export type RequestOptions = { useMasterKey?: boolean; sessionToken?: string; installationId?: string; + include?: any; }; export type FullOptions = { diff --git a/src/__tests__/ParseObject-test.js b/src/__tests__/ParseObject-test.js index 9665bebd2..f4704f971 100644 --- a/src/__tests__/ParseObject-test.js +++ b/src/__tests__/ParseObject-test.js @@ -57,6 +57,11 @@ mockQuery.prototype.containedIn = function(field, ids) { })); }); }; + +mockQuery.prototype.include = function(keys) { + this._include = keys; +}; + mockQuery.prototype.find = function() { return Promise.resolve(this.results); }; @@ -848,6 +853,64 @@ describe('ParseObject', () => { expect(o.op('count')).toBe(undefined); }); + it('can fetchWithInclude', async () => { + const objectController = CoreManager.getObjectController(); + const spy = jest.spyOn( + objectController, + 'fetch' + ) + .mockImplementationOnce((target, forceFetch, options) => {}) + .mockImplementationOnce((target, forceFetch, options) => {}) + .mockImplementationOnce((target, forceFetch, options) => {}); + + const parent = new ParseObject('Person'); + await parent.fetchWithInclude('child', { useMasterKey: true, sessionToken: '123'}); + await parent.fetchWithInclude(['child']); + await parent.fetchWithInclude([['child']]); + expect(objectController.fetch).toHaveBeenCalledTimes(3); + + expect(objectController.fetch.mock.calls[0]).toEqual([ + parent, true, { useMasterKey: true, sessionToken: '123', include: ['child'] } + ]); + expect(objectController.fetch.mock.calls[1]).toEqual([ + parent, true, { include: ['child'] } + ]); + expect(objectController.fetch.mock.calls[2]).toEqual([ + parent, true, { include: ['child'] } + ]); + + spy.mockRestore(); + }); + + it('can fetchAllWithInclude', async () => { + const objectController = CoreManager.getObjectController(); + const spy = jest.spyOn( + objectController, + 'fetch' + ) + .mockImplementationOnce((target, forceFetch, options) => {}) + .mockImplementationOnce((target, forceFetch, options) => {}) + .mockImplementationOnce((target, forceFetch, options) => {}); + + const parent = new ParseObject('Person'); + await ParseObject.fetchAllWithInclude([parent], 'child', { useMasterKey: true, sessionToken: '123'}); + await ParseObject.fetchAllWithInclude([parent], ['child']); + await ParseObject.fetchAllWithInclude([parent], [['child']]); + expect(objectController.fetch).toHaveBeenCalledTimes(3); + + expect(objectController.fetch.mock.calls[0]).toEqual([ + [parent], true, { useMasterKey: true, sessionToken: '123', include: ['child'] } + ]); + expect(objectController.fetch.mock.calls[1]).toEqual([ + [parent], true, { include: ['child'] } + ]); + expect(objectController.fetch.mock.calls[2]).toEqual([ + [parent], true, { include: ['child'] } + ]); + + spy.mockRestore(); + }); + it('can save the object', (done) => { CoreManager.getRESTController()._setXHR( mockXHR([{ @@ -1501,6 +1564,47 @@ describe('ObjectController', () => { }); }); + it('can fetch a single object with include', async (done) => { + var objectController = CoreManager.getObjectController(); + var xhr = { + setRequestHeader: jest.fn(), + open: jest.fn(), + send: jest.fn() + }; + RESTController._setXHR(function() { return xhr; }); + var o = new ParseObject('Person'); + o.id = 'pid'; + objectController.fetch(o, false, { include: ['child'] }).then(() => { + expect(xhr.open.mock.calls[0]).toEqual( + ['POST', 'https://api.parse.com/1/classes/Person/pid', true] + ); + var body = JSON.parse(xhr.send.mock.calls[0]); + expect(body._method).toBe('GET'); + done(); + }); + await flushPromises(); + + xhr.status = 200; + xhr.responseText = JSON.stringify({}); + xhr.readyState = 4; + xhr.onreadystatechange(); + jest.runAllTicks(); + }); + + it('can fetch an array of objects with include', async () => { + var objectController = CoreManager.getObjectController(); + var objects = []; + for (var i = 0; i < 5; i++) { + objects[i] = new ParseObject('Person'); + objects[i].id = 'pid' + i; + } + const results = await objectController.fetch(objects, false, { include: ['child'] }); + expect(results.length).toBe(5); + expect(results[0] instanceof ParseObject).toBe(true); + expect(results[0].id).toBe('pid0'); + expect(results[0].className).toBe('Person'); + }); + it('can destroy an object', async () => { var objectController = CoreManager.getObjectController(); var xhr = { diff --git a/src/__tests__/ParseUser-test.js b/src/__tests__/ParseUser-test.js index 44f14988d..0bfdc7851 100644 --- a/src/__tests__/ParseUser-test.js +++ b/src/__tests__/ParseUser-test.js @@ -517,6 +517,83 @@ describe('ParseUser', () => { }); }); + it('updates the current user on disk when fetched with include', async () => { + ParseUser.enableUnsafeCurrentUser(); + ParseUser._clearCache(); + Storage._clear(); + CoreManager.setRESTController({ + request() { + return Promise.resolve({ + objectId: 'uid6', + }, 200); + }, + ajax() {}, + }); + const child = new ParseObject('TestObject'); + child.set('foo', 'bar'); + await child.save(); + + let u = await ParseUser.signUp('spot', 'fetchWithInclude'); + expect(u.isCurrent()).toBe(true); + ParseUser._clearCache(); + CoreManager.setRESTController({ + request() { + return Promise.resolve({ + child: child.toJSON(), + count: 15, + }, 200); + }, + ajax() {}, + }); + u = await u.fetchWithInclude('child'); + + ParseUser._clearCache(); + ParseObject._clearAllState(); + expect(u.attributes).toEqual({}); + expect(u.get('count')).toBe(undefined); + const current = await ParseUser.currentAsync(); + expect(current.id).toBe('uid6'); + expect(current.get('count')).toBe(15); + expect(current.get('child').foo).toBe('bar'); + }); + + it('does not update non-auth user when fetched with include', async () => { + ParseUser.enableUnsafeCurrentUser(); + ParseUser._clearCache(); + Storage._clear(); + CoreManager.setRESTController({ + request() { + return Promise.resolve({ + objectId: 'uid6', + }, 200); + }, + ajax() {}, + }); + const child = new ParseObject('TestObject'); + child.set('foo', 'bar'); + await child.save(); + + let u = await ParseUser.signUp('spot', 'fetchWithInclude'); + await ParseUser.logOut(); + expect(u.isCurrent()).toBe(false); + ParseUser._clearCache(); + CoreManager.setRESTController({ + request() { + return Promise.resolve({ + child: child.toJSON(), + count: 15, + }, 200); + }, + ajax() {}, + }); + const fetchedUser = await u.fetchWithInclude('child'); + + const current = await ParseUser.currentAsync(); + expect(current).toBe(null); + expect(fetchedUser.get('count')).toBe(15); + expect(fetchedUser.get('child').foo).toBe('bar'); + }); + it('clears the current user on disk when logged out', (done) => { ParseUser.enableUnsafeCurrentUser(); ParseUser._clearCache();