diff --git a/spec/ParseQuery.hint.spec.js b/spec/ParseQuery.hint.spec.js new file mode 100644 index 0000000000..43f1c4e9a7 --- /dev/null +++ b/spec/ParseQuery.hint.spec.js @@ -0,0 +1,170 @@ +'use strict'; + +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'); + }); + + 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(); + + 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('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 }); + }); + + 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/MongoCollection.js b/src/Adapters/Storage/Mongo/MongoCollection.js index 91c28b407d..7cabc522cd 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, explain } = {} + ) { // Support for Full Text Search - $text if (keys && keys.$score) { delete keys.$score; @@ -26,6 +29,8 @@ export default class MongoCollection { keys, maxTimeMS, readPreference, + hint, + explain, }).catch(error => { // Check for "no geoindex" error if ( @@ -54,18 +59,24 @@ export default class MongoCollection { keys, maxTimeMS, readPreference, + hint, + explain, }) ) ); }); } - _rawFind(query, { skip, limit, sort, keys, maxTimeMS, readPreference } = {}) { + _rawFind( + query, + { skip, limit, sort, keys, maxTimeMS, readPreference, hint, explain } = {} + ) { let findOperation = this._mongoCollection.find(query, { skip, limit, sort, readPreference, + hint, }); if (keys) { @@ -76,10 +87,10 @@ 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 } = {}) { + 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. @@ -96,6 +107,7 @@ export default class MongoCollection { sort, maxTimeMS, readPreference, + hint, }); return countOperation; @@ -105,9 +117,9 @@ export default class MongoCollection { return this._mongoCollection.distinct(field, query); } - aggregate(pipeline, { maxTimeMS, readPreference } = {}) { + 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 09d6cd319e..e60551bdb0 100644 --- a/src/Adapters/Storage/Mongo/MongoStorageAdapter.js +++ b/src/Adapters/Storage/Mongo/MongoStorageAdapter.js @@ -620,7 +620,7 @@ export class MongoStorageAdapter implements StorageAdapter { className: string, schema: SchemaType, query: QueryType, - { skip, limit, sort, keys, readPreference }: QueryOptions + { skip, limit, sort, keys, readPreference, hint, explain }: QueryOptions ): Promise { schema = convertParseSchemaToMongoSchema(schema); const mongoWhere = transformWhere(className, query, schema); @@ -652,13 +652,18 @@ export class MongoStorageAdapter implements StorageAdapter { keys: mongoKeys, 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)); } @@ -712,7 +717,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 +727,7 @@ export class MongoStorageAdapter implements StorageAdapter { collection.count(transformWhere(className, query, schema, true), { maxTimeMS: this._maxTimeMS, readPreference, + hint, }) ) .catch(err => this.handleError(err)); @@ -760,7 +767,9 @@ export class MongoStorageAdapter implements StorageAdapter { className: string, schema: any, pipeline: any, - readPreference: ?string + readPreference: ?string, + hint: ?mixed, + explain?: boolean ) { let isPointerField = false; pipeline = pipeline.map(stage => { @@ -791,6 +800,8 @@ export class MongoStorageAdapter implements StorageAdapter { collection.aggregate(pipeline, { readPreference, maxTimeMS: this._maxTimeMS, + hint, + explain, }) ) .then(results => { diff --git a/src/Adapters/Storage/StorageAdapter.js b/src/Adapters/Storage/StorageAdapter.js index 6de3ea3cbd..ce134493bf 100644 --- a/src/Adapters/Storage/StorageAdapter.js +++ b/src/Adapters/Storage/StorageAdapter.js @@ -14,6 +14,8 @@ export type QueryOptions = { distinct?: boolean, pipeline?: any, readPreference?: ?string, + hint?: ?mixed, + explain?: Boolean, }; export type UpdateQueryOptions = { @@ -92,7 +94,8 @@ export interface StorageAdapter { schema: SchemaType, query: QueryType, readPreference?: string, - estimate?: boolean + estimate?: boolean, + hint?: mixed ): Promise; distinct( className: string, @@ -104,7 +107,9 @@ export interface StorageAdapter { className: string, schema: any, pipeline: any, - readPreference: ?string + readPreference: ?string, + hint: ?mixed, + explain?: boolean ): Promise; performInitialization(options: ?any): Promise; diff --git a/src/Controllers/DatabaseController.js b/src/Controllers/DatabaseController.js index 0a643e64ee..81e32aff62 100644 --- a/src/Controllers/DatabaseController.js +++ b/src/Controllers/DatabaseController.js @@ -1289,13 +1289,14 @@ class DatabaseController { distinct, pipeline, readPreference, + hint, + explain, }: 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 @@ -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( @@ -1406,7 +1415,9 @@ class DatabaseController { className, schema, query, - readPreference + readPreference, + undefined, + hint ); } } else if (distinct) { @@ -1428,9 +1439,18 @@ class DatabaseController { className, schema, pipeline, - readPreference + readPreference, + hint, + explain ); } + } else if (explain) { + return this.adapter.find( + className, + schema, + query, + queryOptions + ); } else { return this.adapter .find(className, schema, query, queryOptions) diff --git a/src/RestQuery.js b/src/RestQuery.js index f2225ab3ee..468446561c 100644 --- a/src/RestQuery.js +++ b/src/RestQuery.js @@ -118,6 +118,8 @@ function RestQuery( case 'includeAll': this.includeAll = true; break; + case 'explain': + case 'hint': case 'distinct': case 'pipeline': case 'skip': diff --git a/src/Routers/AggregateRouter.js b/src/Routers/AggregateRouter.js index 591ebd3469..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,6 +46,14 @@ 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); @@ -96,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 056a8df207..0cbc8d215d 100644 --- a/src/Routers/ClassesRouter.js +++ b/src/Routers/ClassesRouter.js @@ -173,6 +173,8 @@ export class ClassesRouter extends PromiseRouter { 'readPreference', 'includeReadPreference', 'subqueryReadPreference', + 'hint', + 'explain', ]; for (const key of Object.keys(body)) { @@ -219,6 +221,15 @@ 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; + } + if (body.explain) { + options.explain = body.explain; + } return options; } diff --git a/src/triggers.js b/src/triggers.js index b340a4619c..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; @@ -507,6 +511,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;