adds ability to set hint on Parse.Query #6288 (#6322)

* added hint to aggregate

* added support for hint in query

* added else clause to aggregate

* fixed tests

* updated tests

* Add tests and clean up

* Add support for explain

Co-authored-by: Diamond Lewis <findlewis@gmail.com>
This commit is contained in:
stevestencil
2020-01-14 01:14:43 -07:00
committed by Diamond Lewis
parent 5a1d94ed88
commit 9842c6ee42
9 changed files with 267 additions and 21 deletions

View File

@@ -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 });
});
});

View File

@@ -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();
}

View File

@@ -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<any> {
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 => {

View File

@@ -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<number>;
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<any>;
performInitialization(options: ?any): Promise<void>;

View File

@@ -1289,13 +1289,14 @@ class DatabaseController {
distinct,
pipeline,
readPreference,
hint,
explain,
}: any = {},
auth: any = {},
validSchemaController: SchemaController.SchemaController
): Promise<any> {
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)

View File

@@ -118,6 +118,8 @@ function RestQuery(
case 'includeAll':
this.includeAll = true;
break;
case 'explain':
case 'hint':
case 'distinct':
case 'pipeline':
case 'skip':

View File

@@ -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] };

View File

@@ -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;
}

View File

@@ -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;