diff --git a/spec/Client.spec.js b/spec/Client.spec.js index 0de226204a..ecd6acb14e 100644 --- a/spec/Client.spec.js +++ b/spec/Client.spec.js @@ -190,6 +190,76 @@ describe('Client', function () { expect(messageJSON.requestId).toBe(2); }); + it('can push query result response', function () { + const parseObjectJSON = { + key: 'value', + className: 'test', + objectId: 'test', + updatedAt: '2015-12-07T21:27:13.746Z', + createdAt: '2015-12-07T21:27:13.746Z', + ACL: 'test', + test: 'test', + }; + const parseWebSocket = { + send: jasmine.createSpy('send'), + }; + const client = new Client(1, parseWebSocket, false, undefined, 'installationId'); + client.pushResult(2, [parseObjectJSON]); + + const lastCall = parseWebSocket.send.calls.first(); + const messageJSON = JSON.parse(lastCall.args[0]); + expect(messageJSON.op).toBe('result'); + expect(messageJSON.clientId).toBe(1); + expect(messageJSON.installationId).toBe('installationId'); + expect(messageJSON.requestId).toBe(2); + expect(messageJSON.results).toEqual([parseObjectJSON]); + }); + + it('can push query result response with selected fields', function () { + const parseObjectJSON = { + key: 'value', + className: 'test', + objectId: 'test', + updatedAt: '2015-12-07T21:27:13.746Z', + createdAt: '2015-12-07T21:27:13.746Z', + ACL: 'test', + test: 'test', + }; + const parseWebSocket = { + send: jasmine.createSpy('send'), + }; + const client = new Client(1, parseWebSocket); + client.addSubscriptionInfo(2, { keys: ['test'] }); + client.pushResult(2, [parseObjectJSON]); + + const lastCall = parseWebSocket.send.calls.first(); + const messageJSON = JSON.parse(lastCall.args[0]); + expect(messageJSON.results).toEqual([ + { + className: 'test', + objectId: 'test', + updatedAt: '2015-12-07T21:27:13.746Z', + createdAt: '2015-12-07T21:27:13.746Z', + ACL: 'test', + test: 'test', + }, + ]); + }); + + it('can push empty query result response when results are missing', function () { + const parseWebSocket = { + send: jasmine.createSpy('send'), + }; + const client = new Client(1, parseWebSocket); + client.pushResult(2); + + const lastCall = parseWebSocket.send.calls.first(); + const messageJSON = JSON.parse(lastCall.args[0]); + expect(messageJSON.op).toBe('result'); + expect(messageJSON.requestId).toBe(2); + expect(messageJSON.results).toEqual([]); + }); + it('can push create response', function () { const parseObjectJSON = { key: 'value', diff --git a/spec/ParseLiveQueryQuery.spec.js b/spec/ParseLiveQueryQuery.spec.js new file mode 100644 index 0000000000..baebebe822 --- /dev/null +++ b/spec/ParseLiveQueryQuery.spec.js @@ -0,0 +1,270 @@ +'use strict'; + +const Parse = require('parse/node'); + +describe('ParseLiveQuery query operation', function () { + beforeEach(function () { + 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; + spyOn(Client, 'pushError'); + }); + + 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: Parse.serverURL + }); + + // 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: Parse.serverURL + }); + + // 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: Parse.serverURL + }); + + // 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 f835fe2140..f15a84b5bd 100644 --- a/src/LiveQuery/ParseLiveQueryServer.ts +++ b/src/LiveQuery/ParseLiveQueryServer.ts @@ -492,6 +492,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); @@ -1313,6 +1316,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); + parseQuery.withJSON({ + className, + 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;