From 1b3a35c685411d7f69eee6af7f0447435f5b1e93 Mon Sep 17 00:00:00 2001 From: Rhys Howell Date: Thu, 13 Nov 2025 17:39:06 -0800 Subject: [PATCH] feat(query-parser): add hint parsing COMPASS-9373 --- packages/query-parser/src/index.spec.ts | 48 +++++++++++++++++++++ packages/query-parser/src/index.ts | 56 ++++++++++++++++++++++++- 2 files changed, 102 insertions(+), 2 deletions(-) diff --git a/packages/query-parser/src/index.spec.ts b/packages/query-parser/src/index.spec.ts index 87c08313..6e590ea9 100644 --- a/packages/query-parser/src/index.spec.ts +++ b/packages/query-parser/src/index.spec.ts @@ -6,6 +6,7 @@ import _debug from 'debug'; import { isCollationValid, isFilterValid, + isHintValid, isLimitValid, isMaxTimeMSValid, isProjectValid, @@ -719,6 +720,53 @@ e s`, }); }); + describe('hint', function () { + it('should default to null', function () { + assert.equal(isHintValid(''), null); + assert.equal(isHintValid(' '), null); + assert.equal(isHintValid('{}'), null); + }); + + it('should parse hint objects', function () { + assert.deepEqual(isHintValid('{_id: 1}'), { _id: 1 }); + assert.deepEqual(isHintValid('{_id: -1}'), { _id: -1 }); + assert.deepEqual(isHintValid('{pineapple: 1, age: -1}'), { + pineapple: 1, + age: -1, + }); + }); + + it('should accept string index names', function () { + assert.deepEqual(isHintValid('"pineapple"'), 'pineapple'); + assert.deepEqual(isHintValid("'pineapple'"), 'pineapple'); + }); + + it('should not accept arrays', function () { + assert.deepEqual(isHintValid('["pineappleOne", "pineappleTwo"]'), false); + }); + + it('should accept docs with numeric values', function () { + assert.deepEqual(isHintValid('{pineapple: 0}'), { pineapple: 0 }); + assert.deepEqual(isHintValid('{pineapple: -1}'), { pineapple: -1 }); + assert.deepEqual(isHintValid('{pineapple: NaN}'), { pineapple: NaN }); + assert.deepEqual(isHintValid('{pineapple: 2}'), { pineapple: 2 }); + }); + + it('should reject broken objects', function () { + assert.equal(isHintValid('{not_pineapple'), false); + assert.equal(isHintValid('invalid pineapple: }'), false); + assert.equal(isHintValid('{invalid pineapple}'), false); + assert.equal(isHintValid('{invalid pineapple: }'), false); + }); + + it('should reject non-string/non-object hint values', function () { + assert.equal(isHintValid('true'), false); + assert.equal(isHintValid('pineapple'), false); + assert.equal(isHintValid('123'), false); + assert.equal(isHintValid('null'), false); + }); + }); + describe('sort', function () { it('should default to null', function () { assert.equal(parseSort(''), null); diff --git a/packages/query-parser/src/index.ts b/packages/query-parser/src/index.ts index 5fd47c4b..9dc5a904 100644 --- a/packages/query-parser/src/index.ts +++ b/packages/query-parser/src/index.ts @@ -25,7 +25,7 @@ const DEFAULT_COLLATION = null; /** @public */ const DEFAULT_MAX_TIME_MS = 60000; // 1 minute in ms /** @public */ -const QUERY_PROPERTIES = ['filter', 'project', 'sort', 'skip', 'limit']; +const DEFAULT_HINT = null; function isEmpty(input: string | number | null | undefined): boolean { if (input === null || input === undefined) { @@ -62,6 +62,18 @@ export function parseSort(input: string) { return parseShellStringToEJSON(input, { mode: ParseMode.Loose }); } +function isValueOkForHint() { + /** + * Prior to MongoDB 7.0, hint would accept invalid values, like NaN. + * So we're on the looser side of validation here. + */ + return true; +} + +function _parseHint(input: string) { + return parseShellStringToEJSON(input, { mode: ParseMode.Loose }); +} + function _parseFilter(input: string) { return parseShellStringToEJSON(input, { mode: ParseMode.Loose, @@ -251,6 +263,45 @@ export function isSortValid(input: string) { } } +/** + * Validation function for a query `hint`. + * Must be a string, array, or a document with only -1 or 1 as values. + * @public + * + * @return false if not valid, otherwise the cleaned-up hint. + */ +export function isHintValid(input: string) { + if (isEmpty(input)) { + return DEFAULT_HINT; + } + + try { + const parsed = _parseHint(input); + + if (_.isString(parsed)) { + return parsed; + } + + if (_.isArray(parsed) || !_.isObject(parsed)) { + debug( + 'Hint "%s" is invalid. Only strings or documents are allowed', + input, + ); + return false; + } + + if (!_.every(parsed, isValueOkForHint)) { + debug('Hint "%s" is invalid bc of its values', input); + return false; + } + + return parsed; + } catch (e) { + debug('Hint "%s" is invalid', input, e); + return false; + } +} + /** * Validation function for a query `maxTimeMS`. Must be digits only. * @public @@ -299,6 +350,7 @@ const validatorFunctions = { isSkipValid, isCollationValid, isNumberValid, + isHintValid, }; /** @public */ @@ -333,7 +385,6 @@ export default function queryParser( export { stringify, toJSString, - QUERY_PROPERTIES, DEFAULT_FILTER, DEFAULT_SORT, DEFAULT_LIMIT, @@ -341,4 +392,5 @@ export { DEFAULT_PROJECT, DEFAULT_COLLATION, DEFAULT_MAX_TIME_MS, + DEFAULT_HINT, };