diff --git a/spec/ParseLiveQuery.spec.js b/spec/ParseLiveQuery.spec.js index 32e20b97..496f9fb9 100644 --- a/spec/ParseLiveQuery.spec.js +++ b/spec/ParseLiveQuery.spec.js @@ -18,13 +18,468 @@ describe('ParseLiveQuery', function () { const query = new Parse.Query(TestObject); query.equalTo('objectId', object.id); const subscription = await query.subscribe(); - subscription.on('update', async object => { + subscription.on('update', object => { expect(object.get('foo')).toBe('bar'); done(); }); object.set({ foo: 'bar' }); await object.save(); }); + it('expect afterEvent create', async done => { + await reconfigureServer({ + liveQuery: { + classNames: ['TestObject'], + }, + startLiveQueryServer: true, + verbose: false, + silent: true, + }); + Parse.Cloud.afterLiveQueryEvent('TestObject', req => { + expect(req.event).toBe('Create'); + expect(req.user).toBeUndefined(); + expect(req.object.get('foo')).toBe('bar'); + }); + + const query = new Parse.Query(TestObject); + const subscription = await query.subscribe(); + subscription.on('create', object => { + expect(object.get('foo')).toBe('bar'); + done(); + }); + + const object = new TestObject(); + object.set('foo', 'bar'); + await object.save(); + }); + + it('expect afterEvent payload', async done => { + await reconfigureServer({ + liveQuery: { + classNames: ['TestObject'], + }, + startLiveQueryServer: true, + verbose: false, + silent: true, + }); + const object = new TestObject(); + await object.save(); + + Parse.Cloud.afterLiveQueryEvent('TestObject', req => { + expect(req.event).toBe('Update'); + expect(req.user).toBeUndefined(); + expect(req.object.get('foo')).toBe('bar'); + expect(req.original.get('foo')).toBeUndefined(); + done(); + }); + + const query = new Parse.Query(TestObject); + query.equalTo('objectId', object.id); + await query.subscribe(); + object.set({ foo: 'bar' }); + await object.save(); + }); + + it('expect afterEvent enter', async done => { + await reconfigureServer({ + liveQuery: { + classNames: ['TestObject'], + }, + startLiveQueryServer: true, + verbose: false, + silent: true, + }); + Parse.Cloud.afterLiveQueryEvent('TestObject', req => { + expect(req.event).toBe('Enter'); + expect(req.user).toBeUndefined(); + expect(req.object.get('foo')).toBe('bar'); + expect(req.original.get('foo')).toBeUndefined(); + }); + + const object = new TestObject(); + await object.save(); + + const query = new Parse.Query(TestObject); + query.equalTo('foo', 'bar'); + const subscription = await query.subscribe(); + subscription.on('enter', object => { + expect(object.get('foo')).toBe('bar'); + done(); + }); + + object.set('foo', 'bar'); + await object.save(); + }); + + it('expect afterEvent leave', async done => { + await reconfigureServer({ + liveQuery: { + classNames: ['TestObject'], + }, + startLiveQueryServer: true, + verbose: false, + silent: true, + }); + Parse.Cloud.afterLiveQueryEvent('TestObject', req => { + expect(req.event).toBe('Leave'); + expect(req.user).toBeUndefined(); + expect(req.object.get('foo')).toBeUndefined(); + expect(req.original.get('foo')).toBe('bar'); + }); + + const object = new TestObject(); + object.set('foo', 'bar'); + await object.save(); + + const query = new Parse.Query(TestObject); + query.equalTo('foo', 'bar'); + const subscription = await query.subscribe(); + subscription.on('leave', object => { + expect(object.get('foo')).toBeUndefined(); + done(); + }); + + object.unset('foo'); + await object.save(); + }); + + it('expect afterEvent delete', async done => { + await reconfigureServer({ + liveQuery: { + classNames: ['TestObject'], + }, + startLiveQueryServer: true, + verbose: false, + silent: true, + }); + Parse.Cloud.afterLiveQueryEvent('TestObject', req => { + expect(req.event).toBe('Delete'); + expect(req.user).toBeUndefined(); + req.object.set('foo', 'bar'); + }); + + const object = new TestObject(); + await object.save(); + + const query = new Parse.Query(TestObject); + query.equalTo('objectId', object.id); + + const subscription = await query.subscribe(); + subscription.on('delete', object => { + expect(object.get('foo')).toBe('bar'); + done(); + }); + + await object.destroy(); + }); + + it('can handle afterEvent modification', async done => { + await reconfigureServer({ + liveQuery: { + classNames: ['TestObject'], + }, + startLiveQueryServer: true, + verbose: false, + silent: true, + }); + const object = new TestObject(); + await object.save(); + + Parse.Cloud.afterLiveQueryEvent('TestObject', req => { + const current = req.object; + current.set('foo', 'yolo'); + + const original = req.original; + original.set('yolo', 'foo'); + }); + + const query = new Parse.Query(TestObject); + query.equalTo('objectId', object.id); + const subscription = await query.subscribe(); + subscription.on('update', (object, original) => { + expect(object.get('foo')).toBe('yolo'); + expect(original.get('yolo')).toBe('foo'); + done(); + }); + object.set({ foo: 'bar' }); + await object.save(); + }); + + it('can return different object in afterEvent', async done => { + await reconfigureServer({ + liveQuery: { + classNames: ['TestObject'], + }, + startLiveQueryServer: true, + verbose: false, + silent: true, + }); + const object = new TestObject(); + await object.save(); + + Parse.Cloud.afterLiveQueryEvent('TestObject', req => { + const object = new Parse.Object('Yolo'); + req.object = object; + }); + + const query = new Parse.Query(TestObject); + query.equalTo('objectId', object.id); + const subscription = await query.subscribe(); + subscription.on('update', object => { + expect(object.className).toBe('Yolo'); + done(); + }); + object.set({ foo: 'bar' }); + await object.save(); + }); + + it('can handle afterEvent throw', async done => { + await reconfigureServer({ + liveQuery: { + classNames: ['TestObject'], + }, + startLiveQueryServer: true, + verbose: false, + silent: true, + }); + + const object = new TestObject(); + await object.save(); + + Parse.Cloud.afterLiveQueryEvent('TestObject', req => { + const current = req.object; + const original = req.original; + + setTimeout(() => { + done(); + }, 2000); + + if (current.get('foo') != original.get('foo')) { + throw "Don't pass an update trigger, or message"; + } + }); + + const query = new Parse.Query(TestObject); + query.equalTo('objectId', object.id); + const subscription = await query.subscribe(); + subscription.on('update', () => { + fail('update should not have been called.'); + }); + subscription.on('error', () => { + fail('error should not have been called.'); + }); + object.set({ foo: 'bar' }); + await object.save(); + }); + it('expect afterEvent create', async done => { + await reconfigureServer({ + liveQuery: { + classNames: ['TestObject'], + }, + startLiveQueryServer: true, + verbose: false, + silent: true, + }); + Parse.Cloud.afterLiveQueryEvent('TestObject', req => { + expect(req.event).toBe('Create'); + expect(req.user).toBeUndefined(); + expect(req.object.get('foo')).toBe('bar'); + }); + + const query = new Parse.Query(TestObject); + const subscription = await query.subscribe(); + subscription.on('create', object => { + expect(object.get('foo')).toBe('bar'); + done(); + }); + + const object = new TestObject(); + object.set('foo', 'bar'); + await object.save(); + }); + + it('expect afterEvent payload', async done => { + await reconfigureServer({ + liveQuery: { + classNames: ['TestObject'], + }, + startLiveQueryServer: true, + verbose: false, + silent: true, + }); + const object = new TestObject(); + await object.save(); + + Parse.Cloud.afterLiveQueryEvent('TestObject', req => { + expect(req.event).toBe('Update'); + expect(req.user).toBeUndefined(); + expect(req.object.get('foo')).toBe('bar'); + expect(req.original.get('foo')).toBeUndefined(); + done(); + }); + + const query = new Parse.Query(TestObject); + query.equalTo('objectId', object.id); + await query.subscribe(); + object.set({ foo: 'bar' }); + await object.save(); + }); + + it('expect afterEvent enter', async done => { + await reconfigureServer({ + liveQuery: { + classNames: ['TestObject'], + }, + startLiveQueryServer: true, + verbose: false, + silent: true, + }); + Parse.Cloud.afterLiveQueryEvent('TestObject', req => { + expect(req.event).toBe('Enter'); + expect(req.user).toBeUndefined(); + expect(req.object.get('foo')).toBe('bar'); + expect(req.original.get('foo')).toBeUndefined(); + }); + + const object = new TestObject(); + await object.save(); + + const query = new Parse.Query(TestObject); + query.equalTo('foo', 'bar'); + const subscription = await query.subscribe(); + subscription.on('enter', object => { + expect(object.get('foo')).toBe('bar'); + done(); + }); + + object.set('foo', 'bar'); + await object.save(); + }); + + it('expect afterEvent leave', async done => { + await reconfigureServer({ + liveQuery: { + classNames: ['TestObject'], + }, + startLiveQueryServer: true, + verbose: false, + silent: true, + }); + Parse.Cloud.afterLiveQueryEvent('TestObject', req => { + expect(req.event).toBe('Leave'); + expect(req.user).toBeUndefined(); + expect(req.object.get('foo')).toBeUndefined(); + expect(req.original.get('foo')).toBe('bar'); + }); + + const object = new TestObject(); + object.set('foo', 'bar'); + await object.save(); + + const query = new Parse.Query(TestObject); + query.equalTo('foo', 'bar'); + const subscription = await query.subscribe(); + subscription.on('leave', object => { + expect(object.get('foo')).toBeUndefined(); + done(); + }); + + object.unset('foo'); + await object.save(); + }); + + it('expect afterEvent delete', async done => { + await reconfigureServer({ + liveQuery: { + classNames: ['TestObject'], + }, + startLiveQueryServer: true, + verbose: false, + silent: true, + }); + Parse.Cloud.afterLiveQueryEvent('TestObject', req => { + expect(req.event).toBe('Delete'); + expect(req.user).toBeUndefined(); + req.object.set('foo', 'bar'); + }); + + const object = new TestObject(); + await object.save(); + + const query = new Parse.Query(TestObject); + query.equalTo('objectId', object.id); + + const subscription = await query.subscribe(); + subscription.on('delete', object => { + expect(object.get('foo')).toBe('bar'); + done(); + }); + + await object.destroy(); + }); + + it('can handle afterEvent modification', async done => { + await reconfigureServer({ + liveQuery: { + classNames: ['TestObject'], + }, + startLiveQueryServer: true, + verbose: false, + silent: true, + }); + const object = new TestObject(); + await object.save(); + + Parse.Cloud.afterLiveQueryEvent('TestObject', req => { + const current = req.object; + current.set('foo', 'yolo'); + + const original = req.original; + original.set('yolo', 'foo'); + }); + + const query = new Parse.Query(TestObject); + query.equalTo('objectId', object.id); + const subscription = await query.subscribe(); + subscription.on('update', (object, original) => { + expect(object.get('foo')).toBe('yolo'); + expect(original.get('yolo')).toBe('foo'); + done(); + }); + object.set({ foo: 'bar' }); + await object.save(); + }); + + it('can handle async afterEvent modification', async done => { + await reconfigureServer({ + liveQuery: { + classNames: ['TestObject'], + }, + startLiveQueryServer: true, + verbose: false, + silent: true, + }); + const parent = new TestObject(); + const child = new TestObject(); + child.set('bar', 'foo'); + await Parse.Object.saveAll([parent, child]); + + Parse.Cloud.afterLiveQueryEvent('TestObject', async req => { + const current = req.object; + const pointer = current.get('child'); + await pointer.fetch(); + }); + + const query = new Parse.Query(TestObject); + query.equalTo('objectId', parent.id); + const subscription = await query.subscribe(); + subscription.on('update', object => { + expect(object.get('child')).toBeDefined(); + expect(object.get('child').get('bar')).toBe('foo'); + done(); + }); + parent.set('child', child); + await parent.save(); + }); it('can handle beforeConnect / beforeSubscribe hooks', async done => { await reconfigureServer({ @@ -58,7 +513,7 @@ describe('ParseLiveQuery', function () { const query = new Parse.Query(TestObject); query.equalTo('objectId', object.id); const subscription = await query.subscribe(); - subscription.on('update', async object => { + subscription.on('update', object => { expect(object.get('foo')).toBe('bar'); done(); }); diff --git a/src/LiveQuery/ParseLiveQueryServer.js b/src/LiveQuery/ParseLiveQueryServer.js index 5d283679..e535480a 100644 --- a/src/LiveQuery/ParseLiveQueryServer.js +++ b/src/LiveQuery/ParseLiveQueryServer.js @@ -14,6 +14,7 @@ import { runLiveQueryEventHandlers, maybeRunConnectTrigger, maybeRunSubscribeTrigger, + maybeRunAfterEventTrigger, } from '../triggers'; import { getAuthForSessionToken, Auth } from '../Auth'; import { getCacheController } from '../Controllers'; @@ -124,7 +125,7 @@ class ParseLiveQueryServer { _onAfterDelete(message: any): void { logger.verbose(Parse.applicationId + 'afterDelete is triggered'); - const deletedParseObject = message.currentParseObject.toJSON(); + let deletedParseObject = message.currentParseObject.toJSON(); const classLevelPermissions = message.classLevelPermissions; const className = deletedParseObject.className; logger.verbose( @@ -158,6 +159,7 @@ class ParseLiveQueryServer { const acl = message.currentParseObject.getACL(); // Check CLP const op = this._getCLPOperation(subscription.query); + let res = {}; this._matchesCLP( classLevelPermissions, message.currentParseObject, @@ -173,6 +175,22 @@ class ParseLiveQueryServer { if (!isMatched) { return null; } + res = { + event: 'Delete', + sessionToken: client.sessionToken, + object: deletedParseObject, + clients: this.clients.size, + subscriptions: this.subscriptions.size, + useMasterKey: client.hasMasterKey, + installationId: client.installationId + }; + return maybeRunAfterEventTrigger('afterEvent', className, res); + }) + .then(() => { + if (res.object && typeof res.object.toJSON === 'function') { + deletedParseObject = res.object.toJSON(); + deletedParseObject.className = className; + } client.pushDelete(requestId, deletedParseObject); }) .catch(error => { @@ -193,7 +211,7 @@ class ParseLiveQueryServer { originalParseObject = message.originalParseObject.toJSON(); } const classLevelPermissions = message.classLevelPermissions; - const currentParseObject = message.currentParseObject.toJSON(); + let currentParseObject = message.currentParseObject.toJSON(); const className = currentParseObject.className; logger.verbose( 'ClassName: %s | ObjectId: %s', @@ -243,6 +261,7 @@ class ParseLiveQueryServer { // Set current ParseObject ACL checking promise, if the object does not match // subscription, we do not need to check ACL let currentACLCheckingPromise; + let res = {}; if (!isCurrentSubscriptionMatched) { currentACLCheckingPromise = Promise.resolve(false); } else { @@ -267,40 +286,67 @@ class ParseLiveQueryServer { currentACLCheckingPromise, ]); }) - .then( - ([isOriginalMatched, isCurrentMatched]) => { - logger.verbose( - 'Original %j | Current %j | Match: %s, %s, %s, %s | Query: %s', - originalParseObject, - currentParseObject, - isOriginalSubscriptionMatched, - isCurrentSubscriptionMatched, - isOriginalMatched, - isCurrentMatched, - subscription.hash - ); + .then(([isOriginalMatched, isCurrentMatched]) => { + logger.verbose( + 'Original %j | Current %j | Match: %s, %s, %s, %s | Query: %s', + originalParseObject, + currentParseObject, + isOriginalSubscriptionMatched, + isCurrentSubscriptionMatched, + isOriginalMatched, + isCurrentMatched, + subscription.hash + ); - // Decide event type - let type; - if (isOriginalMatched && isCurrentMatched) { - type = 'Update'; - } else if (isOriginalMatched && !isCurrentMatched) { - type = 'Leave'; - } else if (!isOriginalMatched && isCurrentMatched) { - if (originalParseObject) { - type = 'Enter'; - } else { - type = 'Create'; - } + // Decide event type + let type; + if (isOriginalMatched && isCurrentMatched) { + type = 'Update'; + } else if (isOriginalMatched && !isCurrentMatched) { + type = 'Leave'; + } else if (!isOriginalMatched && isCurrentMatched) { + if (originalParseObject) { + type = 'Enter'; } else { - return null; + type = 'Create'; + } + } else { + return null; + } + message.event = type; + res = { + event: type, + sessionToken: client.sessionToken, + object: currentParseObject, + original: originalParseObject, + clients: this.clients.size, + subscriptions: this.subscriptions.size, + useMasterKey: client.hasMasterKey, + installationId: client.installationId + }; + return maybeRunAfterEventTrigger('afterEvent', className, res); + }) + .then( + () => { + if (res.object && typeof res.object.toJSON === 'function') { + currentParseObject = res.object.toJSON(); + currentParseObject.className = + res.object.className || className; + } + + if (res.original && typeof res.original.toJSON === 'function') { + originalParseObject = res.original.toJSON(); + originalParseObject.className = + res.original.className || className; + } + const functionName = 'push' + message.event; + if (client[functionName]) { + client[functionName]( + requestId, + currentParseObject, + originalParseObject + ); } - const functionName = 'push' + type; - client[functionName]( - requestId, - currentParseObject, - originalParseObject - ); }, error => { logger.error('Matching ACL error : ', error); diff --git a/src/cloud-code/Parse.Cloud.js b/src/cloud-code/Parse.Cloud.js index 088c4dc3..c3f0536c 100644 --- a/src/cloud-code/Parse.Cloud.js +++ b/src/cloud-code/Parse.Cloud.js @@ -511,6 +511,32 @@ ParseCloud.onLiveQueryEvent = function (handler) { triggers.addLiveQueryEventHandler(handler, Parse.applicationId); }; +/** + * Registers an after live query server event function. + * + * **Available in Cloud Code only.** + * + * ``` + * Parse.Cloud.afterLiveQueryEvent('MyCustomClass', (request) => { + * // code here + * }) + *``` + * + * @method afterLiveQueryEvent + * @name Parse.Cloud.afterLiveQueryEvent + * @param {(String|Parse.Object)} arg1 The Parse.Object subclass to register the after live query event function for. This can instead be a String that is the className of the subclass. + * @param {Function} func The function to run after a live query event. This function can be async and should take one parameter, a {@link Parse.Cloud.LiveQueryEventTrigger}. + */ +ParseCloud.afterLiveQueryEvent = function (parseClass, handler) { + const className = getClassName(parseClass); + triggers.addTrigger( + triggers.Types.afterEvent, + className, + handler, + Parse.applicationId + ); +}; + ParseCloud._removeAllHooks = () => { triggers._unregisterAll(); }; @@ -563,6 +589,19 @@ module.exports = ParseCloud; * @property {String} sessionToken If set, the session of the user that made the request. */ +/** + * @interface Parse.Cloud.LiveQueryEventTrigger + * @property {String} installationId If set, the installationId triggering the request. + * @property {Boolean} useMasterKey If true, means the master key was used. + * @property {Parse.User} user If set, the user that made the request. + * @property {String} sessionToken If set, the session of the user that made the request. + * @property {String} event The live query event that triggered the request. + * @property {Parse.Object} object The object triggering the hook. + * @property {Parse.Object} original If set, the object, as currently stored. + * @property {Integer} clients The number of clients connected. + * @property {Integer} subscriptions The number of subscriptions connected. + */ + /** * @interface Parse.Cloud.BeforeFindRequest * @property {String} installationId If set, the installationId triggering the request. diff --git a/src/triggers.js b/src/triggers.js index de985684..48464c2f 100644 --- a/src/triggers.js +++ b/src/triggers.js @@ -18,6 +18,7 @@ export const Types = { afterDeleteFile: 'afterDeleteFile', beforeConnect: 'beforeConnect', beforeSubscribe: 'beforeSubscribe', + afterEvent: 'afterEvent', }; const FileClassName = '@File'; @@ -802,6 +803,25 @@ export async function maybeRunSubscribeTrigger( request.query = query; } +export async function maybeRunAfterEventTrigger( + triggerType, + className, + request +) { + const trigger = getTrigger(className, triggerType, Parse.applicationId); + if (!trigger) { + return; + } + if (request.object) { + request.object = Parse.Object.fromJSON(request.object); + } + if (request.original) { + request.original = Parse.Object.fromJSON(request.original); + } + request.user = await userForSessionToken(request.sessionToken); + return trigger(request); +} + async function userForSessionToken(sessionToken) { if (!sessionToken) { return;