From 7ce52097df962b8f894723a3951564d3186d82dc Mon Sep 17 00:00:00 2001 From: Steve Stencil Date: Tue, 7 Jan 2020 14:48:20 -0500 Subject: [PATCH 1/7] added hint to aggregate --- src/Adapters/Storage/Mongo/MongoCollection.js | 4 ++-- src/Adapters/Storage/Mongo/MongoStorageAdapter.js | 4 +++- src/Controllers/DatabaseController.js | 13 ++++++++----- src/RestQuery.js | 5 +++++ src/Routers/AggregateRouter.js | 1 + src/Routers/ClassesRouter.js | 1 + 6 files changed, 20 insertions(+), 8 deletions(-) diff --git a/src/Adapters/Storage/Mongo/MongoCollection.js b/src/Adapters/Storage/Mongo/MongoCollection.js index 91c28b407d..1e5466f2e9 100644 --- a/src/Adapters/Storage/Mongo/MongoCollection.js +++ b/src/Adapters/Storage/Mongo/MongoCollection.js @@ -105,9 +105,9 @@ export default class MongoCollection { return this._mongoCollection.distinct(field, query); } - aggregate(pipeline, { maxTimeMS, readPreference } = {}) { + aggregate(pipeline, { maxTimeMS, readPreference, hint } = {}) { return this._mongoCollection - .aggregate(pipeline, { maxTimeMS, readPreference }) + .aggregate(pipeline, { maxTimeMS, readPreference, hint }) .toArray(); } diff --git a/src/Adapters/Storage/Mongo/MongoStorageAdapter.js b/src/Adapters/Storage/Mongo/MongoStorageAdapter.js index 09d6cd319e..43a3a3c338 100644 --- a/src/Adapters/Storage/Mongo/MongoStorageAdapter.js +++ b/src/Adapters/Storage/Mongo/MongoStorageAdapter.js @@ -760,7 +760,8 @@ export class MongoStorageAdapter implements StorageAdapter { className: string, schema: any, pipeline: any, - readPreference: ?string + readPreference: ?string, + hint ) { let isPointerField = false; pipeline = pipeline.map(stage => { @@ -791,6 +792,7 @@ export class MongoStorageAdapter implements StorageAdapter { collection.aggregate(pipeline, { readPreference, maxTimeMS: this._maxTimeMS, + hint, }) ) .then(results => { diff --git a/src/Controllers/DatabaseController.js b/src/Controllers/DatabaseController.js index 0a643e64ee..9b4e6bad16 100644 --- a/src/Controllers/DatabaseController.js +++ b/src/Controllers/DatabaseController.js @@ -1289,13 +1289,13 @@ class DatabaseController { distinct, pipeline, readPreference, + hint, }: any = {}, auth: any = {}, validSchemaController: SchemaController.SchemaController ): Promise { const isMaster = acl === undefined; const aclGroup = acl || []; - op = op || (typeof query.objectId == 'string' && Object.keys(query).length === 1 @@ -1406,7 +1406,8 @@ class DatabaseController { className, schema, query, - readPreference + readPreference, + hint ); } } else if (distinct) { @@ -1417,7 +1418,8 @@ class DatabaseController { className, schema, query, - distinct + distinct, + hint ); } } else if (pipeline) { @@ -1428,12 +1430,13 @@ class DatabaseController { className, schema, pipeline, - readPreference + readPreference, + hint ); } } else { return this.adapter - .find(className, schema, query, queryOptions) + .find(className, schema, query, queryOptions, hint) .then(objects => objects.map(object => { object = untransformObjectACL(object); diff --git a/src/RestQuery.js b/src/RestQuery.js index f2225ab3ee..34599e9230 100644 --- a/src/RestQuery.js +++ b/src/RestQuery.js @@ -32,6 +32,8 @@ function RestQuery( this.className = className; this.restWhere = restWhere; this.restOptions = restOptions; + this.hint = this.restOptions.hint; + delete this.restOptions.hint; this.clientSDK = clientSDK; this.runAfterFind = runAfterFind; this.response = null; @@ -661,6 +663,9 @@ RestQuery.prototype.runFind = function(options = {}) { if (options.op) { findOptions.op = options.op; } + if (this.hint) { + findOptions.hint = this.hint; + } return this.config.database .find(this.className, this.restWhere, findOptions, this.auth) .then(results => { diff --git a/src/Routers/AggregateRouter.js b/src/Routers/AggregateRouter.js index 591ebd3469..5a9d07265b 100644 --- a/src/Routers/AggregateRouter.js +++ b/src/Routers/AggregateRouter.js @@ -50,6 +50,7 @@ export class AggregateRouter extends ClassesRouter { if (typeof body.where === 'string') { body.where = JSON.parse(body.where); } + options.hint = body.hint; return rest .find( req.config, diff --git a/src/Routers/ClassesRouter.js b/src/Routers/ClassesRouter.js index 056a8df207..61040cbfdb 100644 --- a/src/Routers/ClassesRouter.js +++ b/src/Routers/ClassesRouter.js @@ -173,6 +173,7 @@ export class ClassesRouter extends PromiseRouter { 'readPreference', 'includeReadPreference', 'subqueryReadPreference', + 'hint', ]; for (const key of Object.keys(body)) { From a8d404ed366fcab4223276eea2ea573b6ccb603a Mon Sep 17 00:00:00 2001 From: Steve Stencil Date: Tue, 7 Jan 2020 17:35:09 -0500 Subject: [PATCH 2/7] added support for hint in query --- src/Adapters/Storage/Mongo/MongoCollection.js | 81 +++++++++++++++---- .../Storage/Mongo/MongoStorageAdapter.js | 16 ++-- src/Routers/ClassesRouter.js | 6 ++ src/triggers.js | 4 + 4 files changed, 86 insertions(+), 21 deletions(-) diff --git a/src/Adapters/Storage/Mongo/MongoCollection.js b/src/Adapters/Storage/Mongo/MongoCollection.js index 1e5466f2e9..075c1b1b03 100644 --- a/src/Adapters/Storage/Mongo/MongoCollection.js +++ b/src/Adapters/Storage/Mongo/MongoCollection.js @@ -13,7 +13,10 @@ export default class MongoCollection { // none, then build the geoindex. // This could be improved a lot but it's not clear if that's a good // idea. Or even if this behavior is a good idea. - find(query, { skip, limit, sort, keys, maxTimeMS, readPreference } = {}) { + find( + query, + { skip, limit, sort, keys, maxTimeMS, readPreference, hint } = {} + ) { // Support for Full Text Search - $text if (keys && keys.$score) { delete keys.$score; @@ -26,6 +29,7 @@ export default class MongoCollection { keys, maxTimeMS, readPreference, + hint, }).catch(error => { // Check for "no geoindex" error if ( @@ -54,19 +58,35 @@ export default class MongoCollection { keys, maxTimeMS, readPreference, + hint, }) ) ); }); } - _rawFind(query, { skip, limit, sort, keys, maxTimeMS, readPreference } = {}) { - let findOperation = this._mongoCollection.find(query, { - skip, - limit, - sort, - readPreference, - }); + _rawFind( + query, + { skip, limit, sort, keys, maxTimeMS, readPreference, hint } = {} + ) { + let findOperation; + if (hint) { + findOperation = this._mongoCollection + .find(query, { + skip, + limit, + sort, + readPreference, + }) + .hint(hint); + } else { + findOperation = this._mongoCollection.find(query, { + skip, + limit, + sort, + readPreference, + }); + } if (keys) { findOperation = findOperation.project(keys); @@ -79,15 +99,37 @@ export default class MongoCollection { return findOperation.toArray(); } - count(query, { skip, limit, sort, maxTimeMS, readPreference } = {}) { + count(query, { skip, limit, sort, maxTimeMS, readPreference, hint } = {}) { // If query is empty, then use estimatedDocumentCount instead. // This is due to countDocuments performing a scan, // which greatly increases execution time when being run on large collections. // See https://github.com/Automattic/mongoose/issues/6713 for more info regarding this problem. if (typeof query !== 'object' || !Object.keys(query).length) { - return this._mongoCollection.estimatedDocumentCount({ - maxTimeMS, - }); + if (hint) { + return this._mongoCollection + .estimatedDocumentCount({ + maxTimeMS, + }) + .hint(hint); + } else { + return this._mongoCollection.estimatedDocumentCount({ + maxTimeMS, + }); + } + } + + if (hint) { + const countOperation = this._mongoCollection + .countDocuments(query, { + skip, + limit, + sort, + maxTimeMS, + readPreference, + }) + .hint(hint); + + return countOperation; } const countOperation = this._mongoCollection.countDocuments(query, { @@ -101,14 +143,21 @@ export default class MongoCollection { return countOperation; } - distinct(field, query) { + distinct(field, query, hint) { + if (hint) { + return this._mongoCollection.distinct(field, query).hint; + } + return this._mongoCollection.distinct(field, query); } aggregate(pipeline, { maxTimeMS, readPreference, hint } = {}) { - return this._mongoCollection - .aggregate(pipeline, { maxTimeMS, readPreference, hint }) - .toArray(); + if (hint) { + return this._mongoCollection + .aggregate(pipeline, { maxTimeMS, readPreference }) + .hint(hint) + .toArray(); + } } insertOne(object, session) { diff --git a/src/Adapters/Storage/Mongo/MongoStorageAdapter.js b/src/Adapters/Storage/Mongo/MongoStorageAdapter.js index 43a3a3c338..21fb0322f7 100644 --- a/src/Adapters/Storage/Mongo/MongoStorageAdapter.js +++ b/src/Adapters/Storage/Mongo/MongoStorageAdapter.js @@ -620,7 +620,8 @@ export class MongoStorageAdapter implements StorageAdapter { className: string, schema: SchemaType, query: QueryType, - { skip, limit, sort, keys, readPreference }: QueryOptions + { skip, limit, sort, keys, readPreference }: QueryOptions, + hint: ?mixed ): Promise { schema = convertParseSchemaToMongoSchema(schema); const mongoWhere = transformWhere(className, query, schema); @@ -652,6 +653,7 @@ export class MongoStorageAdapter implements StorageAdapter { keys: mongoKeys, maxTimeMS: this._maxTimeMS, readPreference, + hint, }) ) .then(objects => @@ -712,7 +714,8 @@ export class MongoStorageAdapter implements StorageAdapter { className: string, schema: SchemaType, query: QueryType, - readPreference: ?string + readPreference: ?string, + hint: ?mixed ) { schema = convertParseSchemaToMongoSchema(schema); readPreference = this._parseReadPreference(readPreference); @@ -721,6 +724,7 @@ export class MongoStorageAdapter implements StorageAdapter { collection.count(transformWhere(className, query, schema, true), { maxTimeMS: this._maxTimeMS, readPreference, + hint, }) ) .catch(err => this.handleError(err)); @@ -730,7 +734,8 @@ export class MongoStorageAdapter implements StorageAdapter { className: string, schema: SchemaType, query: QueryType, - fieldName: string + fieldName: string, + hint: ?mixed ) { schema = convertParseSchemaToMongoSchema(schema); const isPointerField = @@ -741,7 +746,8 @@ export class MongoStorageAdapter implements StorageAdapter { .then(collection => collection.distinct( transformField, - transformWhere(className, query, schema) + transformWhere(className, query, schema), + hint ) ) .then(objects => { @@ -761,7 +767,7 @@ export class MongoStorageAdapter implements StorageAdapter { schema: any, pipeline: any, readPreference: ?string, - hint + hint: ?mixed ) { let isPointerField = false; pipeline = pipeline.map(stage => { diff --git a/src/Routers/ClassesRouter.js b/src/Routers/ClassesRouter.js index 61040cbfdb..6400b46c54 100644 --- a/src/Routers/ClassesRouter.js +++ b/src/Routers/ClassesRouter.js @@ -220,6 +220,12 @@ export class ClassesRouter extends PromiseRouter { if (typeof body.subqueryReadPreference === 'string') { options.subqueryReadPreference = body.subqueryReadPreference; } + if ( + body.hint && + (typeof body.hint === 'string' || typeof body.hint === 'object') + ) { + options.hint = body.hint; + } return options; } diff --git a/src/triggers.js b/src/triggers.js index b340a4619c..60468e3aff 100644 --- a/src/triggers.js +++ b/src/triggers.js @@ -507,6 +507,10 @@ export function maybeRunQueryTrigger( restOptions = restOptions || {}; restOptions.order = jsonQuery.order; } + if (jsonQuery.hint) { + restOptions = restOptions || {}; + restOptions.hint = jsonQuery.hint; + } if (requestObject.readPreference) { restOptions = restOptions || {}; restOptions.readPreference = requestObject.readPreference; From 00a6b275c617d588b623cdf965eb0ee59cb7d55e Mon Sep 17 00:00:00 2001 From: Steve Date: Tue, 7 Jan 2020 21:40:30 -0500 Subject: [PATCH 3/7] added else clause to aggregate --- src/Adapters/Storage/Mongo/MongoCollection.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/Adapters/Storage/Mongo/MongoCollection.js b/src/Adapters/Storage/Mongo/MongoCollection.js index 075c1b1b03..4f538447ed 100644 --- a/src/Adapters/Storage/Mongo/MongoCollection.js +++ b/src/Adapters/Storage/Mongo/MongoCollection.js @@ -158,6 +158,9 @@ export default class MongoCollection { .hint(hint) .toArray(); } + return this._mongoCollection + .aggregate(pipeline, { maxTimeMS, readPreference }) + .toArray(); } insertOne(object, session) { From d41d0359839a606691c7b3901afe1290daf769f9 Mon Sep 17 00:00:00 2001 From: Steve Date: Wed, 8 Jan 2020 10:01:39 -0500 Subject: [PATCH 4/7] fixed tests --- spec/ParseQuery.hint.spec.js | 111 +++++++++++++++++++++++++ src/Adapters/Storage/StorageAdapter.js | 13 ++- src/Controllers/DatabaseController.js | 3 +- 3 files changed, 122 insertions(+), 5 deletions(-) create mode 100644 spec/ParseQuery.hint.spec.js diff --git a/spec/ParseQuery.hint.spec.js b/spec/ParseQuery.hint.spec.js new file mode 100644 index 0000000000..218680fe95 --- /dev/null +++ b/spec/ParseQuery.hint.spec.js @@ -0,0 +1,111 @@ +'use strict'; + +const MongoStorageAdapter = require('../lib/Adapters/Storage/Mongo/MongoStorageAdapter') + .default; +const mongoURI = + 'mongodb://localhost:27017/parseServerMongoAdapterTestDatabase'; +const PostgresStorageAdapter = require('../lib/Adapters/Storage/Postgres/PostgresStorageAdapter') + .default; +const postgresURI = + 'postgres://localhost:5432/parse_server_postgres_adapter_test_database'; +const request = require('../lib/request'); +let databaseAdapter; + +const hintHelper = () => { + if (process.env.PARSE_SERVER_TEST_DB === 'postgres') { + if (!databaseAdapter) { + databaseAdapter = new PostgresStorageAdapter({ uri: postgresURI }); + } + } else { + databaseAdapter = new MongoStorageAdapter({ uri: mongoURI }); + } + const subjects = [ + 'coffee', + 'Coffee Shopping', + 'Baking a cake', + 'baking', + 'Café Con Leche', + 'Сырники', + 'coffee and cream', + 'Cafe con Leche', + ]; + const requests = []; + for (const i in subjects) { + const request = { + method: 'POST', + body: { + subject: subjects[i], + comment: subjects[i], + }, + path: '/1/classes/TestObject', + }; + requests.push(request); + } + return reconfigureServer({ + appId: 'test', + restAPIKey: 'test', + publicServerURL: 'http://localhost:8378/1', + databaseAdapter, + }).then(() => { + return request({ + method: 'POST', + url: 'http://localhost:8378/1/batch', + body: { + requests, + }, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'test', + 'Content-Type': 'application/json', + }, + }); + }); +}; + +describe('Parse.Query hint testing', () => { + it('should execute query with hint as a string', done => { + hintHelper() + .then(() => { + return request({ + method: 'POST', + url: 'http://localhost:8378/1/classes/TestObject', + body: { where: {}, _method: 'GET', hint: '_id_' }, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'test', + 'Content-Type': 'application/json', + }, + }); + }) + .then( + resp => { + expect(resp.data.results.length).toBe(3); + done(); + }, + e => done.fail(e) + ); + }); + + it('should execute query with hint as object', done => { + hintHelper() + .then(() => { + return request({ + method: 'POST', + url: 'http://localhost:8378/1/classes/TestObject', + body: { where: {}, _method: 'GET', hint: { _id_: 1 } }, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'test', + 'Content-Type': 'application/json', + }, + }); + }) + .then( + resp => { + expect(resp.data.results.length).toBe(3); + done(); + }, + e => done.fail(e) + ); + }); +}); diff --git a/src/Adapters/Storage/StorageAdapter.js b/src/Adapters/Storage/StorageAdapter.js index 6de3ea3cbd..d748a61dee 100644 --- a/src/Adapters/Storage/StorageAdapter.js +++ b/src/Adapters/Storage/StorageAdapter.js @@ -14,6 +14,7 @@ export type QueryOptions = { distinct?: boolean, pipeline?: any, readPreference?: ?string, + hint?: ?mixed, }; export type UpdateQueryOptions = { @@ -80,7 +81,8 @@ export interface StorageAdapter { className: string, schema: SchemaType, query: QueryType, - options: QueryOptions + options: QueryOptions, + hint?: mixed ): Promise<[any]>; ensureUniqueness( className: string, @@ -92,19 +94,22 @@ export interface StorageAdapter { schema: SchemaType, query: QueryType, readPreference?: string, - estimate?: boolean + estimate?: boolean, + hint?: mixed ): Promise; distinct( className: string, schema: SchemaType, query: QueryType, - fieldName: string + fieldName: string, + hint: ?mixed ): Promise; aggregate( className: string, schema: any, pipeline: any, - readPreference: ?string + readPreference: ?string, + hint: ?mixed ): Promise; performInitialization(options: ?any): Promise; diff --git a/src/Controllers/DatabaseController.js b/src/Controllers/DatabaseController.js index 9b4e6bad16..052f964747 100644 --- a/src/Controllers/DatabaseController.js +++ b/src/Controllers/DatabaseController.js @@ -1407,7 +1407,8 @@ class DatabaseController { schema, query, readPreference, - hint + undefined, + hint, ); } } else if (distinct) { From 288bb2a203c3d820aa3bed8c1be03297ac91f466 Mon Sep 17 00:00:00 2001 From: Steve Date: Wed, 8 Jan 2020 13:09:42 -0500 Subject: [PATCH 5/7] updated tests --- spec/ParseQuery.hint.spec.js | 53 ++++++++++++++++++++++++++---------- 1 file changed, 38 insertions(+), 15 deletions(-) diff --git a/spec/ParseQuery.hint.spec.js b/spec/ParseQuery.hint.spec.js index 218680fe95..efe72b6e02 100644 --- a/spec/ParseQuery.hint.spec.js +++ b/spec/ParseQuery.hint.spec.js @@ -46,20 +46,43 @@ const hintHelper = () => { restAPIKey: 'test', publicServerURL: 'http://localhost:8378/1', databaseAdapter, - }).then(() => { - return request({ - method: 'POST', - url: 'http://localhost:8378/1/batch', - body: { - requests, - }, - headers: { - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'test', - 'Content-Type': 'application/json', - }, + }) + .then(() => { + return request({ + method: 'POST', + url: 'http://localhost:8378/1/schemas/TestObject', + body: { + className: 'TestObject', + fields: { + subject: { + type: 'String', + }, + comment: { + type: 'String', + }, + }, + indexes: { + indexName: { + subject: 1, + }, + }, + }, + }); + }) + .then(() => { + return request({ + method: 'POST', + url: 'http://localhost:8378/1/batch', + body: { + requests, + }, + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'test', + 'Content-Type': 'application/json', + }, + }); }); - }); }; describe('Parse.Query hint testing', () => { @@ -69,7 +92,7 @@ describe('Parse.Query hint testing', () => { return request({ method: 'POST', url: 'http://localhost:8378/1/classes/TestObject', - body: { where: {}, _method: 'GET', hint: '_id_' }, + body: { where: {}, _method: 'GET', hint: 'subject' }, headers: { 'X-Parse-Application-Id': 'test', 'X-Parse-REST-API-Key': 'test', @@ -92,7 +115,7 @@ describe('Parse.Query hint testing', () => { return request({ method: 'POST', url: 'http://localhost:8378/1/classes/TestObject', - body: { where: {}, _method: 'GET', hint: { _id_: 1 } }, + body: { where: {}, _method: 'GET', hint: { subject: 1 } }, headers: { 'X-Parse-Application-Id': 'test', 'X-Parse-REST-API-Key': 'test', From 0863e847332c00c15035b6729462e9230e980079 Mon Sep 17 00:00:00 2001 From: Diamond Lewis Date: Thu, 9 Jan 2020 23:57:51 -0600 Subject: [PATCH 6/7] Add tests and clean up --- spec/ParseQuery.hint.spec.js | 219 ++++++++---------- src/Adapters/Storage/Mongo/MongoCollection.js | 78 ++----- .../Storage/Mongo/MongoStorageAdapter.js | 6 +- src/Adapters/Storage/StorageAdapter.js | 3 +- src/Controllers/DatabaseController.js | 5 +- 5 files changed, 118 insertions(+), 193 deletions(-) diff --git a/spec/ParseQuery.hint.spec.js b/spec/ParseQuery.hint.spec.js index efe72b6e02..1cc90b974d 100644 --- a/spec/ParseQuery.hint.spec.js +++ b/spec/ParseQuery.hint.spec.js @@ -1,134 +1,103 @@ 'use strict'; -const MongoStorageAdapter = require('../lib/Adapters/Storage/Mongo/MongoStorageAdapter') - .default; -const mongoURI = - 'mongodb://localhost:27017/parseServerMongoAdapterTestDatabase'; -const PostgresStorageAdapter = require('../lib/Adapters/Storage/Postgres/PostgresStorageAdapter') - .default; -const postgresURI = - 'postgres://localhost:5432/parse_server_postgres_adapter_test_database'; -const request = require('../lib/request'); -let databaseAdapter; +const Config = require('../lib/Config'); +const TestUtils = require('../lib/TestUtils'); -const hintHelper = () => { - if (process.env.PARSE_SERVER_TEST_DB === 'postgres') { - if (!databaseAdapter) { - databaseAdapter = new PostgresStorageAdapter({ uri: postgresURI }); - } - } else { - databaseAdapter = new MongoStorageAdapter({ uri: mongoURI }); - } - const subjects = [ - 'coffee', - 'Coffee Shopping', - 'Baking a cake', - 'baking', - 'Café Con Leche', - 'Сырники', - 'coffee and cream', - 'Cafe con Leche', - ]; - const requests = []; - for (const i in subjects) { - const request = { - method: 'POST', - body: { - subject: subjects[i], - comment: subjects[i], - }, - path: '/1/classes/TestObject', - }; - requests.push(request); - } - return reconfigureServer({ - appId: 'test', - restAPIKey: 'test', - publicServerURL: 'http://localhost:8378/1', - databaseAdapter, - }) - .then(() => { - return request({ - method: 'POST', - url: 'http://localhost:8378/1/schemas/TestObject', - body: { - className: 'TestObject', - fields: { - subject: { - type: 'String', - }, - comment: { - type: 'String', - }, - }, - indexes: { - indexName: { - subject: 1, - }, - }, - }, - }); - }) - .then(() => { - return request({ - method: 'POST', - url: 'http://localhost:8378/1/batch', - body: { - requests, - }, - headers: { - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'test', - 'Content-Type': 'application/json', - }, - }); +let config; + +describe_only_db('mongo')('Parse.Query hint', () => { + beforeEach(() => { + config = Config.get('test'); + }); + + afterEach(async () => { + await config.database.schemaCache.clear(); + await TestUtils.destroyAllDataPermanently(false); + }); + + it('query find with hint string', async () => { + const object = new TestObject(); + await object.save(); + + const collection = await config.database.adapter._adaptiveCollection( + 'TestObject' + ); + let explain = await collection._rawFind( + { _id: object.id }, + { explain: true } + ); + expect(explain.queryPlanner.winningPlan.stage).toBe('IDHACK'); + explain = await collection._rawFind( + { _id: object.id }, + { hint: '_id_', explain: true } + ); + expect(explain.queryPlanner.winningPlan.stage).toBe('FETCH'); + expect(explain.queryPlanner.winningPlan.inputStage.indexName).toBe('_id_'); + }); + + it('query find with hint object', async () => { + const object = new TestObject(); + await object.save(); + + const collection = await config.database.adapter._adaptiveCollection( + 'TestObject' + ); + let explain = await collection._rawFind( + { _id: object.id }, + { explain: true } + ); + expect(explain.queryPlanner.winningPlan.stage).toBe('IDHACK'); + explain = await collection._rawFind( + { _id: object.id }, + { hint: { _id: 1 }, explain: true } + ); + expect(explain.queryPlanner.winningPlan.stage).toBe('FETCH'); + expect(explain.queryPlanner.winningPlan.inputStage.keyPattern).toEqual({ + _id: 1, }); -}; + }); + + it('query aggregate with hint string', async () => { + const object = new TestObject({ foo: 'bar' }); + await object.save(); -describe('Parse.Query hint testing', () => { - it('should execute query with hint as a string', done => { - hintHelper() - .then(() => { - return request({ - method: 'POST', - url: 'http://localhost:8378/1/classes/TestObject', - body: { where: {}, _method: 'GET', hint: 'subject' }, - headers: { - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'test', - 'Content-Type': 'application/json', - }, - }); - }) - .then( - resp => { - expect(resp.data.results.length).toBe(3); - done(); - }, - e => done.fail(e) - ); + const collection = await config.database.adapter._adaptiveCollection( + 'TestObject' + ); + let result = await collection.aggregate([{ $group: { _id: '$foo' } }], { + explain: true, + }); + let { queryPlanner } = result[0].stages[0].$cursor; + expect(queryPlanner.winningPlan.stage).toBe('COLLSCAN'); + + result = await collection.aggregate([{ $group: { _id: '$foo' } }], { + hint: '_id_', + explain: true, + }); + queryPlanner = result[0].stages[0].$cursor.queryPlanner; + expect(queryPlanner.winningPlan.stage).toBe('FETCH'); + expect(queryPlanner.winningPlan.inputStage.indexName).toBe('_id_'); }); - it('should execute query with hint as object', done => { - hintHelper() - .then(() => { - return request({ - method: 'POST', - url: 'http://localhost:8378/1/classes/TestObject', - body: { where: {}, _method: 'GET', hint: { subject: 1 } }, - headers: { - 'X-Parse-Application-Id': 'test', - 'X-Parse-REST-API-Key': 'test', - 'Content-Type': 'application/json', - }, - }); - }) - .then( - resp => { - expect(resp.data.results.length).toBe(3); - done(); - }, - e => done.fail(e) - ); + it('query aggregate with hint object', async () => { + const object = new TestObject({ foo: 'bar' }); + await object.save(); + + const collection = await config.database.adapter._adaptiveCollection( + 'TestObject' + ); + let result = await collection.aggregate([{ $group: { _id: '$foo' } }], { + explain: true, + }); + let { queryPlanner } = result[0].stages[0].$cursor; + expect(queryPlanner.winningPlan.stage).toBe('COLLSCAN'); + + result = await collection.aggregate([{ $group: { _id: '$foo' } }], { + hint: { _id: 1 }, + explain: true, + }); + queryPlanner = result[0].stages[0].$cursor.queryPlanner; + expect(queryPlanner.winningPlan.stage).toBe('FETCH'); + expect(queryPlanner.winningPlan.inputStage.keyPattern).toEqual({ _id: 1 }); }); }); diff --git a/src/Adapters/Storage/Mongo/MongoCollection.js b/src/Adapters/Storage/Mongo/MongoCollection.js index 4f538447ed..7cabc522cd 100644 --- a/src/Adapters/Storage/Mongo/MongoCollection.js +++ b/src/Adapters/Storage/Mongo/MongoCollection.js @@ -15,7 +15,7 @@ export default class MongoCollection { // idea. Or even if this behavior is a good idea. find( query, - { skip, limit, sort, keys, maxTimeMS, readPreference, hint } = {} + { skip, limit, sort, keys, maxTimeMS, readPreference, hint, explain } = {} ) { // Support for Full Text Search - $text if (keys && keys.$score) { @@ -30,6 +30,7 @@ export default class MongoCollection { maxTimeMS, readPreference, hint, + explain, }).catch(error => { // Check for "no geoindex" error if ( @@ -59,6 +60,7 @@ export default class MongoCollection { maxTimeMS, readPreference, hint, + explain, }) ) ); @@ -67,26 +69,15 @@ export default class MongoCollection { _rawFind( query, - { skip, limit, sort, keys, maxTimeMS, readPreference, hint } = {} + { skip, limit, sort, keys, maxTimeMS, readPreference, hint, explain } = {} ) { - let findOperation; - if (hint) { - findOperation = this._mongoCollection - .find(query, { - skip, - limit, - sort, - readPreference, - }) - .hint(hint); - } else { - findOperation = this._mongoCollection.find(query, { - skip, - limit, - sort, - readPreference, - }); - } + let findOperation = this._mongoCollection.find(query, { + skip, + limit, + sort, + readPreference, + hint, + }); if (keys) { findOperation = findOperation.project(keys); @@ -96,7 +87,7 @@ export default class MongoCollection { findOperation = findOperation.maxTimeMS(maxTimeMS); } - return findOperation.toArray(); + return explain ? findOperation.explain(explain) : findOperation.toArray(); } count(query, { skip, limit, sort, maxTimeMS, readPreference, hint } = {}) { @@ -105,31 +96,9 @@ export default class MongoCollection { // which greatly increases execution time when being run on large collections. // See https://github.com/Automattic/mongoose/issues/6713 for more info regarding this problem. if (typeof query !== 'object' || !Object.keys(query).length) { - if (hint) { - return this._mongoCollection - .estimatedDocumentCount({ - maxTimeMS, - }) - .hint(hint); - } else { - return this._mongoCollection.estimatedDocumentCount({ - maxTimeMS, - }); - } - } - - if (hint) { - const countOperation = this._mongoCollection - .countDocuments(query, { - skip, - limit, - sort, - maxTimeMS, - readPreference, - }) - .hint(hint); - - return countOperation; + return this._mongoCollection.estimatedDocumentCount({ + maxTimeMS, + }); } const countOperation = this._mongoCollection.countDocuments(query, { @@ -138,28 +107,19 @@ export default class MongoCollection { sort, maxTimeMS, readPreference, + hint, }); return countOperation; } - distinct(field, query, hint) { - if (hint) { - return this._mongoCollection.distinct(field, query).hint; - } - + distinct(field, query) { return this._mongoCollection.distinct(field, query); } - aggregate(pipeline, { maxTimeMS, readPreference, hint } = {}) { - if (hint) { - return this._mongoCollection - .aggregate(pipeline, { maxTimeMS, readPreference }) - .hint(hint) - .toArray(); - } + aggregate(pipeline, { maxTimeMS, readPreference, hint, explain } = {}) { return this._mongoCollection - .aggregate(pipeline, { maxTimeMS, readPreference }) + .aggregate(pipeline, { maxTimeMS, readPreference, hint, explain }) .toArray(); } diff --git a/src/Adapters/Storage/Mongo/MongoStorageAdapter.js b/src/Adapters/Storage/Mongo/MongoStorageAdapter.js index 21fb0322f7..6bc58336c7 100644 --- a/src/Adapters/Storage/Mongo/MongoStorageAdapter.js +++ b/src/Adapters/Storage/Mongo/MongoStorageAdapter.js @@ -734,8 +734,7 @@ export class MongoStorageAdapter implements StorageAdapter { className: string, schema: SchemaType, query: QueryType, - fieldName: string, - hint: ?mixed + fieldName: string ) { schema = convertParseSchemaToMongoSchema(schema); const isPointerField = @@ -746,8 +745,7 @@ export class MongoStorageAdapter implements StorageAdapter { .then(collection => collection.distinct( transformField, - transformWhere(className, query, schema), - hint + transformWhere(className, query, schema) ) ) .then(objects => { diff --git a/src/Adapters/Storage/StorageAdapter.js b/src/Adapters/Storage/StorageAdapter.js index d748a61dee..a693cd9d1b 100644 --- a/src/Adapters/Storage/StorageAdapter.js +++ b/src/Adapters/Storage/StorageAdapter.js @@ -101,8 +101,7 @@ export interface StorageAdapter { className: string, schema: SchemaType, query: QueryType, - fieldName: string, - hint: ?mixed + fieldName: string ): Promise; aggregate( className: string, diff --git a/src/Controllers/DatabaseController.js b/src/Controllers/DatabaseController.js index 052f964747..1e3eb2451c 100644 --- a/src/Controllers/DatabaseController.js +++ b/src/Controllers/DatabaseController.js @@ -1408,7 +1408,7 @@ class DatabaseController { query, readPreference, undefined, - hint, + hint ); } } else if (distinct) { @@ -1419,8 +1419,7 @@ class DatabaseController { className, schema, query, - distinct, - hint + distinct ); } } else if (pipeline) { From eecc3ffb2e1c29336f99e0886196913526c91eb0 Mon Sep 17 00:00:00 2001 From: Diamond Lewis Date: Mon, 13 Jan 2020 18:16:22 -0600 Subject: [PATCH 7/7] Add support for explain --- spec/ParseQuery.hint.spec.js | 67 +++++++++++++++++++ .../Storage/Mongo/MongoStorageAdapter.js | 19 ++++-- src/Adapters/Storage/StorageAdapter.js | 7 +- src/Controllers/DatabaseController.js | 23 ++++++- src/RestQuery.js | 7 +- src/Routers/AggregateRouter.js | 12 +++- src/Routers/ClassesRouter.js | 4 ++ src/triggers.js | 4 ++ 8 files changed, 122 insertions(+), 21 deletions(-) diff --git a/spec/ParseQuery.hint.spec.js b/spec/ParseQuery.hint.spec.js index 1cc90b974d..43f1c4e9a7 100644 --- a/spec/ParseQuery.hint.spec.js +++ b/spec/ParseQuery.hint.spec.js @@ -2,9 +2,22 @@ const Config = require('../lib/Config'); const TestUtils = require('../lib/TestUtils'); +const request = require('../lib/request'); let config; +const masterKeyHeaders = { + 'X-Parse-Application-Id': 'test', + 'X-Parse-Rest-API-Key': 'rest', + 'X-Parse-Master-Key': 'test', + 'Content-Type': 'application/json', +}; + +const masterKeyOptions = { + headers: masterKeyHeaders, + json: true, +}; + describe_only_db('mongo')('Parse.Query hint', () => { beforeEach(() => { config = Config.get('test'); @@ -100,4 +113,58 @@ describe_only_db('mongo')('Parse.Query hint', () => { expect(queryPlanner.winningPlan.stage).toBe('FETCH'); expect(queryPlanner.winningPlan.inputStage.keyPattern).toEqual({ _id: 1 }); }); + + it('query find with hint (rest)', async () => { + const object = new TestObject(); + await object.save(); + let options = Object.assign({}, masterKeyOptions, { + url: Parse.serverURL + '/classes/TestObject', + qs: { + explain: true, + }, + }); + let response = await request(options); + let explain = response.data.results; + expect(explain.queryPlanner.winningPlan.inputStage.stage).toBe('COLLSCAN'); + + options = Object.assign({}, masterKeyOptions, { + url: Parse.serverURL + '/classes/TestObject', + qs: { + explain: true, + hint: '_id_', + }, + }); + response = await request(options); + explain = response.data.results; + expect( + explain.queryPlanner.winningPlan.inputStage.inputStage.indexName + ).toBe('_id_'); + }); + + it('query aggregate with hint (rest)', async () => { + const object = new TestObject({ foo: 'bar' }); + await object.save(); + let options = Object.assign({}, masterKeyOptions, { + url: Parse.serverURL + '/aggregate/TestObject', + qs: { + explain: true, + group: JSON.stringify({ objectId: '$foo' }), + }, + }); + let response = await request(options); + let { queryPlanner } = response.data.results[0].stages[0].$cursor; + expect(queryPlanner.winningPlan.stage).toBe('COLLSCAN'); + + options = Object.assign({}, masterKeyOptions, { + url: Parse.serverURL + '/aggregate/TestObject', + qs: { + explain: true, + hint: '_id_', + group: JSON.stringify({ objectId: '$foo' }), + }, + }); + response = await request(options); + queryPlanner = response.data.results[0].stages[0].$cursor.queryPlanner; + expect(queryPlanner.winningPlan.inputStage.keyPattern).toEqual({ _id: 1 }); + }); }); diff --git a/src/Adapters/Storage/Mongo/MongoStorageAdapter.js b/src/Adapters/Storage/Mongo/MongoStorageAdapter.js index 6bc58336c7..e60551bdb0 100644 --- a/src/Adapters/Storage/Mongo/MongoStorageAdapter.js +++ b/src/Adapters/Storage/Mongo/MongoStorageAdapter.js @@ -620,8 +620,7 @@ export class MongoStorageAdapter implements StorageAdapter { className: string, schema: SchemaType, query: QueryType, - { skip, limit, sort, keys, readPreference }: QueryOptions, - hint: ?mixed + { skip, limit, sort, keys, readPreference, hint, explain }: QueryOptions ): Promise { schema = convertParseSchemaToMongoSchema(schema); const mongoWhere = transformWhere(className, query, schema); @@ -654,13 +653,17 @@ export class MongoStorageAdapter implements StorageAdapter { maxTimeMS: this._maxTimeMS, readPreference, hint, + explain, }) ) - .then(objects => - objects.map(object => + .then(objects => { + if (explain) { + return objects; + } + return objects.map(object => mongoObjectToParseObject(className, object, schema) - ) - ) + ); + }) .catch(err => this.handleError(err)); } @@ -765,7 +768,8 @@ export class MongoStorageAdapter implements StorageAdapter { schema: any, pipeline: any, readPreference: ?string, - hint: ?mixed + hint: ?mixed, + explain?: boolean ) { let isPointerField = false; pipeline = pipeline.map(stage => { @@ -797,6 +801,7 @@ export class MongoStorageAdapter implements StorageAdapter { readPreference, maxTimeMS: this._maxTimeMS, hint, + explain, }) ) .then(results => { diff --git a/src/Adapters/Storage/StorageAdapter.js b/src/Adapters/Storage/StorageAdapter.js index a693cd9d1b..ce134493bf 100644 --- a/src/Adapters/Storage/StorageAdapter.js +++ b/src/Adapters/Storage/StorageAdapter.js @@ -15,6 +15,7 @@ export type QueryOptions = { pipeline?: any, readPreference?: ?string, hint?: ?mixed, + explain?: Boolean, }; export type UpdateQueryOptions = { @@ -81,8 +82,7 @@ export interface StorageAdapter { className: string, schema: SchemaType, query: QueryType, - options: QueryOptions, - hint?: mixed + options: QueryOptions ): Promise<[any]>; ensureUniqueness( className: string, @@ -108,7 +108,8 @@ export interface StorageAdapter { schema: any, pipeline: any, readPreference: ?string, - hint: ?mixed + hint: ?mixed, + explain?: boolean ): Promise; performInitialization(options: ?any): Promise; diff --git a/src/Controllers/DatabaseController.js b/src/Controllers/DatabaseController.js index 1e3eb2451c..81e32aff62 100644 --- a/src/Controllers/DatabaseController.js +++ b/src/Controllers/DatabaseController.js @@ -1290,6 +1290,7 @@ class DatabaseController { pipeline, readPreference, hint, + explain, }: any = {}, auth: any = {}, validSchemaController: SchemaController.SchemaController @@ -1333,7 +1334,15 @@ class DatabaseController { sort.updatedAt = sort._updated_at; delete sort._updated_at; } - const queryOptions = { skip, limit, sort, keys, readPreference }; + const queryOptions = { + skip, + limit, + sort, + keys, + readPreference, + hint, + explain, + }; Object.keys(sort).forEach(fieldName => { if (fieldName.match(/^authData\.([a-zA-Z0-9_]+)\.id$/)) { throw new Parse.Error( @@ -1431,12 +1440,20 @@ class DatabaseController { schema, pipeline, readPreference, - hint + hint, + explain ); } + } else if (explain) { + return this.adapter.find( + className, + schema, + query, + queryOptions + ); } else { return this.adapter - .find(className, schema, query, queryOptions, hint) + .find(className, schema, query, queryOptions) .then(objects => objects.map(object => { object = untransformObjectACL(object); diff --git a/src/RestQuery.js b/src/RestQuery.js index 34599e9230..468446561c 100644 --- a/src/RestQuery.js +++ b/src/RestQuery.js @@ -32,8 +32,6 @@ function RestQuery( this.className = className; this.restWhere = restWhere; this.restOptions = restOptions; - this.hint = this.restOptions.hint; - delete this.restOptions.hint; this.clientSDK = clientSDK; this.runAfterFind = runAfterFind; this.response = null; @@ -120,6 +118,8 @@ function RestQuery( case 'includeAll': this.includeAll = true; break; + case 'explain': + case 'hint': case 'distinct': case 'pipeline': case 'skip': @@ -663,9 +663,6 @@ RestQuery.prototype.runFind = function(options = {}) { if (options.op) { findOptions.op = options.op; } - if (this.hint) { - findOptions.hint = this.hint; - } return this.config.database .find(this.className, this.restWhere, findOptions, this.auth) .then(results => { diff --git a/src/Routers/AggregateRouter.js b/src/Routers/AggregateRouter.js index 5a9d07265b..94544282e2 100644 --- a/src/Routers/AggregateRouter.js +++ b/src/Routers/AggregateRouter.js @@ -4,7 +4,7 @@ import * as middleware from '../middlewares'; import Parse from 'parse/node'; import UsersRouter from './UsersRouter'; -const BASE_KEYS = ['where', 'distinct', 'pipeline']; +const BASE_KEYS = ['where', 'distinct', 'pipeline', 'hint', 'explain']; const PIPELINE_KEYS = [ 'addFields', @@ -46,11 +46,18 @@ export class AggregateRouter extends ClassesRouter { if (body.distinct) { options.distinct = String(body.distinct); } + if (body.hint) { + options.hint = body.hint; + delete body.hint; + } + if (body.explain) { + options.explain = body.explain; + delete body.explain; + } options.pipeline = AggregateRouter.getPipeline(body); if (typeof body.where === 'string') { body.where = JSON.parse(body.where); } - options.hint = body.hint; return rest .find( req.config, @@ -97,7 +104,6 @@ export class AggregateRouter extends ClassesRouter { */ static getPipeline(body) { let pipeline = body.pipeline || body; - if (!Array.isArray(pipeline)) { pipeline = Object.keys(pipeline).map(key => { return { [key]: pipeline[key] }; diff --git a/src/Routers/ClassesRouter.js b/src/Routers/ClassesRouter.js index 6400b46c54..0cbc8d215d 100644 --- a/src/Routers/ClassesRouter.js +++ b/src/Routers/ClassesRouter.js @@ -174,6 +174,7 @@ export class ClassesRouter extends PromiseRouter { 'includeReadPreference', 'subqueryReadPreference', 'hint', + 'explain', ]; for (const key of Object.keys(body)) { @@ -226,6 +227,9 @@ export class ClassesRouter extends PromiseRouter { ) { options.hint = body.hint; } + if (body.explain) { + options.explain = body.explain; + } return options; } diff --git a/src/triggers.js b/src/triggers.js index 60468e3aff..05ee7c278f 100644 --- a/src/triggers.js +++ b/src/triggers.js @@ -499,6 +499,10 @@ export function maybeRunQueryTrigger( restOptions = restOptions || {}; restOptions.excludeKeys = jsonQuery.excludeKeys; } + if (jsonQuery.explain) { + restOptions = restOptions || {}; + restOptions.explain = jsonQuery.explain; + } if (jsonQuery.keys) { restOptions = restOptions || {}; restOptions.keys = jsonQuery.keys;