diff --git a/spec/ParseLiveQueryQuery.spec.js b/spec/ParseLiveQueryQuery.spec.js new file mode 100644 index 0000000000..52e50c529c --- /dev/null +++ b/spec/ParseLiveQueryQuery.spec.js @@ -0,0 +1,271 @@ +'use strict'; + +const Parse = require('parse/node'); + +describe('ParseLiveQuery query operation', function () { + beforeEach(function (done) { + Parse.CoreManager.getLiveQueryController().setDefaultLiveQueryClient(null); + // Mock ParseWebSocketServer + const mockParseWebSocketServer = jasmine.createSpy('ParseWebSocketServer'); + jasmine.mockLibrary( + '../lib/LiveQuery/ParseWebSocketServer', + 'ParseWebSocketServer', + mockParseWebSocketServer + ); + // Mock Client pushError + const Client = require('../lib/LiveQuery/Client').Client; + Client.pushError = jasmine.createSpy('pushError'); + done(); + }); + + afterEach(async function () { + const client = await Parse.CoreManager.getLiveQueryController().getDefaultLiveQueryClient(); + if (client) { + await client.close(); + } + jasmine.restoreLibrary('../lib/LiveQuery/ParseWebSocketServer', 'ParseWebSocketServer'); + }); + + function addMockClient(parseLiveQueryServer, clientId) { + const Client = require('../lib/LiveQuery/Client').Client; + const client = new Client(clientId, {}); + client.pushResult = jasmine.createSpy('pushResult'); + parseLiveQueryServer.clients.set(clientId, client); + return client; + } + + function addMockSubscription(parseLiveQueryServer, clientId, requestId, parseWebSocket, query = {}) { + const Subscription = require('../lib/LiveQuery/Subscription').Subscription; + const subscription = new Subscription( + query.className || 'TestObject', + query.where || {}, + 'hash' + ); + + // Add to server subscriptions + if (!parseLiveQueryServer.subscriptions.has(subscription.className)) { + parseLiveQueryServer.subscriptions.set(subscription.className, new Map()); + } + const classSubscriptions = parseLiveQueryServer.subscriptions.get(subscription.className); + classSubscriptions.set('hash', subscription); + + // Add to client + const client = parseLiveQueryServer.clients.get(clientId); + const subscriptionInfo = { + subscription: subscription, + keys: query.keys, + }; + if (parseWebSocket.sessionToken) { + subscriptionInfo.sessionToken = parseWebSocket.sessionToken; + } + client.subscriptionInfos.set(requestId, subscriptionInfo); + + return subscription; + } + + it('can handle query command with existing subscription', async () => { + await reconfigureServer({ + liveQuery: { + classNames: ['TestObject'], + }, + startLiveQueryServer: true, + verbose: false, + silent: true, + }); + + const { ParseLiveQueryServer } = require('../lib/LiveQuery/ParseLiveQueryServer'); + const parseLiveQueryServer = new ParseLiveQueryServer({ + appId: 'test', + masterKey: 'test', + serverURL: 'http://localhost:1337/parse' + }); + + // Create test objects + const TestObject = Parse.Object.extend('TestObject'); + const obj1 = new TestObject(); + obj1.set('name', 'object1'); + await obj1.save(); + + const obj2 = new TestObject(); + obj2.set('name', 'object2'); + await obj2.save(); + + // Add mock client + const clientId = 1; + const client = addMockClient(parseLiveQueryServer, clientId); + client.hasMasterKey = true; + + // Add mock subscription + const parseWebSocket = { clientId: 1 }; + const requestId = 2; + const query = { + className: 'TestObject', + where: {}, + }; + addMockSubscription(parseLiveQueryServer, clientId, requestId, parseWebSocket, query); + + // Handle query command + const request = { + op: 'query', + requestId: requestId, + }; + + await parseLiveQueryServer._handleQuery(parseWebSocket, request); + + // Verify pushResult was called + expect(client.pushResult).toHaveBeenCalled(); + const results = client.pushResult.calls.mostRecent().args[1]; + expect(Array.isArray(results)).toBe(true); + expect(results.length).toBe(2); + expect(results.some(r => r.name === 'object1')).toBe(true); + expect(results.some(r => r.name === 'object2')).toBe(true); + }); + + it('can handle query command without clientId', async () => { + const { ParseLiveQueryServer } = require('../lib/LiveQuery/ParseLiveQueryServer'); + const parseLiveQueryServer = new ParseLiveQueryServer({}); + const incompleteParseConn = {}; + await parseLiveQueryServer._handleQuery(incompleteParseConn, {}); + + const Client = require('../lib/LiveQuery/Client').Client; + expect(Client.pushError).toHaveBeenCalled(); + }); + + it('can handle query command without subscription', async () => { + const { ParseLiveQueryServer } = require('../lib/LiveQuery/ParseLiveQueryServer'); + const parseLiveQueryServer = new ParseLiveQueryServer({}); + const clientId = 1; + addMockClient(parseLiveQueryServer, clientId); + + const parseWebSocket = { clientId: 1 }; + const request = { + op: 'query', + requestId: 999, // Non-existent subscription + }; + + await parseLiveQueryServer._handleQuery(parseWebSocket, request); + + const Client = require('../lib/LiveQuery/Client').Client; + expect(Client.pushError).toHaveBeenCalled(); + }); + + it('respects field filtering (keys) when executing query', async () => { + await reconfigureServer({ + liveQuery: { + classNames: ['TestObject'], + }, + startLiveQueryServer: true, + verbose: false, + silent: true, + }); + + const { ParseLiveQueryServer } = require('../lib/LiveQuery/ParseLiveQueryServer'); + const parseLiveQueryServer = new ParseLiveQueryServer({ + appId: 'test', + masterKey: 'test', + serverURL: 'http://localhost:1337/parse' + }); + + // Create test object with multiple fields + const TestObject = Parse.Object.extend('TestObject'); + const obj = new TestObject(); + obj.set('name', 'test'); + obj.set('color', 'blue'); + obj.set('size', 'large'); + await obj.save(); + + // Add mock client + const clientId = 1; + const client = addMockClient(parseLiveQueryServer, clientId); + client.hasMasterKey = true; + + // Add mock subscription with keys + const parseWebSocket = { clientId: 1 }; + const requestId = 2; + const query = { + className: 'TestObject', + where: {}, + keys: ['name', 'color'], // Only these fields + }; + addMockSubscription(parseLiveQueryServer, clientId, requestId, parseWebSocket, query); + + // Handle query command + const request = { + op: 'query', + requestId: requestId, + }; + + await parseLiveQueryServer._handleQuery(parseWebSocket, request); + + // Verify results + expect(client.pushResult).toHaveBeenCalled(); + const results = client.pushResult.calls.mostRecent().args[1]; + expect(results.length).toBe(1); + + // Results should include selected fields + expect(results[0].name).toBe('test'); + expect(results[0].color).toBe('blue'); + + // Results should NOT include size + expect(results[0].size).toBeUndefined(); + }); + + it('handles query with where constraints', async () => { + await reconfigureServer({ + liveQuery: { + classNames: ['TestObject'], + }, + startLiveQueryServer: true, + verbose: false, + silent: true, + }); + + const { ParseLiveQueryServer } = require('../lib/LiveQuery/ParseLiveQueryServer'); + const parseLiveQueryServer = new ParseLiveQueryServer({ + appId: 'test', + masterKey: 'test', + serverURL: 'http://localhost:1337/parse' + }); + + // Create test objects + const TestObject = Parse.Object.extend('TestObject'); + const obj1 = new TestObject(); + obj1.set('name', 'match'); + obj1.set('status', 'active'); + await obj1.save(); + + const obj2 = new TestObject(); + obj2.set('name', 'nomatch'); + obj2.set('status', 'inactive'); + await obj2.save(); + + // Add mock client + const clientId = 1; + const client = addMockClient(parseLiveQueryServer, clientId); + client.hasMasterKey = true; + + // Add mock subscription with where clause + const parseWebSocket = { clientId: 1 }; + const requestId = 2; + const query = { + className: 'TestObject', + where: { status: 'active' }, // Only active objects + }; + addMockSubscription(parseLiveQueryServer, clientId, requestId, parseWebSocket, query); + + // Handle query command + const request = { + op: 'query', + requestId: requestId, + }; + + await parseLiveQueryServer._handleQuery(parseWebSocket, request); + + // Verify results + expect(client.pushResult).toHaveBeenCalled(); + const results = client.pushResult.calls.mostRecent().args[1]; + expect(results.length).toBe(1); + expect(results[0].name).toBe('match'); + expect(results[0].status).toBe('active'); + }); +}); diff --git a/src/LiveQuery/Client.js b/src/LiveQuery/Client.js index 0ce629bd4e..3b1d73b639 100644 --- a/src/LiveQuery/Client.js +++ b/src/LiveQuery/Client.js @@ -22,6 +22,7 @@ class Client { pushUpdate: Function; pushDelete: Function; pushLeave: Function; + pushResult: Function; constructor( id: number, @@ -45,6 +46,7 @@ class Client { this.pushUpdate = this._pushEvent('update'); this.pushDelete = this._pushEvent('delete'); this.pushLeave = this._pushEvent('leave'); + this.pushResult = this._pushQueryResult.bind(this); } static pushResponse(parseWebSocket: any, message: Message): void { @@ -126,6 +128,27 @@ class Client { } return limitedParseObject; } + + _pushQueryResult(subscriptionId: number, results: any[]): void { + const response: Message = { + op: 'result', + clientId: this.id, + installationId: this.installationId, + requestId: subscriptionId, + }; + + if (results && Array.isArray(results)) { + let keys; + if (this.subscriptionInfos.has(subscriptionId)) { + keys = this.subscriptionInfos.get(subscriptionId).keys; + } + response['results'] = results.map(obj => this._toJSONWithFields(obj, keys)); + } else { + response['results'] = []; + } + + Client.pushResponse(this.parseWebSocket, JSON.stringify(response)); + } } export { Client }; diff --git a/src/LiveQuery/ParseLiveQueryServer.ts b/src/LiveQuery/ParseLiveQueryServer.ts index 3e6048c345..5b71dc9032 100644 --- a/src/LiveQuery/ParseLiveQueryServer.ts +++ b/src/LiveQuery/ParseLiveQueryServer.ts @@ -460,6 +460,9 @@ class ParseLiveQueryServer { case 'unsubscribe': this._handleUnsubscribe(parseWebsocket, request); break; + case 'query': + this._handleQuery(parseWebsocket, request); + break; default: Client.pushError(parseWebsocket, 3, 'Get unknown operation'); logger.error('Get unknown operation', request.op); @@ -1056,6 +1059,79 @@ class ParseLiveQueryServer { `Delete client: ${parseWebsocket.clientId} | subscription: ${request.requestId}` ); } + + async _handleQuery(parseWebsocket: any, request: any): Promise { + if (!Object.prototype.hasOwnProperty.call(parseWebsocket, 'clientId')) { + Client.pushError(parseWebsocket, 2, 'Can not find this client, make sure you connect to server before querying'); + logger.error('Can not find this client, make sure you connect to server before querying'); + return; + } + + const client = this.clients.get(parseWebsocket.clientId); + if (!client) { + Client.pushError(parseWebsocket, 2, 'Cannot find client with clientId ' + parseWebsocket.clientId); + logger.error('Can not find client ' + parseWebsocket.clientId); + return; + } + + const requestId = request.requestId; + const subscriptionInfo = client.getSubscriptionInfo(requestId); + if (!subscriptionInfo) { + Client.pushError(parseWebsocket, 2, 'Cannot find subscription with requestId ' + requestId); + logger.error('Can not find subscription with requestId ' + requestId); + return; + } + + const { subscription } = subscriptionInfo; + if (!subscription) { + Client.pushError(parseWebsocket, 2, 'Subscription not found for requestId ' + requestId); + logger.error('Subscription not found for requestId ' + requestId); + return; + } + + const { className, query } = subscription; + + try { + const sessionToken = subscriptionInfo.sessionToken || client.sessionToken; + const parseQuery = new Parse.Query(className); + + if (query && typeof query === 'object' && query !== null && Object.keys(query).length > 0) { + parseQuery._where = query; + } + + if (subscriptionInfo.keys && Array.isArray(subscriptionInfo.keys) && subscriptionInfo.keys.length > 0) { + parseQuery.select(...subscriptionInfo.keys); + } + + const findOptions: any = {}; + if (sessionToken) { + findOptions.sessionToken = sessionToken; + } else if (client.hasMasterKey) { + findOptions.useMasterKey = true; + } + + const results = await parseQuery.find(findOptions); + const jsonResults = results.map(obj => obj.toJSON()); + client.pushResult(requestId, jsonResults); + + logger.verbose(`Executed query for client ${parseWebsocket.clientId} subscription ${requestId}`); + + runLiveQueryEventHandlers({ + client, + event: 'query', + clients: this.clients.size, + subscriptions: this.subscriptions.size, + sessionToken, + useMasterKey: client.hasMasterKey, + installationId: client.installationId, + }); + } catch (e) { + logger.error(`Exception in _handleQuery:`, e); + const error = resolveError(e); + Client.pushError(parseWebsocket, error.code, error.message, false, request.requestId); + logger.error(`Failed running query on ${className}: ${JSON.stringify(error)}`); + } + } } export { ParseLiveQueryServer }; diff --git a/src/LiveQuery/RequestSchema.js b/src/LiveQuery/RequestSchema.js index 6e0a0566b2..0b67953f9c 100644 --- a/src/LiveQuery/RequestSchema.js +++ b/src/LiveQuery/RequestSchema.js @@ -4,7 +4,7 @@ const general = { properties: { op: { type: 'string', - enum: ['connect', 'subscribe', 'unsubscribe', 'update'], + enum: ['connect', 'subscribe', 'unsubscribe', 'update', 'query'], }, }, required: ['op'], @@ -149,12 +149,26 @@ const unsubscribe = { additionalProperties: false, }; +const query = { + title: 'Query operation schema', + type: 'object', + properties: { + op: 'query', + requestId: { + type: 'number', + }, + }, + required: ['op', 'requestId'], + additionalProperties: false, +}; + const RequestSchema = { general: general, connect: connect, subscribe: subscribe, update: update, unsubscribe: unsubscribe, + query: query, }; export default RequestSchema;