From caee281bc58901fea7cead21d6a9a0bb2022d91b Mon Sep 17 00:00:00 2001 From: dblythy Date: Sat, 9 Oct 2021 02:24:33 +1100 Subject: [PATCH 1/3] fix: allow LiveQuery on Parse.Session (#7554) --- CHANGELOG.md | 1 + spec/ParseLiveQuery.spec.js | 54 +++++++++++++++++++++++++- src/Controllers/LiveQueryController.js | 6 ++- src/LiveQuery/ParseLiveQueryServer.js | 26 +++++++++++++ src/RestWrite.js | 19 ++++++++- src/cloud-code/Parse.Cloud.js | 29 ++++++-------- src/triggers.js | 7 ++++ 7 files changed, 120 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a5f6f8dfe..42858f691a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -157,6 +157,7 @@ ___ - Allow setting descending sort to full text queries (dblythy) [#7496](https://github.com/parse-community/parse-server/pull/7496) - Allow cloud string for ES modules (Daniel Blyth) [#7560](https://github.com/parse-community/parse-server/pull/7560) - docs: Introduce deprecation ID for reference in comments and online search (Manuel Trezza) [#7562](https://github.com/parse-community/parse-server/pull/7562) +- Allow liveQuery on Session class (Daniel Blyth) [#7554](https://github.com/parse-community/parse-server/pull/7554) ## 4.10.4 [Full Changelog](https://github.com/parse-community/parse-server/compare/4.10.3...4.10.4) diff --git a/spec/ParseLiveQuery.spec.js b/spec/ParseLiveQuery.spec.js index dda6d7740b..2393467544 100644 --- a/spec/ParseLiveQuery.spec.js +++ b/spec/ParseLiveQuery.spec.js @@ -708,6 +708,58 @@ describe('ParseLiveQuery', function () { } }); + it('liveQuery on Session class', async done => { + await reconfigureServer({ + liveQuery: { classNames: [Parse.Session] }, + startLiveQueryServer: true, + verbose: false, + silent: true, + }); + + const user = new Parse.User(); + user.setUsername('username'); + user.setPassword('password'); + await user.signUp(); + + const query = new Parse.Query(Parse.Session); + const subscription = await query.subscribe(); + + subscription.on('create', async obj => { + await new Promise(resolve => setTimeout(resolve, 200)); + expect(obj.get('user').id).toBe(user.id); + expect(obj.get('createdWith')).toEqual({ action: 'login', authProvider: 'password' }); + expect(obj.get('expiresAt')).toBeInstanceOf(Date); + expect(obj.get('installationId')).toBeDefined(); + expect(obj.get('createdAt')).toBeInstanceOf(Date); + expect(obj.get('updatedAt')).toBeInstanceOf(Date); + done(); + }); + + await Parse.User.logIn('username', 'password'); + }); + + it('prevent liveQuery on Session class when not logged in', async done => { + await reconfigureServer({ + liveQuery: { + classNames: [Parse.Session], + }, + startLiveQueryServer: true, + verbose: false, + silent: true, + }); + + Parse.LiveQuery.on('error', error => { + expect(error).toBe('Invalid session token'); + }); + const query = new Parse.Query(Parse.Session); + const subscription = await query.subscribe(); + subscription.on('error', error => { + Parse.LiveQuery.removeAllListeners('error'); + expect(error).toBe('Invalid session token'); + done(); + }); + }); + it('handle invalid websocket payload length', async done => { await reconfigureServer({ liveQuery: { @@ -754,7 +806,7 @@ describe('ParseLiveQuery', function () { await reconfigureServer({ liveQuery: { - classNames: ['_User'], + classNames: [Parse.User], }, startLiveQueryServer: true, verbose: false, diff --git a/src/Controllers/LiveQueryController.js b/src/Controllers/LiveQueryController.js index 34cf28893f..064084caa4 100644 --- a/src/Controllers/LiveQueryController.js +++ b/src/Controllers/LiveQueryController.js @@ -1,5 +1,6 @@ import { ParseCloudCodePublisher } from '../LiveQuery/ParseCloudCodePublisher'; import { LiveQueryOptions } from '../Options'; +import { getClassName } from './../triggers'; export class LiveQueryController { classNames: any; liveQueryPublisher: any; @@ -9,7 +10,10 @@ export class LiveQueryController { if (!config || !config.classNames) { this.classNames = new Set(); } else if (config.classNames instanceof Array) { - const classNames = config.classNames.map(name => new RegExp('^' + name + '$')); + const classNames = config.classNames.map(name => { + const _name = getClassName(name); + return new RegExp(`^${_name}$`); + }); this.classNames = new Set(classNames); } else { throw 'liveQuery.classes should be an array of string'; diff --git a/src/LiveQuery/ParseLiveQueryServer.js b/src/LiveQuery/ParseLiveQueryServer.js index f7a065b7f6..0091c459c0 100644 --- a/src/LiveQuery/ParseLiveQueryServer.js +++ b/src/LiveQuery/ParseLiveQueryServer.js @@ -729,10 +729,12 @@ class ParseLiveQueryServer { } const client = this.clients.get(parseWebsocket.clientId); const className = request.query.className; + let authCalled = false; try { const trigger = getTrigger(className, 'beforeSubscribe', Parse.applicationId); if (trigger) { const auth = await this.getAuthFromClient(client, request.requestId, request.sessionToken); + authCalled = true; if (auth && auth.user) { request.user = auth.user; } @@ -749,6 +751,30 @@ class ParseLiveQueryServer { request.query = query; } + if (className === '_Session') { + if (!authCalled) { + const auth = await this.getAuthFromClient( + client, + request.requestId, + request.sessionToken + ); + if (auth && auth.user) { + request.user = auth.user; + } + } + if (request.user) { + request.query.where.user = request.user.toPointer(); + } else if (!request.master) { + Client.pushError( + parseWebsocket, + Parse.Error.INVALID_SESSION_TOKEN, + 'Invalid session token', + false, + request.requestId + ); + return; + } + } // Get subscription from subscriptions, create one if necessary const subscriptionHash = queryHash(request.query); // Add className to subscriptions if necessary diff --git a/src/RestWrite.js b/src/RestWrite.js index c1f7ca2a26..a651cf9c6c 100644 --- a/src/RestWrite.js +++ b/src/RestWrite.js @@ -1591,11 +1591,22 @@ RestWrite.prototype.sanitizedData = function () { // Returns an updated copy of the object RestWrite.prototype.buildUpdatedObject = function (extraData) { + const className = Parse.Object.fromJSON(extraData); + const readOnlyAttributes = className.constructor.readOnlyAttributes + ? className.constructor.readOnlyAttributes() + : []; + if (!this.originalData) { + for (const attribute of readOnlyAttributes) { + extraData[attribute] = this.data[attribute]; + } + } const updatedObject = triggers.inflate(extraData, this.originalData); Object.keys(this.data).reduce(function (data, key) { if (key.indexOf('.') > 0) { if (typeof data[key].__op === 'string') { - updatedObject.set(key, data[key]); + if (!readOnlyAttributes.includes(key)) { + updatedObject.set(key, data[key]); + } } else { // subdocument key with dot notation { 'x.y': v } => { 'x': { 'y' : v } }) const splittedKey = key.split('.'); @@ -1612,7 +1623,11 @@ RestWrite.prototype.buildUpdatedObject = function (extraData) { return data; }, deepcopy(this.data)); - updatedObject.set(this.sanitizedData()); + const sanitized = this.sanitizedData(); + for (const attribute of readOnlyAttributes) { + delete sanitized[attribute]; + } + updatedObject.set(sanitized); return updatedObject; }; diff --git a/src/cloud-code/Parse.Cloud.js b/src/cloud-code/Parse.Cloud.js index d16fe28ad4..5329a3eda2 100644 --- a/src/cloud-code/Parse.Cloud.js +++ b/src/cloud-code/Parse.Cloud.js @@ -6,13 +6,6 @@ function isParseObjectConstructor(object) { return typeof object === 'function' && Object.prototype.hasOwnProperty.call(object, 'className'); } -function getClassName(parseClass) { - if (parseClass && parseClass.className) { - return parseClass.className; - } - return parseClass; -} - function validateValidator(validator) { if (!validator || typeof validator === 'function') { return; @@ -161,7 +154,7 @@ ParseCloud.job = function (functionName, handler) { * @param {(Object|Function)} validator An optional function to help validating cloud code. This function can be an async function and should take one parameter a {@link Parse.Cloud.TriggerRequest}, or a {@link Parse.Cloud.ValidatorObject}. */ ParseCloud.beforeSave = function (parseClass, handler, validationHandler) { - var className = getClassName(parseClass); + const className = triggers.getClassName(parseClass); validateValidator(validationHandler); triggers.addTrigger( triggers.Types.beforeSave, @@ -197,7 +190,7 @@ ParseCloud.beforeSave = function (parseClass, handler, validationHandler) { * @param {(Object|Function)} validator An optional function to help validating cloud code. This function can be an async function and should take one parameter a {@link Parse.Cloud.TriggerRequest}, or a {@link Parse.Cloud.ValidatorObject}. */ ParseCloud.beforeDelete = function (parseClass, handler, validationHandler) { - var className = getClassName(parseClass); + const className = triggers.getClassName(parseClass); validateValidator(validationHandler); triggers.addTrigger( triggers.Types.beforeDelete, @@ -236,7 +229,7 @@ ParseCloud.beforeLogin = function (handler) { if (typeof handler === 'string' || isParseObjectConstructor(handler)) { // validation will occur downstream, this is to maintain internal // code consistency with the other hook types. - className = getClassName(handler); + className = triggers.getClassName(handler); handler = arguments[1]; } triggers.addTrigger(triggers.Types.beforeLogin, className, handler, Parse.applicationId); @@ -266,7 +259,7 @@ ParseCloud.afterLogin = function (handler) { if (typeof handler === 'string' || isParseObjectConstructor(handler)) { // validation will occur downstream, this is to maintain internal // code consistency with the other hook types. - className = getClassName(handler); + className = triggers.getClassName(handler); handler = arguments[1]; } triggers.addTrigger(triggers.Types.afterLogin, className, handler, Parse.applicationId); @@ -295,7 +288,7 @@ ParseCloud.afterLogout = function (handler) { if (typeof handler === 'string' || isParseObjectConstructor(handler)) { // validation will occur downstream, this is to maintain internal // code consistency with the other hook types. - className = getClassName(handler); + className = triggers.getClassName(handler); handler = arguments[1]; } triggers.addTrigger(triggers.Types.afterLogout, className, handler, Parse.applicationId); @@ -327,7 +320,7 @@ ParseCloud.afterLogout = function (handler) { * @param {(Object|Function)} validator An optional function to help validating cloud code. This function can be an async function and should take one parameter a {@link Parse.Cloud.TriggerRequest}, or a {@link Parse.Cloud.ValidatorObject}. */ ParseCloud.afterSave = function (parseClass, handler, validationHandler) { - var className = getClassName(parseClass); + const className = triggers.getClassName(parseClass); validateValidator(validationHandler); triggers.addTrigger( triggers.Types.afterSave, @@ -363,7 +356,7 @@ ParseCloud.afterSave = function (parseClass, handler, validationHandler) { * @param {(Object|Function)} validator An optional function to help validating cloud code. This function can be an async function and should take one parameter a {@link Parse.Cloud.TriggerRequest}, or a {@link Parse.Cloud.ValidatorObject}. */ ParseCloud.afterDelete = function (parseClass, handler, validationHandler) { - var className = getClassName(parseClass); + const className = triggers.getClassName(parseClass); validateValidator(validationHandler); triggers.addTrigger( triggers.Types.afterDelete, @@ -399,7 +392,7 @@ ParseCloud.afterDelete = function (parseClass, handler, validationHandler) { * @param {(Object|Function)} validator An optional function to help validating cloud code. This function can be an async function and should take one parameter a {@link Parse.Cloud.BeforeFindRequest}, or a {@link Parse.Cloud.ValidatorObject}. */ ParseCloud.beforeFind = function (parseClass, handler, validationHandler) { - var className = getClassName(parseClass); + const className = triggers.getClassName(parseClass); validateValidator(validationHandler); triggers.addTrigger( triggers.Types.beforeFind, @@ -435,7 +428,7 @@ ParseCloud.beforeFind = function (parseClass, handler, validationHandler) { * @param {(Object|Function)} validator An optional function to help validating cloud code. This function can be an async function and should take one parameter a {@link Parse.Cloud.AfterFindRequest}, or a {@link Parse.Cloud.ValidatorObject}. */ ParseCloud.afterFind = function (parseClass, handler, validationHandler) { - const className = getClassName(parseClass); + const className = triggers.getClassName(parseClass); validateValidator(validationHandler); triggers.addTrigger( triggers.Types.afterFind, @@ -663,7 +656,7 @@ ParseCloud.sendEmail = function (data) { */ ParseCloud.beforeSubscribe = function (parseClass, handler, validationHandler) { validateValidator(validationHandler); - var className = getClassName(parseClass); + const className = triggers.getClassName(parseClass); triggers.addTrigger( triggers.Types.beforeSubscribe, className, @@ -701,7 +694,7 @@ ParseCloud.onLiveQueryEvent = function (handler) { * @param {(Object|Function)} validator An optional function to help validating cloud code. This function can be an async function and should take one parameter a {@link Parse.Cloud.LiveQueryEventTrigger}, or a {@link Parse.Cloud.ValidatorObject}. */ ParseCloud.afterLiveQueryEvent = function (parseClass, handler, validationHandler) { - const className = getClassName(parseClass); + const className = triggers.getClassName(parseClass); validateValidator(validationHandler); triggers.addTrigger( triggers.Types.afterEvent, diff --git a/src/triggers.js b/src/triggers.js index cdd4353128..4d1cb5fba9 100644 --- a/src/triggers.js +++ b/src/triggers.js @@ -46,6 +46,13 @@ const baseStore = function () { }); }; +export function getClassName(parseClass) { + if (parseClass && parseClass.className) { + return parseClass.className; + } + return parseClass; +} + function validateClassNameForTriggers(className, type) { if (type == Types.beforeSave && className === '_PushStatus') { // _PushStatus uses undocumented nested key increment ops From 197fcbda00affeef509ef95afde0fc54ec9be1d8 Mon Sep 17 00:00:00 2001 From: Brandon Scott Date: Fri, 8 Oct 2021 16:44:40 -0400 Subject: [PATCH 2/3] refactor: modernize HTTPRequest tests (#7604) --- CHANGELOG.md | 1 + spec/HTTPRequest.spec.js | 196 +++++++++++++++++---------------------- 2 files changed, 86 insertions(+), 111 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 42858f691a..9ba4f42830 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -157,6 +157,7 @@ ___ - Allow setting descending sort to full text queries (dblythy) [#7496](https://github.com/parse-community/parse-server/pull/7496) - Allow cloud string for ES modules (Daniel Blyth) [#7560](https://github.com/parse-community/parse-server/pull/7560) - docs: Introduce deprecation ID for reference in comments and online search (Manuel Trezza) [#7562](https://github.com/parse-community/parse-server/pull/7562) +- refactor: Modernize HTTPRequest tests (brandongregoryscott) [#7604](https://github.com/parse-community/parse-server/pull/7604) - Allow liveQuery on Session class (Daniel Blyth) [#7554](https://github.com/parse-community/parse-server/pull/7554) ## 4.10.4 diff --git a/spec/HTTPRequest.spec.js b/spec/HTTPRequest.spec.js index efd133a236..f218ff3c91 100644 --- a/spec/HTTPRequest.spec.js +++ b/spec/HTTPRequest.spec.js @@ -6,7 +6,7 @@ const httpRequest = require('../lib/cloud-code/httpRequest'), express = require('express'); const port = 13371; -const httpRequestServer = 'http://localhost:' + port; +const httpRequestServer = `http://localhost:${port}`; function startServer(done) { const app = express(); @@ -51,167 +51,136 @@ describe('httpRequest', () => { server.close(done); }); - it('should do /hello', done => { - httpRequest({ - url: httpRequestServer + '/hello', - }).then(function (httpResponse) { - expect(httpResponse.status).toBe(200); - expect(httpResponse.buffer).toEqual(new Buffer('{"response":"OK"}')); - expect(httpResponse.text).toEqual('{"response":"OK"}'); - expect(httpResponse.data.response).toEqual('OK'); - done(); - }, done.fail); + it('should do /hello', async () => { + const httpResponse = await httpRequest({ + url: `${httpRequestServer}/hello`, + }); + + expect(httpResponse.status).toBe(200); + expect(httpResponse.buffer).toEqual(Buffer.from('{"response":"OK"}')); + expect(httpResponse.text).toEqual('{"response":"OK"}'); + expect(httpResponse.data.response).toEqual('OK'); }); - it('should do not follow redirects by default', done => { - httpRequest({ - url: httpRequestServer + '/301', - }).then(function (httpResponse) { - expect(httpResponse.status).toBe(301); - done(); - }, done.fail); + it('should do not follow redirects by default', async () => { + const httpResponse = await httpRequest({ + url: `${httpRequestServer}/301`, + }); + + expect(httpResponse.status).toBe(301); }); - it('should follow redirects when set', done => { - httpRequest({ - url: httpRequestServer + '/301', + it('should follow redirects when set', async () => { + const httpResponse = await httpRequest({ + url: `${httpRequestServer}/301`, followRedirects: true, - }).then(function (httpResponse) { - expect(httpResponse.status).toBe(200); - expect(httpResponse.buffer).toEqual(new Buffer('{"response":"OK"}')); - expect(httpResponse.text).toEqual('{"response":"OK"}'); - expect(httpResponse.data.response).toEqual('OK'); - done(); - }, done.fail); + }); + + expect(httpResponse.status).toBe(200); + expect(httpResponse.buffer).toEqual(Buffer.from('{"response":"OK"}')); + expect(httpResponse.text).toEqual('{"response":"OK"}'); + expect(httpResponse.data.response).toEqual('OK'); }); - it('should fail on 404', done => { - let calls = 0; - httpRequest({ - url: httpRequestServer + '/404', - }).then( - function () { - calls++; - fail('should not succeed'); - done(); - }, - function (httpResponse) { - calls++; - expect(calls).toBe(1); - expect(httpResponse.status).toBe(404); - expect(httpResponse.buffer).toEqual(new Buffer('NO')); - expect(httpResponse.text).toEqual('NO'); - expect(httpResponse.data).toBe(undefined); - done(); - } + it('should fail on 404', async () => { + await expectAsync( + httpRequest({ + url: `${httpRequestServer}/404`, + }) + ).toBeRejectedWith( + jasmine.objectContaining({ + status: 404, + buffer: Buffer.from('NO'), + text: 'NO', + data: undefined, + }) ); }); - it('should post on echo', done => { - httpRequest({ + it('should post on echo', async () => { + const httpResponse = await httpRequest({ method: 'POST', - url: httpRequestServer + '/echo', + url: `${httpRequestServer}/echo`, body: { foo: 'bar', }, headers: { 'Content-Type': 'application/json', }, - }).then( - function (httpResponse) { - expect(httpResponse.status).toBe(200); - expect(httpResponse.data).toEqual({ foo: 'bar' }); - done(); - }, - function () { - fail('should not fail'); - done(); - } - ); + }); + + expect(httpResponse.status).toBe(200); + expect(httpResponse.data).toEqual({ foo: 'bar' }); }); - it('should encode a query string body by default', done => { + it('should encode a query string body by default', () => { const options = { body: { foo: 'bar' }, }; const result = httpRequest.encodeBody(options); + expect(result.body).toEqual('foo=bar'); expect(result.headers['Content-Type']).toEqual('application/x-www-form-urlencoded'); - done(); }); - it('should encode a JSON body', done => { + it('should encode a JSON body', () => { const options = { body: { foo: 'bar' }, headers: { 'Content-Type': 'application/json' }, }; const result = httpRequest.encodeBody(options); + expect(result.body).toEqual('{"foo":"bar"}'); - done(); }); - it('should encode a www-form body', done => { + + it('should encode a www-form body', () => { const options = { body: { foo: 'bar', bar: 'baz' }, headers: { 'cOntent-tYpe': 'application/x-www-form-urlencoded' }, }; const result = httpRequest.encodeBody(options); + expect(result.body).toEqual('foo=bar&bar=baz'); - done(); }); - it('should not encode a wrong content type', done => { + + it('should not encode a wrong content type', () => { const options = { body: { foo: 'bar', bar: 'baz' }, headers: { 'cOntent-tYpe': 'mime/jpeg' }, }; const result = httpRequest.encodeBody(options); + expect(result.body).toEqual({ foo: 'bar', bar: 'baz' }); - done(); }); - it('should fail gracefully', done => { - httpRequest({ - url: 'http://not a good url', - }).then(done.fail, function (error) { - expect(error).not.toBeUndefined(); - expect(error).not.toBeNull(); - done(); - }); + it('should fail gracefully', async () => { + await expectAsync( + httpRequest({ + url: 'http://not a good url', + }) + ).toBeRejected(); }); - it('should params object to query string', done => { - httpRequest({ - url: httpRequestServer + '/qs', + it('should params object to query string', async () => { + const httpResponse = await httpRequest({ + url: `${httpRequestServer}/qs`, params: { foo: 'bar', }, - }).then( - function (httpResponse) { - expect(httpResponse.status).toBe(200); - expect(httpResponse.data).toEqual({ foo: 'bar' }); - done(); - }, - function () { - fail('should not fail'); - done(); - } - ); + }); + + expect(httpResponse.status).toBe(200); + expect(httpResponse.data).toEqual({ foo: 'bar' }); }); - it('should params string to query string', done => { - httpRequest({ - url: httpRequestServer + '/qs', + it('should params string to query string', async () => { + const httpResponse = await httpRequest({ + url: `${httpRequestServer}/qs`, params: 'foo=bar&foo2=bar2', - }).then( - function (httpResponse) { - expect(httpResponse.status).toBe(200); - expect(httpResponse.data).toEqual({ foo: 'bar', foo2: 'bar2' }); - done(); - }, - function () { - fail('should not fail'); - done(); - } - ); + }); + + expect(httpResponse.status).toBe(200); + expect(httpResponse.data).toEqual({ foo: 'bar', foo2: 'bar2' }); }); it('should not crash with undefined body', () => { @@ -230,6 +199,7 @@ describe('httpRequest', () => { const serialized = JSON.stringify(httpResponse); const result = JSON.parse(serialized); + expect(result.text).toBe('hello'); expect(result.data).toBe(undefined); expect(result.body).toBe(undefined); @@ -251,43 +221,47 @@ describe('httpRequest', () => { }); it('serialized httpResponse correctly with body buffer string', () => { - const httpResponse = new HTTPResponse({}, new Buffer('hello')); + const httpResponse = new HTTPResponse({}, Buffer.from('hello')); expect(httpResponse.text).toBe('hello'); expect(httpResponse.data).toBe(undefined); const serialized = JSON.stringify(httpResponse); const result = JSON.parse(serialized); + expect(result.text).toBe('hello'); expect(result.data).toBe(undefined); }); it('serialized httpResponse correctly with body buffer JSON Object', () => { const json = '{"foo":"bar"}'; - const httpResponse = new HTTPResponse({}, new Buffer(json)); + const httpResponse = new HTTPResponse({}, Buffer.from(json)); const serialized = JSON.stringify(httpResponse); const result = JSON.parse(serialized); + expect(result.text).toEqual('{"foo":"bar"}'); expect(result.data).toEqual({ foo: 'bar' }); }); it('serialized httpResponse with Parse._encode should be allright', () => { const json = '{"foo":"bar"}'; - const httpResponse = new HTTPResponse({}, new Buffer(json)); + const httpResponse = new HTTPResponse({}, Buffer.from(json)); const encoded = Parse._encode(httpResponse); let foundData, foundText, foundBody = false; + for (const key in encoded) { - if (key == 'data') { + if (key === 'data') { foundData = true; } - if (key == 'text') { + if (key === 'text') { foundText = true; } - if (key == 'body') { + if (key === 'body') { foundBody = true; } } + expect(foundData).toBe(true); expect(foundText).toBe(true); expect(foundBody).toBe(false); From 68a3a875017d545253f0438c5d4c1c2b6c6c3b22 Mon Sep 17 00:00:00 2001 From: dblythy Date: Sat, 9 Oct 2021 11:34:09 +1100 Subject: [PATCH 3/3] fix: set objects in afterFind triggers (#7311) --- CHANGELOG.md | 1 + spec/CloudCode.spec.js | 47 +++++++++++++++++++++++++++ spec/ParseLiveQuery.spec.js | 38 ++++++++++++++++++++++ src/LiveQuery/ParseLiveQueryServer.js | 15 ++++----- src/triggers.js | 29 +++++++++++++---- 5 files changed, 115 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ba4f42830..19e4e61f1c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -154,6 +154,7 @@ ___ - Refactor: uniform issue templates across repos (Manuel Trezza) [#7528](https://github.com/parse-community/parse-server/pull/7528) - ci: bump ci environment (Manuel Trezza) [#7539](https://github.com/parse-community/parse-server/pull/7539) - CI now pushes docker images to Docker Hub (Corey Baker) [#7548](https://github.com/parse-community/parse-server/pull/7548) +- Allow afterFind and afterLiveQueryEvent to set unsaved pointers and keys (dblythy) [#7310](https://github.com/parse-community/parse-server/pull/7310) - Allow setting descending sort to full text queries (dblythy) [#7496](https://github.com/parse-community/parse-server/pull/7496) - Allow cloud string for ES modules (Daniel Blyth) [#7560](https://github.com/parse-community/parse-server/pull/7560) - docs: Introduce deprecation ID for reference in comments and online search (Manuel Trezza) [#7562](https://github.com/parse-community/parse-server/pull/7562) diff --git a/spec/CloudCode.spec.js b/spec/CloudCode.spec.js index 07d94a366f..adace31078 100644 --- a/spec/CloudCode.spec.js +++ b/spec/CloudCode.spec.js @@ -2391,6 +2391,53 @@ describe('afterFind hooks', () => { }); }); + it('can set a pointer object in afterFind', async () => { + const obj = new Parse.Object('MyObject'); + await obj.save(); + Parse.Cloud.afterFind('MyObject', async ({ objects }) => { + const otherObject = new Parse.Object('Test'); + otherObject.set('foo', 'bar'); + await otherObject.save(); + objects[0].set('Pointer', otherObject); + objects[0].set('xyz', 'yolo'); + expect(objects[0].get('Pointer').get('foo')).toBe('bar'); + }); + const query = new Parse.Query('MyObject'); + query.equalTo('objectId', obj.id); + const obj2 = await query.first(); + expect(obj2.get('xyz')).toBe('yolo'); + const pointer = obj2.get('Pointer'); + expect(pointer.get('foo')).toBe('bar'); + }); + + it('can set invalid object in afterFind', async () => { + const obj = new Parse.Object('MyObject'); + await obj.save(); + Parse.Cloud.afterFind('MyObject', () => [{}]); + const query = new Parse.Query('MyObject'); + query.equalTo('objectId', obj.id); + const obj2 = await query.first(); + expect(obj2).toBeDefined(); + expect(obj2.toJSON()).toEqual({}); + expect(obj2.id).toBeUndefined(); + }); + + it('can return a unsaved object in afterFind', async () => { + const obj = new Parse.Object('MyObject'); + await obj.save(); + Parse.Cloud.afterFind('MyObject', async () => { + const otherObject = new Parse.Object('Test'); + otherObject.set('foo', 'bar'); + return [otherObject]; + }); + const query = new Parse.Query('MyObject'); + const obj2 = await query.first(); + expect(obj2.get('foo')).toEqual('bar'); + expect(obj2.id).toBeUndefined(); + await obj2.save(); + expect(obj2.id).toBeDefined(); + }); + it('should have request headers', done => { Parse.Cloud.afterFind('MyObject', req => { expect(req.headers).toBeDefined(); diff --git a/spec/ParseLiveQuery.spec.js b/spec/ParseLiveQuery.spec.js index 2393467544..ab78a4cfa7 100644 --- a/spec/ParseLiveQuery.spec.js +++ b/spec/ParseLiveQuery.spec.js @@ -358,6 +358,44 @@ describe('ParseLiveQuery', function () { await object.save(); }); + it('can handle afterEvent set pointers', async done => { + await reconfigureServer({ + liveQuery: { + classNames: ['TestObject'], + }, + startLiveQueryServer: true, + verbose: false, + silent: true, + }); + + const object = new TestObject(); + await object.save(); + + const secondObject = new Parse.Object('Test2'); + secondObject.set('foo', 'bar'); + await secondObject.save(); + + Parse.Cloud.afterLiveQueryEvent('TestObject', async ({ object }) => { + const query = new Parse.Query('Test2'); + const obj = await query.first(); + object.set('obj', obj); + }); + + const query = new Parse.Query(TestObject); + query.equalTo('objectId', object.id); + const subscription = await query.subscribe(); + subscription.on('update', object => { + expect(object.get('obj')).toBeDefined(); + expect(object.get('obj').get('foo')).toBe('bar'); + done(); + }); + subscription.on('error', () => { + fail('error should not have been called.'); + }); + object.set({ foo: 'bar' }); + await object.save(); + }); + it('can handle async afterEvent modification', async done => { await reconfigureServer({ liveQuery: { diff --git a/src/LiveQuery/ParseLiveQueryServer.js b/src/LiveQuery/ParseLiveQueryServer.js index 0091c459c0..1a7f830327 100644 --- a/src/LiveQuery/ParseLiveQueryServer.js +++ b/src/LiveQuery/ParseLiveQueryServer.js @@ -10,7 +10,7 @@ import { ParsePubSub } from './ParsePubSub'; import SchemaController from '../Controllers/SchemaController'; import _ from 'lodash'; import { v4 as uuidv4 } from 'uuid'; -import { runLiveQueryEventHandlers, getTrigger, runTrigger } from '../triggers'; +import { runLiveQueryEventHandlers, getTrigger, runTrigger, toJSONwithObjects } from '../triggers'; import { getAuthForSessionToken, Auth } from '../Auth'; import { getCacheController } from '../Controllers'; import LRU from 'lru-cache'; @@ -183,8 +183,7 @@ class ParseLiveQueryServer { return; } if (res.object && typeof res.object.toJSON === 'function') { - deletedParseObject = res.object.toJSON(); - deletedParseObject.className = className; + deletedParseObject = toJSONwithObjects(res.object, res.object.className || className); } if ( (deletedParseObject.className === '_User' || @@ -337,13 +336,13 @@ class ParseLiveQueryServer { return; } if (res.object && typeof res.object.toJSON === 'function') { - currentParseObject = res.object.toJSON(); - currentParseObject.className = res.object.className || className; + currentParseObject = toJSONwithObjects(res.object, res.object.className || className); } - if (res.original && typeof res.original.toJSON === 'function') { - originalParseObject = res.original.toJSON(); - originalParseObject.className = res.original.className || className; + originalParseObject = toJSONwithObjects( + res.original, + res.original.className || className + ); } if ( (currentParseObject.className === '_User' || diff --git a/src/triggers.js b/src/triggers.js index 4d1cb5fba9..8320b5fb74 100644 --- a/src/triggers.js +++ b/src/triggers.js @@ -168,6 +168,27 @@ export function _unregisterAll() { Object.keys(_triggerStore).forEach(appId => delete _triggerStore[appId]); } +export function toJSONwithObjects(object, className) { + if (!object || !object.toJSON) { + return {}; + } + const toJSON = object.toJSON(); + const stateController = Parse.CoreManager.getObjectStateController(); + const [pending] = stateController.getPendingOps(object._getStateIdentifier()); + for (const key in pending) { + const val = object.get(key); + if (!val || !val._toFullJSON) { + toJSON[key] = val; + continue; + } + toJSON[key] = val._toFullJSON(); + } + if (className) { + toJSON.className = className; + } + return toJSON; +} + export function getTrigger(className, triggerType, applicationId) { if (!applicationId) { throw 'Missing ApplicationID'; @@ -323,7 +344,7 @@ export function getResponseObject(request, resolve, reject) { response = request.objects; } response = response.map(object => { - return object.toJSON(); + return toJSONwithObjects(object); }); return resolve(response); } @@ -451,12 +472,6 @@ export function maybeRunAfterFindTrigger( const response = trigger(request); if (response && typeof response.then === 'function') { return response.then(results => { - if (!results) { - throw new Parse.Error( - Parse.Error.SCRIPT_FAILED, - 'AfterFind expect results to be returned in the promise' - ); - } return results; }); }