From db2d4fe0d7907db262ad290ad8fc41f640905967 Mon Sep 17 00:00:00 2001 From: Daniel Date: Sat, 30 May 2026 13:47:40 +1000 Subject: [PATCH 1/3] refactor: Remove internal Parse.Query usage from Auth and LiveQuery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Towards #8787. Auth's session/role resolution and LiveQuery's _clearCachedRoles now always go through RestQuery instead of the Parse JS SDK's Parse.Query. The !config SDK fallback branches in Auth (getAuthForSessionToken, getRolesForUser, getRolesByIds) are removed — all real callers already pass a config, so a config is now required. Threads config into the LiveQuery getAuthForSessionToken calls. Deletes the dead SessionTokenCache (exported but never used in src). Drops the obsolete no-config Auth/Role specs and rewrites the role-based ACL LiveQuery specs to mock the auth seam instead of the removed Parse.Query. --- spec/Auth.spec.js | 37 -------- spec/ParseLiveQueryServer.spec.js | 73 ++++----------- spec/ParseRole.spec.js | 4 - spec/SessionTokenCache.spec.js | 54 ----------- src/Auth.js | 128 ++++++++++---------------- src/LiveQuery/ParseLiveQueryServer.ts | 31 +++++-- src/LiveQuery/SessionTokenCache.js | 50 ---------- 7 files changed, 91 insertions(+), 286 deletions(-) delete mode 100644 spec/SessionTokenCache.spec.js delete mode 100644 src/LiveQuery/SessionTokenCache.js diff --git a/spec/Auth.spec.js b/spec/Auth.spec.js index a055cda5bc..f387662631 100644 --- a/spec/Auth.spec.js +++ b/spec/Auth.spec.js @@ -114,20 +114,6 @@ describe('Auth', () => { expect(session.get('expiresAt') > expiry).toBeTrue(); }); - it('should load auth without a config', async () => { - const user = new Parse.User(); - await user.signUp({ - username: 'hello', - password: 'password', - }); - expect(user.getSessionToken()).not.toBeUndefined(); - const userAuth = await getAuthForSessionToken({ - sessionToken: user.getSessionToken(), - }); - expect(userAuth.user instanceof Parse.User).toBe(true); - expect(userAuth.user.id).toBe(user.id); - }); - it('should load auth with a config', async () => { const user = new Parse.User(); await user.signUp({ @@ -146,29 +132,6 @@ describe('Auth', () => { describe('getRolesForUser', () => { const rolesNumber = 100; - it('should load all roles without config', async () => { - const user = new Parse.User(); - await user.signUp({ - username: 'hello', - password: 'password', - }); - expect(user.getSessionToken()).not.toBeUndefined(); - const userAuth = await getAuthForSessionToken({ - sessionToken: user.getSessionToken(), - }); - const roles = []; - for (let i = 0; i < rolesNumber; i++) { - const acl = new Parse.ACL(); - const role = new Parse.Role('roleloadtest' + i, acl); - role.getUsers().add([user]); - roles.push(role); - } - const savedRoles = await Parse.Object.saveAll(roles); - expect(savedRoles.length).toBe(rolesNumber); - const cloudRoles = await userAuth.getRolesForUser(); - expect(cloudRoles.length).toBe(rolesNumber); - }); - it('should load all roles with config', async () => { const user = new Parse.User(); await user.signUp({ diff --git a/spec/ParseLiveQueryServer.spec.js b/spec/ParseLiveQueryServer.spec.js index 62bf33d327..de1e812c65 100644 --- a/spec/ParseLiveQueryServer.spec.js +++ b/spec/ParseLiveQueryServer.spec.js @@ -1506,34 +1506,16 @@ describe('ParseLiveQueryServer', function () { }; const requestId = 0; - spyOn(Parse, 'Query').and.callFake(function () { - let shouldReturn = false; - return { - equalTo() { - shouldReturn = true; - // Nothing to do here - return this; + // The user has the "liveQueryRead" role, but the ACL only grants read access + // to "otherLiveQueryRead", so it should not match. + spyOn(parseLiveQueryServer, 'getAuthForSessionToken').and.returnValue( + Promise.resolve({ + userId: 'someUserId', + auth: { + getUserRoles: () => Promise.resolve(['role:liveQueryRead']), }, - containedIn() { - shouldReturn = false; - return this; - }, - find() { - if (!shouldReturn) { - return Promise.resolve([]); - } - //Return a role with the name "liveQueryRead" as that is what was set on the ACL - const liveQueryRole = new Parse.Role('liveQueryRead', new Parse.ACL()); - liveQueryRole.id = 'abcdef1234'; - return Promise.resolve([liveQueryRole]); - }, - }; - }); - - parseLiveQueryServer._matchesACL(acl, client, requestId).then(function (isMatched) { - expect(isMatched).toBe(false); - done(); - }); + }) + ); parseLiveQueryServer._matchesACL(acl, client, requestId).then(function (isMatched) { expect(isMatched).toBe(false); @@ -1553,36 +1535,15 @@ describe('ParseLiveQueryServer', function () { }; const requestId = 0; - spyOn(Parse, 'Query').and.callFake(function () { - let shouldReturn = false; - return { - equalTo() { - shouldReturn = true; - // Nothing to do here - return this; - }, - containedIn() { - shouldReturn = false; - return this; - }, - find() { - if (!shouldReturn) { - return Promise.resolve([]); - } - //Return a role with the name "liveQueryRead" as that is what was set on the ACL - const liveQueryRole = new Parse.Role('liveQueryRead', new Parse.ACL()); - liveQueryRole.id = 'abcdef1234'; - return Promise.resolve([liveQueryRole]); - }, - each(callback) { - //Return a role with the name "liveQueryRead" as that is what was set on the ACL - const liveQueryRole = new Parse.Role('liveQueryRead', new Parse.ACL()); - liveQueryRole.id = 'abcdef1234'; - callback(liveQueryRole); - return Promise.resolve(); + // The user has the "liveQueryRead" role, which the ACL grants read access to. + spyOn(parseLiveQueryServer, 'getAuthForSessionToken').and.returnValue( + Promise.resolve({ + userId: 'someUserId', + auth: { + getUserRoles: () => Promise.resolve(['role:liveQueryRead']), }, - }; - }); + }) + ); parseLiveQueryServer._matchesACL(acl, client, requestId).then(function (isMatched) { expect(isMatched).toBe(true); diff --git a/spec/ParseRole.spec.js b/spec/ParseRole.spec.js index 95e6189a6a..a2caf6bbec 100644 --- a/spec/ParseRole.spec.js +++ b/spec/ParseRole.spec.js @@ -201,10 +201,6 @@ describe('Parse Role testing', () => { testLoadRoles(Config.get('test'), done); }); - it('should recursively load roles without config', done => { - testLoadRoles(undefined, done); - }); - it('_Role object should not save without name.', done => { const role = new Parse.Role(); role.save(null, { useMasterKey: true }).then( diff --git a/spec/SessionTokenCache.spec.js b/spec/SessionTokenCache.spec.js deleted file mode 100644 index 6b3c83df62..0000000000 --- a/spec/SessionTokenCache.spec.js +++ /dev/null @@ -1,54 +0,0 @@ -const SessionTokenCache = require('../lib/LiveQuery/SessionTokenCache').SessionTokenCache; - -describe('SessionTokenCache', function () { - beforeEach(function (done) { - const Parse = require('parse/node'); - - spyOn(Parse, 'Query').and.returnValue({ - first: jasmine.createSpy('first').and.returnValue( - Promise.resolve( - new Parse.Object('_Session', { - user: new Parse.User({ id: 'userId' }), - }) - ) - ), - equalTo: function () {}, - }); - - done(); - }); - - it('can get undefined userId', function (done) { - const sessionTokenCache = new SessionTokenCache(); - - sessionTokenCache.getUserId(undefined).then( - () => {}, - error => { - expect(error).not.toBeNull(); - done(); - } - ); - }); - - it('can get existing userId', function (done) { - const sessionTokenCache = new SessionTokenCache(); - const sessionToken = 'sessionToken'; - const userId = 'userId'; - sessionTokenCache.cache.set(sessionToken, userId); - - sessionTokenCache.getUserId(sessionToken).then(userIdFromCache => { - expect(userIdFromCache).toBe(userId); - done(); - }); - }); - - it('can get new userId', function (done) { - const sessionTokenCache = new SessionTokenCache(); - - sessionTokenCache.getUserId('sessionToken').then(userIdFromCache => { - expect(userIdFromCache).toBe('userId'); - expect(sessionTokenCache.cache.size).toBe(1); - done(); - }); - }); -}); diff --git a/src/Auth.js b/src/Auth.js index dd75aaace2..39160a18ab 100644 --- a/src/Auth.js +++ b/src/Auth.js @@ -154,32 +154,21 @@ const getAuthForSessionToken = async function ({ } } - let results; - if (config) { - const restOptions = { - limit: 1, - include: 'user', - }; - const RestQuery = require('./RestQuery'); - const query = await RestQuery({ - method: RestQuery.Method.get, - config, - runBeforeFind: false, - auth: master(config), - className: '_Session', - restWhere: { sessionToken }, - restOptions, - }); - results = (await query.execute()).results; - } else { - results = ( - await new Parse.Query(Parse.Session) - .limit(1) - .include('user') - .equalTo('sessionToken', sessionToken) - .find({ useMasterKey: true }) - ).map(obj => obj.toJSON()); - } + const restOptions = { + limit: 1, + include: 'user', + }; + const RestQuery = require('./RestQuery'); + const query = await RestQuery({ + method: RestQuery.Method.get, + config, + runBeforeFind: false, + auth: master(config), + className: '_Session', + restWhere: { sessionToken }, + restOptions, + }); + const results = (await query.execute()).results; if (results.length !== 1 || !results[0]['user']) { throw new Parse.Error(Parse.Error.INVALID_SESSION_TOKEN, 'Invalid session token'); @@ -267,29 +256,23 @@ Auth.prototype.getUserRoles = function () { Auth.prototype.getRolesForUser = async function () { //Stack all Parse.Role const results = []; - if (this.config) { - const restWhere = { - users: { - __type: 'Pointer', - className: '_User', - objectId: this.user.id, - }, - }; - const RestQuery = require('./RestQuery'); - const query = await RestQuery({ - method: RestQuery.Method.find, - runBeforeFind: false, - config: this.config, - auth: master(this.config), - className: '_Role', - restWhere, - }); - await query.each(result => results.push(result)); - } else { - await new Parse.Query(Parse.Role) - .equalTo('users', this.user) - .each(result => results.push(result.toJSON()), { useMasterKey: true }); - } + const restWhere = { + users: { + __type: 'Pointer', + className: '_User', + objectId: this.user.id, + }, + }; + const RestQuery = require('./RestQuery'); + const query = await RestQuery({ + method: RestQuery.Method.find, + runBeforeFind: false, + config: this.config, + auth: master(this.config), + className: '_Role', + restWhere, + }); + await query.each(result => results.push(result)); return results; }; @@ -355,37 +338,24 @@ Auth.prototype.clearRoleCache = function (sessionToken) { Auth.prototype.getRolesByIds = async function (ins) { const results = []; // Build an OR query across all parentRoles - if (!this.config) { - await new Parse.Query(Parse.Role) - .containedIn( - 'roles', - ins.map(id => { - const role = new Parse.Object(Parse.Role); - role.id = id; - return role; - }) - ) - .each(result => results.push(result.toJSON()), { useMasterKey: true }); - } else { - const roles = ins.map(id => { - return { - __type: 'Pointer', - className: '_Role', - objectId: id, - }; - }); - const restWhere = { roles: { $in: roles } }; - const RestQuery = require('./RestQuery'); - const query = await RestQuery({ - method: RestQuery.Method.find, - config: this.config, - runBeforeFind: false, - auth: master(this.config), + const roles = ins.map(id => { + return { + __type: 'Pointer', className: '_Role', - restWhere, - }); - await query.each(result => results.push(result)); - } + objectId: id, + }; + }); + const restWhere = { roles: { $in: roles } }; + const RestQuery = require('./RestQuery'); + const query = await RestQuery({ + method: RestQuery.Method.find, + config: this.config, + runBeforeFind: false, + auth: master(this.config), + className: '_Role', + restWhere, + }); + await query.each(result => results.push(result)); return results; }; diff --git a/src/LiveQuery/ParseLiveQueryServer.ts b/src/LiveQuery/ParseLiveQueryServer.ts index f835fe2140..ed230bf259 100644 --- a/src/LiveQuery/ParseLiveQueryServer.ts +++ b/src/LiveQuery/ParseLiveQueryServer.ts @@ -18,7 +18,8 @@ import { resolveError, toJSONwithObjects, } from '../triggers'; -import { getAuthForSessionToken, Auth } from '../Auth'; +import { getAuthForSessionToken, master, Auth } from '../Auth'; +import RestQuery from '../RestQuery'; import { getCacheController, getDatabaseController } from '../Controllers'; import Config from '../Config'; import { LRUCache as LRU } from 'lru-cache'; @@ -606,19 +607,36 @@ class ParseLiveQueryServer { async _clearCachedRoles(userId: string) { try { - const validTokens = await new Parse.Query(Parse.Session) - .equalTo('user', Parse.User.createWithoutData(userId)) - .find({ useMasterKey: true }); + const config = Config.get(this.config.appId); + const query = await RestQuery({ + method: RestQuery.Method.find, + config, + runBeforeFind: false, + auth: master(config), + className: '_Session', + restWhere: { + user: { + __type: 'Pointer', + className: '_User', + objectId: userId, + }, + }, + }); + const { results: validTokens } = await query.execute(); await Promise.all( validTokens.map(async token => { - const sessionToken = token.get('sessionToken'); + const sessionToken = token.sessionToken; const authPromise = this.authCache.get(sessionToken); if (!authPromise) { return; } const [auth1, auth2] = await Promise.all([ authPromise, - getAuthForSessionToken({ cacheController: this.cacheController, sessionToken }), + getAuthForSessionToken({ + cacheController: this.cacheController, + sessionToken, + config: Config.get(this.config.appId), + }), ]); auth1.auth?.clearRoleCache(sessionToken); auth2.auth?.clearRoleCache(sessionToken); @@ -641,6 +659,7 @@ class ParseLiveQueryServer { const authPromise = getAuthForSessionToken({ cacheController: this.cacheController, sessionToken: sessionToken, + config: Config.get(this.config.appId), }) .then(auth => { return { auth, userId: auth && auth.user && auth.user.id }; diff --git a/src/LiveQuery/SessionTokenCache.js b/src/LiveQuery/SessionTokenCache.js deleted file mode 100644 index a7f52b65a0..0000000000 --- a/src/LiveQuery/SessionTokenCache.js +++ /dev/null @@ -1,50 +0,0 @@ -import Parse from 'parse/node'; -import { LRUCache as LRU } from 'lru-cache'; -import logger from '../logger'; - -function userForSessionToken(sessionToken) { - var q = new Parse.Query('_Session'); - q.equalTo('sessionToken', sessionToken); - return q.first({ useMasterKey: true }).then(function (session) { - if (!session) { - return Promise.reject('No session found for session token'); - } - return session.get('user'); - }); -} - -class SessionTokenCache { - cache: Object; - - constructor(timeout: number = 30 * 24 * 60 * 60 * 1000, maxSize: number = 10000) { - this.cache = new LRU({ - max: maxSize, - ttl: timeout, - }); - } - - getUserId(sessionToken: string): any { - if (!sessionToken) { - return Promise.reject('Empty sessionToken'); - } - const userId = this.cache.get(sessionToken); - if (userId) { - logger.verbose('Fetch userId %s of sessionToken %s from Cache', userId, sessionToken); - return Promise.resolve(userId); - } - return userForSessionToken(sessionToken).then( - user => { - logger.verbose('Fetch userId %s of sessionToken %s from Parse', user.id, sessionToken); - const userId = user.id; - this.cache.set(sessionToken, userId); - return Promise.resolve(userId); - }, - error => { - logger.error('Can not fetch userId for sessionToken %j, error %j', sessionToken, error); - return Promise.reject(error); - } - ); - } -} - -export { SessionTokenCache }; From bb2d8aec6bcfb86d8f093de70955a7cdedd474d1 Mon Sep 17 00:00:00 2001 From: Daniel Date: Sat, 30 May 2026 14:01:00 +1000 Subject: [PATCH 2/3] refactor: Isolate Parse.Query SDK usage behind QueryAdapter Introduce src/cloud-code/QueryAdapter.js as the single boundary between parse-server's internal REST/JSON query format and the Parse JS SDK's Parse.Query. Core code (triggers, rest, RestQuery, LiveQuery) now calls inflateQuery/deflateQuery/isQuery/applyQueryToRest instead of constructing or inspecting Parse.Query directly, so the SDK query type lives in one place. Towards #8787. --- src/LiveQuery/ParseLiveQueryServer.ts | 8 ++-- src/LiveQuery/QueryTools.js | 5 +- src/RestQuery.js | 4 +- src/cloud-code/QueryAdapter.js | 68 +++++++++++++++++++++++++++ src/rest.js | 3 +- src/triggers.js | 57 +++------------------- 6 files changed, 85 insertions(+), 60 deletions(-) create mode 100644 src/cloud-code/QueryAdapter.js diff --git a/src/LiveQuery/ParseLiveQueryServer.ts b/src/LiveQuery/ParseLiveQueryServer.ts index ed230bf259..b72e9d0ee2 100644 --- a/src/LiveQuery/ParseLiveQueryServer.ts +++ b/src/LiveQuery/ParseLiveQueryServer.ts @@ -20,6 +20,7 @@ import { } from '../triggers'; import { getAuthForSessionToken, master, Auth } from '../Auth'; import RestQuery from '../RestQuery'; +import { inflateQuery, deflateQuery } from '../cloud-code/QueryAdapter'; import { getCacheController, getDatabaseController } from '../Controllers'; import Config from '../Config'; import { LRUCache as LRU } from 'lru-cache'; @@ -1015,13 +1016,10 @@ class ParseLiveQueryServer { request.user = auth.user; } - const parseQuery = new Parse.Query(className); - parseQuery.withJSON(request.query); - request.query = parseQuery; + request.query = inflateQuery(className, request.query); await runTrigger(trigger, `beforeSubscribe.${className}`, request, auth); - const query = request.query.toJSON(); - request.query = query; + request.query = deflateQuery(request.query); } if (className === '_Session') { diff --git a/src/LiveQuery/QueryTools.js b/src/LiveQuery/QueryTools.js index 37cfbfa47f..cf0b51329d 100644 --- a/src/LiveQuery/QueryTools.js +++ b/src/LiveQuery/QueryTools.js @@ -3,6 +3,7 @@ var Id = require('./Id'); var Parse = require('parse/node'); var vm = require('vm'); var logger = require('../logger').default; +const { isQuery } = require('../cloud-code/QueryAdapter'); var regexTimeout = 0; // IMPORTANT: vmContext is shared across all calls for performance (vm.createContext() is expensive). @@ -97,7 +98,7 @@ function stringify(object): string { * skip, and limit. */ function queryHash(query) { - if (query instanceof Parse.Query) { + if (isQuery(query)) { query = { className: query.className, where: query._where, @@ -169,7 +170,7 @@ function contains(haystack: Array, needle: any): boolean { * queries, we can avoid building a full-blown query tool. */ function matchesQuery(object: any, query: any): boolean { - if (query instanceof Parse.Query) { + if (isQuery(query)) { var className = object.id instanceof Id ? object.id.className : object.className; if (className !== query.className) { return false; diff --git a/src/RestQuery.js b/src/RestQuery.js index 8456db6a3d..8ac8aacace 100644 --- a/src/RestQuery.js +++ b/src/RestQuery.js @@ -5,6 +5,7 @@ var SchemaController = require('./Controllers/SchemaController'); var Parse = require('parse/node').Parse; var logger = require('./logger').default; const triggers = require('./triggers'); +const { inflateQuery } = require('./cloud-code/QueryAdapter'); const { continueWhile } = require('parse/lib/node/promiseUtils'); const AlwaysSelectedKeys = ['objectId', 'createdAt', 'updatedAt', 'ACL']; const { enforceRoleSecurity } = require('./SharedRest'); @@ -1121,8 +1122,7 @@ _UnsafeRestQuery.prototype.runAfterFindTrigger = function () { const json = Object.assign({}, this.restOptions); json.where = this.restWhere; - const parseQuery = new Parse.Query(this.className); - parseQuery.withJSON(json); + const parseQuery = inflateQuery(this.className, json); // Run afterFind trigger and set the new results return triggers .maybeRunAfterFindTrigger( diff --git a/src/cloud-code/QueryAdapter.js b/src/cloud-code/QueryAdapter.js new file mode 100644 index 0000000000..d650141578 --- /dev/null +++ b/src/cloud-code/QueryAdapter.js @@ -0,0 +1,68 @@ +// QueryAdapter — the single boundary between parse-server's internal, +// SDK-agnostic query format and the Parse JS SDK's `Parse.Query`. +// +// parse-server represents a query internally as plain JSON +// (`{ className, where, ...restOptions }`). Cloud Code triggers +// (`beforeFind`, `afterFind`, `beforeSubscribe`), however, are a public +// contract that hands the handler a real `Parse.Query` instance and may +// receive a modified one back. This module is the only place in `src/` that +// constructs or inspects a `Parse.Query`, so the rest of the codebase stays +// free of a direct SDK dependency on the query type. Towards #8787. + +import Parse from 'parse/node'; + +// Fields that `Parse.Query#toJSON()` may surface and that map onto +// parse-server's `restOptions`. `where` is handled separately as `restWhere`. +const REST_OPTION_KEYS = [ + 'limit', + 'skip', + 'include', + 'excludeKeys', + 'explain', + 'keys', + 'order', + 'hint', + 'comment', +]; + +// Build a `Parse.Query` from parse-server's internal format so it can be +// handed to a Cloud Code trigger. `json` is `{ where, ...restOptions }`; +// omit it to produce an empty query for the class. +export function inflateQuery(className, json) { + const query = new Parse.Query(className); + if (json) { + query.withJSON(json); + } + return query; +} + +// Convert a `Parse.Query` (typically one a trigger returned or mutated) back +// into parse-server's internal JSON format. +export function deflateQuery(query) { + return query.toJSON(); +} + +// Whether a value is a `Parse.Query` instance. Used where a trigger may return +// either a modified query or some other value. +export function isQuery(value) { + return value instanceof Parse.Query; +} + +// Merge the JSON of a (possibly trigger-modified) `Parse.Query` onto existing +// `restWhere` / `restOptions`, preserving the field-by-field override semantics +// the `beforeFind` trigger has always used. +export function applyQueryToRest(query, restWhere, restOptions) { + const jsonQuery = deflateQuery(query); + if (jsonQuery.where) { + restWhere = jsonQuery.where; + } + for (const key of REST_OPTION_KEYS) { + if (jsonQuery[key]) { + restOptions = restOptions || {}; + restOptions[key] = jsonQuery[key]; + } + } + return { restWhere, restOptions }; +} + +export default { inflateQuery, deflateQuery, isQuery, applyQueryToRest }; diff --git a/src/rest.js b/src/rest.js index 7a78f2f8b5..ca805e3288 100644 --- a/src/rest.js +++ b/src/rest.js @@ -12,6 +12,7 @@ var Parse = require('parse/node').Parse; var RestQuery = require('./RestQuery'); var RestWrite = require('./RestWrite'); var triggers = require('./triggers'); +const { inflateQuery } = require('./cloud-code/QueryAdapter'); const Auth = require('./Auth'); const { enforceRoleSecurity } = require('./SharedRest'); const { createSanitizedError } = require('./Error'); @@ -106,7 +107,7 @@ async function runFindTriggers( className, objectsForAfterFind, config, - new Parse.Query(className).withJSON({ where: restWhere, ...restOptions }), + inflateQuery(className, { where: restWhere, ...restOptions }), context, isGet ); diff --git a/src/triggers.js b/src/triggers.js index f66d96f942..4ac2582d5f 100644 --- a/src/triggers.js +++ b/src/triggers.js @@ -2,6 +2,7 @@ import Parse from 'parse/node'; import { logger } from './logger'; import Utils from './Utils'; +import { inflateQuery, isQuery, applyQueryToRest } from './cloud-code/QueryAdapter'; export const Types = { beforeLogin: 'beforeLogin', @@ -493,16 +494,12 @@ export function maybeRunAfterFindTrigger( const request = getRequestObject(triggerType, auth, null, null, config, context, isGet); // Convert query parameter to Parse.Query instance - if (query instanceof Parse.Query) { + if (isQuery(query)) { request.query = query; } else if (typeof query === 'object' && query !== null) { - const parseQueryInstance = new Parse.Query(classNameQuery); - if (query.where) { - parseQueryInstance.withJSON(query); - } - request.query = parseQueryInstance; + request.query = inflateQuery(classNameQuery, query); } else { - request.query = new Parse.Query(classNameQuery); + request.query = inflateQuery(classNameQuery); } const { success, error } = getResponseObject( @@ -584,8 +581,7 @@ export function maybeRunQueryTrigger( const json = Object.assign({}, restOptions); json.where = restWhere; - const parseQuery = new Parse.Query(className); - parseQuery.withJSON(json); + const parseQuery = inflateQuery(className, json); let count = false; if (restOptions) { @@ -613,49 +609,10 @@ export function maybeRunQueryTrigger( .then( result => { let queryResult = parseQuery; - if (result && result instanceof Parse.Query) { + if (result && isQuery(result)) { queryResult = result; } - const jsonQuery = queryResult.toJSON(); - if (jsonQuery.where) { - restWhere = jsonQuery.where; - } - if (jsonQuery.limit) { - restOptions = restOptions || {}; - restOptions.limit = jsonQuery.limit; - } - if (jsonQuery.skip) { - restOptions = restOptions || {}; - restOptions.skip = jsonQuery.skip; - } - if (jsonQuery.include) { - restOptions = restOptions || {}; - restOptions.include = jsonQuery.include; - } - if (jsonQuery.excludeKeys) { - restOptions = restOptions || {}; - restOptions.excludeKeys = jsonQuery.excludeKeys; - } - if (jsonQuery.explain) { - restOptions = restOptions || {}; - restOptions.explain = jsonQuery.explain; - } - if (jsonQuery.keys) { - restOptions = restOptions || {}; - restOptions.keys = jsonQuery.keys; - } - if (jsonQuery.order) { - restOptions = restOptions || {}; - restOptions.order = jsonQuery.order; - } - if (jsonQuery.hint) { - restOptions = restOptions || {}; - restOptions.hint = jsonQuery.hint; - } - if (jsonQuery.comment) { - restOptions = restOptions || {}; - restOptions.comment = jsonQuery.comment; - } + ({ restWhere, restOptions } = applyQueryToRest(queryResult, restWhere, restOptions)); if (requestObject.readPreference) { restOptions = restOptions || {}; restOptions.readPreference = requestObject.readPreference; From 0638023d1d8d875af8643bdda5f8a2afa1ed1e06 Mon Sep 17 00:00:00 2001 From: Daniel Date: Sat, 30 May 2026 14:51:47 +1000 Subject: [PATCH 3/3] fix: Preserve falsy beforeFind query overrides in QueryAdapter applyQueryToRest tested option truthiness, so an explicit falsy override from a beforeFind trigger (e.g. query.limit(0)) was silently dropped. Test presence with hasOwnProperty instead. Parse.Query#toJSON only emits keys that were set, so no default values leak in. Adds a regression test. --- spec/CloudCode.spec.js | 13 +++++++++++++ src/cloud-code/QueryAdapter.js | 7 ++++--- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/spec/CloudCode.spec.js b/spec/CloudCode.spec.js index 941d896aae..28e40d9896 100644 --- a/spec/CloudCode.spec.js +++ b/spec/CloudCode.spec.js @@ -2673,6 +2673,19 @@ describe('beforeFind hooks', () => { }); }); + it('should preserve a falsy query override from beforeFind (limit 0)', async () => { + Parse.Cloud.beforeFind('MyObject', req => { + req.query.limit(0); + }); + + const obj0 = new Parse.Object('MyObject'); + const obj1 = new Parse.Object('MyObject'); + await Parse.Object.saveAll([obj0, obj1]); + + const results = await new Parse.Query('MyObject').find(); + expect(results.length).toBe(0); + }); + it('should have object found with nested relational data query', async () => { const obj1 = Parse.Object.extend('TestObject'); const obj2 = Parse.Object.extend('TestObject2'); diff --git a/src/cloud-code/QueryAdapter.js b/src/cloud-code/QueryAdapter.js index d650141578..c561ccb987 100644 --- a/src/cloud-code/QueryAdapter.js +++ b/src/cloud-code/QueryAdapter.js @@ -49,15 +49,16 @@ export function isQuery(value) { } // Merge the JSON of a (possibly trigger-modified) `Parse.Query` onto existing -// `restWhere` / `restOptions`, preserving the field-by-field override semantics -// the `beforeFind` trigger has always used. +// `restWhere` / `restOptions` with the field-by-field override semantics the +// `beforeFind` trigger uses. Presence is tested via `hasOwnProperty` so an +// explicit falsy override (e.g. `limit(0)`) is preserved rather than dropped. export function applyQueryToRest(query, restWhere, restOptions) { const jsonQuery = deflateQuery(query); if (jsonQuery.where) { restWhere = jsonQuery.where; } for (const key of REST_OPTION_KEYS) { - if (jsonQuery[key]) { + if (Object.prototype.hasOwnProperty.call(jsonQuery, key)) { restOptions = restOptions || {}; restOptions[key] = jsonQuery[key]; }