diff --git a/src/ParseObject.ts b/src/ParseObject.ts index 1cab3c1cb..599c94c5d 100644 --- a/src/ParseObject.ts +++ b/src/ParseObject.ts @@ -47,6 +47,7 @@ export type SaveOptions = FullOptions & { cascadeSave?: boolean; context?: AttributeMap; batchSize?: number; + transaction?: boolean; }; type FetchOptions = { @@ -1354,6 +1355,29 @@ class ParseObject { } const controller = CoreManager.getObjectController(); const unsaved = options.cascadeSave !== false ? unsavedChildren(this) : null; + if ( + unsaved && + unsaved.length && + options.transaction === true && + unsaved.some(el => el instanceof ParseObject) + ) { + saveOptions.transaction = options.transaction; + const unsavedFiles: ParseFile[] = []; + const unsavedObjects: ParseObject[] = []; + unsaved.forEach(el => { + if (el instanceof ParseFile) unsavedFiles.push(el); + else unsavedObjects.push(el); + }); + unsavedObjects.push(this); + + const filePromise = unsavedFiles.length + ? controller.save(unsavedFiles, saveOptions) + : Promise.resolve(); + + return filePromise + .then(() => controller.save(unsavedObjects, saveOptions)) + .then((savedOjbects: this[]) => savedOjbects.pop()); + } return controller.save(unsaved, saveOptions).then(() => { return controller.save(this, saveOptions); }) as Promise as Promise; @@ -1770,6 +1794,9 @@ class ParseObject { if (options.hasOwnProperty('sessionToken')) { destroyOptions.sessionToken = options.sessionToken; } + if (options.hasOwnProperty('transaction') && typeof options.transaction === 'boolean') { + destroyOptions.transaction = options.transaction; + } if (options.hasOwnProperty('batchSize') && typeof options.batchSize === 'number') { destroyOptions.batchSize = options.batchSize; } @@ -1805,6 +1832,9 @@ class ParseObject { if (options.hasOwnProperty('sessionToken')) { saveOptions.sessionToken = options.sessionToken; } + if (options.hasOwnProperty('transaction') && typeof options.transaction === 'boolean') { + saveOptions.transaction = options.transaction; + } if (options.hasOwnProperty('batchSize') && typeof options.batchSize === 'number') { saveOptions.batchSize = options.batchSize; } @@ -2322,12 +2352,20 @@ const DefaultController = { target: ParseObject | Array, options: RequestOptions ): Promise> { - const batchSize = + if (options && options.batchSize && options.transaction) + throw new ParseError( + ParseError.OTHER_CAUSE, + 'You cannot use both transaction and batchSize options simultaneously.' + ); + + let batchSize = options && options.batchSize ? options.batchSize : CoreManager.get('REQUEST_BATCH_SIZE'); const localDatastore = CoreManager.getLocalDatastore(); const RESTController = CoreManager.getRESTController(); if (Array.isArray(target)) { + if (options && options.transaction && target.length > 1) batchSize = target.length; + if (target.length < 1) { return Promise.resolve([]); } @@ -2348,21 +2386,20 @@ const DefaultController = { let deleteCompleted = Promise.resolve(); const errors = []; batches.forEach(batch => { + const requests = batch.map(obj => { + return { + method: 'DELETE', + path: getServerUrlPath() + 'classes/' + obj.className + '/' + obj._getId(), + body: {}, + }; + }); + const body = + options && options.transaction && requests.length > 1 + ? { requests, transaction: true } + : { requests }; + deleteCompleted = deleteCompleted.then(() => { - return RESTController.request( - 'POST', - 'batch', - { - requests: batch.map(obj => { - return { - method: 'DELETE', - path: getServerUrlPath() + 'classes/' + obj.className + '/' + obj._getId(), - body: {}, - }; - }), - }, - options - ).then(results => { + return RESTController.request('POST', 'batch', body, options).then(results => { for (let i = 0; i < results.length; i++) { if (results[i] && results[i].hasOwnProperty('error')) { const err = new ParseError(results[i].error.code, results[i].error.error); @@ -2402,8 +2439,17 @@ const DefaultController = { target: ParseObject | null | Array, options: RequestOptions ): Promise | ParseFile | undefined> { - const batchSize = + if (options && options.batchSize && options.transaction) + return Promise.reject( + new ParseError( + ParseError.OTHER_CAUSE, + 'You cannot use both transaction and batchSize options simultaneously.' + ) + ); + + let batchSize = options && options.batchSize ? options.batchSize : CoreManager.get('REQUEST_BATCH_SIZE'); + const localDatastore = CoreManager.getLocalDatastore(); const mapIdForPin = {}; @@ -2437,6 +2483,17 @@ const DefaultController = { } }); + if (options && options.transaction && pending.length > 1) { + if (pending.some(el => !canBeSerialized(el))) + return Promise.reject( + new ParseError( + ParseError.OTHER_CAUSE, + 'Tried to save a transactional batch containing an object with unserializable attributes.' + ) + ); + batchSize = pending.length; + } + return Promise.all(filesSaved).then(() => { let objectError = null; return continueWhile( @@ -2504,18 +2561,16 @@ const DefaultController = { when(batchReady) .then(() => { // Kick off the batch request - return RESTController.request( - 'POST', - 'batch', - { - requests: batch.map(obj => { - const params = obj._getSaveParams(); - params.path = getServerUrlPath() + params.path; - return params; - }), - }, - options - ); + const requests = batch.map(obj => { + const params = obj._getSaveParams(); + params.path = getServerUrlPath() + params.path; + return params; + }); + const body = + options && options.transaction && requests.length > 1 + ? { requests, transaction: true } + : { requests }; + return RESTController.request('POST', 'batch', body, options); }) .then(batchReturned.resolve, error => { batchReturned.reject(new ParseError(ParseError.INCORRECT_TYPE, error.message)); diff --git a/src/RESTController.ts b/src/RESTController.ts index aa5ae8401..c85d58e65 100644 --- a/src/RESTController.ts +++ b/src/RESTController.ts @@ -16,6 +16,7 @@ export type RequestOptions = { context?: any; usePost?: boolean; ignoreEmailVerification?: boolean; + transaction?: boolean; }; export type FullOptions = { diff --git a/src/__tests__/ParseObject-test.js b/src/__tests__/ParseObject-test.js index 412f2eafe..077c500d0 100644 --- a/src/__tests__/ParseObject-test.js +++ b/src/__tests__/ParseObject-test.js @@ -2016,12 +2016,258 @@ describe('ParseObject', () => { it('should fail saveAll batch cycle', async () => { const obj = new ParseObject('Item'); obj.set('child', obj); - try { - await ParseObject.saveAll([obj]); - expect(true).toBe(false); - } catch (e) { - expect(e.message).toBe('Tried to save a batch with a cycle.'); - } + + await expect(ParseObject.saveAll([obj])).rejects.toEqual( + expect.objectContaining({ + message: 'Tried to save a batch with a cycle.', + }) + ); + }); + + it('should fail save with transaction and batchSize option', async () => { + const obj1 = new ParseObject('TestObject'); + const obj2 = new ParseObject('TestObject'); + + await expect( + ParseObject.saveAll([obj1, obj2], { transaction: true, batchSize: 20 }) + ).rejects.toEqual( + expect.objectContaining({ + message: 'You cannot use both transaction and batchSize options simultaneously.', + }) + ); + }); + + it('should fail destroy with transaction and batchSize option', async () => { + const obj1 = new ParseObject('TestObject'); + const obj2 = new ParseObject('TestObject'); + + await expect( + ParseObject.destroyAll([obj1, obj2], { transaction: true, batchSize: 20 }) + ).rejects.toEqual( + expect.objectContaining({ + message: 'You cannot use both transaction and batchSize options simultaneously.', + }) + ); + }); + + it('should fail save batch with unserializable attribute and transaction option', async () => { + const obj1 = new ParseObject('TestObject'); + const obj2 = new ParseObject('TestObject'); + obj1.set('relatedObject', obj2); + + await expect(ParseObject.saveAll([obj1, obj2], { transaction: true })).rejects.toEqual( + expect.objectContaining({ + message: + 'Tried to save a transactional batch containing an object with unserializable attributes.', + }) + ); + }); + + it('should fail to save object when its children lack IDs using transaction option', async () => { + RESTController._setXHR(mockXHR([{ status: 200, response: [] }])); + + const obj1 = new ParseObject('TestObject'); + const obj2 = new ParseObject('TestObject'); + obj1.set('relatedObject', obj2); + + await expect(obj1.save(null, { transaction: true })).rejects.toEqual( + expect.objectContaining({ + message: + 'Tried to save a transactional batch containing an object with unserializable attributes.', + }) + ); + }); + + it('should save batch with serializable attribute and transaction option', async () => { + CoreManager.getRESTController()._setXHR( + mockXHR([ + { + status: 200, + response: [{ success: { objectId: 'parent' } }, { success: { objectId: 'id2' } }], + }, + ]) + ); + + const controller = CoreManager.getRESTController(); + jest.spyOn(controller, 'request'); + + const obj1 = new ParseObject('TestObject'); + const obj2 = new ParseObject('TestObject'); + obj2.id = 'id2'; + obj1.set('relatedObject', obj2); + + const [saved1, saved2] = await ParseObject.saveAll([obj1, obj2], { transaction: true }); + + expect(saved1.dirty()).toBe(false); + expect(saved2.dirty()).toBe(false); + expect(saved1.id).toBe('parent'); + expect(saved2.id).toBe('id2'); + + expect(controller.request).toHaveBeenCalledWith( + 'POST', + 'batch', + { + requests: [ + { + method: 'POST', + body: { + relatedObject: { __type: 'Pointer', className: 'TestObject', objectId: 'id2' }, + }, + path: '/1/classes/TestObject', + }, + { method: 'PUT', body: {}, path: '/1/classes/TestObject/id2' }, + ], + transaction: true, + }, + expect.anything() + ); + }); + + it('should save object along with its children using transaction option', async () => { + CoreManager.getRESTController()._setXHR( + mockXHR([ + { + status: 200, + response: [{ success: { objectId: 'id2' } }, { success: { objectId: 'parent' } }], + }, + ]) + ); + + const controller = CoreManager.getRESTController(); + jest.spyOn(controller, 'request'); + + const obj1 = new ParseObject('TestObject'); + const obj2 = new ParseObject('TestObject'); + obj2.id = 'id2'; + obj2.set('attribute', true); + + obj1.set('relatedObject', obj2); + + const saved1 = await obj1.save(null, { transaction: true }); + + const saved2 = saved1.get('relatedObject'); + expect(saved1.dirty()).toBe(false); + expect(saved2.dirty()).toBe(false); + expect(saved1.id).toBe('parent'); + expect(saved2.id).toBe('id2'); + + expect(controller.request).toHaveBeenCalledWith( + 'POST', + 'batch', + { + requests: [ + { + method: 'PUT', + body: { attribute: true }, + path: '/1/classes/TestObject/id2', + }, + { + method: 'POST', + body: { + relatedObject: { __type: 'Pointer', className: 'TestObject', objectId: 'id2' }, + }, + path: '/1/classes/TestObject', + }, + ], + transaction: true, + }, + expect.anything() + ); + }); + + it('should save file & object along with its children using transaction option', async () => { + CoreManager.getRESTController()._setXHR( + mockXHR([ + { + status: 200, + response: { name: 'mock-name', url: 'mock-url' }, + }, + { + status: 200, + response: [{ success: { objectId: 'id2' } }, { success: { objectId: 'parent' } }], + }, + ]) + ); + + const controller = CoreManager.getRESTController(); + jest.spyOn(controller, 'request'); + + const file1 = new ParseFile('parse-server-logo', [0, 1, 2, 3]); + const obj1 = new ParseObject('TestObject'); + const obj2 = new ParseObject('TestObject'); + obj2.id = 'id2'; + obj2.set('file', file1); + + obj1.set('relatedObject', obj2); + + const saved1 = await obj1.save(null, { transaction: true }); + + const saved2 = saved1.get('relatedObject'); + expect(saved1.dirty()).toBe(false); + expect(saved2.dirty()).toBe(false); + expect(saved1.id).toBe('parent'); + expect(saved2.id).toBe('id2'); + + const file = saved2.get('file'); + expect(file.name()).toBe('mock-name'); + expect(file.url()).toBe('mock-url'); + + expect(controller.request).toHaveBeenCalledWith( + 'POST', + 'batch', + { + requests: [ + { + method: 'PUT', + body: { file: { __type: 'File', name: 'mock-name', url: 'mock-url' } }, + path: '/1/classes/TestObject/id2', + }, + { + method: 'POST', + body: { + relatedObject: { __type: 'Pointer', className: 'TestObject', objectId: 'id2' }, + }, + path: '/1/classes/TestObject', + }, + ], + transaction: true, + }, + expect.anything() + ); + }); + + it('should destroy batch with transaction option', async () => { + CoreManager.getRESTController()._setXHR( + mockXHR([ + { + status: 200, + response: [{ success: { objectId: 'parent' } }, { success: { objectId: 'id2' } }], + }, + ]) + ); + + const controller = CoreManager.getRESTController(); + jest.spyOn(controller, 'request'); + + const obj1 = new ParseObject('TestObject'); + const obj2 = new ParseObject('TestObject'); + obj1.id = 'parent'; + obj2.id = 'id2'; + + await ParseObject.destroyAll([obj1, obj2], { transaction: true }); + + expect(controller.request).toHaveBeenCalledWith( + 'POST', + 'batch', + { + requests: [ + { method: 'DELETE', body: {}, path: '/1/classes/TestObject/parent' }, + { method: 'DELETE', body: {}, path: '/1/classes/TestObject/id2' }, + ], + transaction: true, + }, + expect.anything() + ); }); it('should fail on invalid date', done => {