diff --git a/eslint.config.mjs b/eslint.config.mjs index ce6405da719..059138f2d34 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -124,6 +124,7 @@ export default [ _openGoldenData: true, _rand: true, _replMonitorStats: true, + _resultSetsEqualNormalized: true, _resultSetsEqualUnordered: true, _setShellFailPoint: true, _srand: true, diff --git a/jstests/core/shell/result_comparison.js b/jstests/core/shell/result_comparison.js index 25575894049..d32b07386f3 100644 --- a/jstests/core/shell/result_comparison.js +++ b/jstests/core/shell/result_comparison.js @@ -1,39 +1,67 @@ /* - * Tests `_resultSetsEqualUnordered`, which compares two sets of results (order of documents is - * disregarded) for equality. Field order inside an object is ignored, but array ordering and - * everything else is required for equality. + * Tests `_resultSetsEqualUnordered` and `_resultSetsEqualNormalized`, which compare two sets of + * results for equality. In `_resultSetsEqualUnordered`, field order inside an object is ignored, + * but array ordering and everything else is required for equality. In `_resultSetsEqualNormalized`, + * array ordering is also ignored, as well as some differences regarding numeric types and + * floating point closeness. */ const currentDate = new Date(); -// We should throw for invalid input. This function expects both arguments to be a list of objects. -assert.throwsWithCode(() => _resultSetsEqualUnordered({}, []), 9193201); -assert.throwsWithCode(() => _resultSetsEqualUnordered([], 5), 9193201); -assert.throwsWithCode(() => _resultSetsEqualUnordered([4, 1], []), 9193202); -assert.throwsWithCode(() => _resultSetsEqualUnordered([], ["abc"]), 9193203); -assert.throwsWithCode(() => _resultSetsEqualUnordered([[]], [{a: 1}]), 9193202); -assert.throwsWithCode(() => _resultSetsEqualUnordered([], undefined), 9193201); -assert.throwsWithCode(() => _resultSetsEqualUnordered([], null), 9193201); -assert.throwsWithCode(() => _resultSetsEqualUnordered([null], []), 9193202); +const comparisonFunctions = [_resultSetsEqualUnordered, _resultSetsEqualNormalized]; + +// We should throw for invalid input. These functions expect both arguments to be a list of objects. +comparisonFunctions.forEach(comparisonFn => { + assert.throwsWithCode(() => comparisonFn({}, []), 9193201); + assert.throwsWithCode(() => comparisonFn([], 5), 9193201); + assert.throwsWithCode(() => comparisonFn([4, 1], []), 9193202); + assert.throwsWithCode(() => comparisonFn([], ["abc"]), 9193203); + assert.throwsWithCode(() => comparisonFn([[]], [{a: 1}]), 9193202); + assert.throwsWithCode(() => comparisonFn([], undefined), 9193201); + assert.throwsWithCode(() => comparisonFn([], null), 9193201); + assert.throwsWithCode(() => comparisonFn([null], []), 9193202); +}); // Some base cases. -assert(_resultSetsEqualUnordered([], [])); -assert(_resultSetsEqualUnordered([{a: 1}], [{a: 1}])); -assert(_resultSetsEqualUnordered([{a: 1}, {a: 1}], [{a: 1}, {a: 1}])); -assert(_resultSetsEqualUnordered([{a: 1}, {b: 1}], [{b: 1}, {a: 1}])); -assert(_resultSetsEqualUnordered([{a: null}], [{a: null}])); -assert(!_resultSetsEqualUnordered([], [{a: 1}])); -assert(!_resultSetsEqualUnordered([{a: 1}], [])); -// Different types should fail the comparison. -assert(!_resultSetsEqualUnordered([{a: 1}], [{a: '1'}])); +comparisonFunctions.forEach(comparisonFn => { + assert(comparisonFn([], [])); + assert(comparisonFn([{a: 1}], [{a: 1}])); + assert(comparisonFn([{a: 1}, {a: 1}], [{a: 1}, {a: 1}])); + assert(comparisonFn([{a: 1}, {b: 1}], [{b: 1}, {a: 1}])); + assert(comparisonFn([{a: null}], [{a: null}])); + assert(!comparisonFn([], [{a: 1}])); + assert(!comparisonFn([{a: 1}], [])); +}); + +// Different non-numeric types should fail both comparisons. +comparisonFunctions.forEach(comparisonFn => { + assert(!comparisonFn([{a: 1}], [{a: '1'}])); + assert(!comparisonFn([{a: null}], [{}])); + assert(!comparisonFn([{a: null}], [{b: null}])); + assert(!comparisonFn([{a: null}], [{a: undefined}])); + assert(!comparisonFn([{}], [{a: undefined}])); +}); + +// Different numeric types should fail the unordered comparison. assert(!_resultSetsEqualUnordered([{a: 1}], [{a: NumberLong(1)}])); assert(!_resultSetsEqualUnordered([{a: 1}], [{a: NumberDecimal(1)}])); assert(!_resultSetsEqualUnordered([{a: NumberInt(1)}], [{a: NumberDecimal(1)}])); assert(!_resultSetsEqualUnordered([{a: NumberInt(1)}], [{a: NumberLong(1)}])); -assert(!_resultSetsEqualUnordered([{a: null}], [{}])); -assert(!_resultSetsEqualUnordered([{a: null}], [{b: null}])); -assert(!_resultSetsEqualUnordered([{a: null}], [{a: undefined}])); -assert(!_resultSetsEqualUnordered([{}], [{a: undefined}])); +// However, they should pass the normalized comparison. +assert(_resultSetsEqualNormalized([{a: 1}], [{a: NumberLong(1)}])); +assert(_resultSetsEqualNormalized([{a: 1}], [{a: NumberDecimal(1)}])); +assert(_resultSetsEqualNormalized([{a: NumberInt(1)}], [{a: NumberDecimal(1)}])); +assert(_resultSetsEqualNormalized([{a: NumberInt(1)}], [{a: NumberLong(1)}])); + +// Unordered comparison requires all values to be exactly equal. +assert(!_resultSetsEqualUnordered([{a: 0.14285714285714285}], [{a: 0.14285714285714288}])); +// Normalized comparison rounds doubles to 15 decimal places. +assert(_resultSetsEqualNormalized([{a: 0.14285714285714285}], [{a: 0.14285714285714288}])); +// Normalized comparison is sensitive to differences before the 15th decimal place. +assert(!_resultSetsEqualNormalized([{a: 0.142857142856}], [{a: 0.142857142855}])); +// Normalized comparison doesn't currently round decimals. +assert(!_resultSetsEqualNormalized([{a: NumberDecimal('0.14285714285714285')}], + [{a: NumberDecimal('0.14285714285714288')}])); /* * Given two sets of results - `equalResults` and `differentResults`, we test that all pairs of @@ -45,6 +73,9 @@ function assertExpectedOutputs(equalResults, differentResults) { for (const result2 of equalResults) { assert(_resultSetsEqualUnordered(result1, result2), {result1, result2}); assert(_resultSetsEqualUnordered(result2, result1), {result1, result2}); + // Additional normalizations shouldn't make the comparison more strict. + assert(_resultSetsEqualNormalized(result1, result2), {result1, result2}); + assert(_resultSetsEqualNormalized(result2, result1), {result1, result2}); } } for (const result1 of equalResults) { diff --git a/src/mongo/shell/shell_utils.cpp b/src/mongo/shell/shell_utils.cpp index 6ec874e7300..a77640f79a1 100644 --- a/src/mongo/shell/shell_utils.cpp +++ b/src/mongo/shell/shell_utils.cpp @@ -859,8 +859,7 @@ void sortBSONObjectInternallyHelper(const BSONObj& input, * Returns a new BSON with the same field/value pairings, but is recursively sorted by the fields. * By default, arrays are not sorted unless NormalizationOptsSet has the kSortArrays bit set. */ -BSONObj sortBSONObjectInternally(const BSONObj& input, - NormalizationOptsSet opts = NormalizationOpts::kSortBSON) { +BSONObj sortBSONObjectInternally(const BSONObj& input, NormalizationOptsSet opts) { BSONObjBuilder bob(input.objsize()); sortBSONObjectInternallyHelper(input, bob, opts); return bob.obj(); @@ -1048,20 +1047,14 @@ BSONObj normalizeBSONObj(const BSONObj& input, NormalizationOptsSet opts) { return result; } -/* - * Takes two arrays of documents, and returns whether they contain the same set of BSON Objects. The - * BSON do not need to be in the same order for this to return true. Has no special logic for - * handling double/NumberDecimal closeness. - */ -BSONObj _resultSetsEqualUnordered(const BSONObj& input, void*) { +bool compareNormalizedResultSets(const BSONObj& input, NormalizationOptsSet opts) { BSONObjIterator i(input); - uassert(9422901, "_resultSetsEqualUnordered expects two arguments", i.more()); + uassert(9422901, "expected two arguments", i.more()); auto first = i.next(); - uassert(9422902, "_resultSetsEqualUnordered expects two arguments", i.more()); + uassert(9422902, "expected two arguments", i.more()); auto second = i.next(); uassert(9193201, - str::stream() << "_resultSetsEqualUnordered expects two arrays of containing objects " - "as input received " + str::stream() << "expected two arrays containing objects as input received " << first.type() << " and " << second.type(), first.type() == BSONType::Array && second.type() == BSONType::Array); @@ -1070,45 +1063,70 @@ BSONObj _resultSetsEqualUnordered(const BSONObj& input, void*) { for (const auto& el : firstAsBson) { uassert(9193202, - str::stream() << "_resultSetsEqualUnordered expects all elements of input arrays " - "to be objects, received " + str::stream() << "expected all elements of input arrays to be objects, received " << el.type(), el.type() == BSONType::Object); } for (const auto& el : secondAsBson) { uassert(9193203, - str::stream() << "_resultSetsEqualUnordered expects all elements of input arrays " - "to be objects, received " + str::stream() << "expected all elements of input arrays to be objects, received " << el.type(), el.type() == BSONType::Object); } if (firstAsBson.size() != secondAsBson.size()) { - return BSON("" << false); + return false; } // Optimistically assume they're already in the same order. if (first.binaryEqualValues(second)) { - return BSON("" << true); + return true; } std::vector firstSorted; std::vector secondSorted; for (size_t i = 0; i < firstAsBson.size(); i++) { - firstSorted.push_back(sortBSONObjectInternally(firstAsBson[i].Obj())); - secondSorted.push_back(sortBSONObjectInternally(secondAsBson[i].Obj())); + firstSorted.push_back(normalizeBSONObj(firstAsBson[i].Obj(), opts)); + secondSorted.push_back(normalizeBSONObj(secondAsBson[i].Obj(), opts)); } - sortQueryResults(firstSorted); - sortQueryResults(secondSorted); + if (isSet(opts, NormalizationOpts::kSortResults)) { + sortQueryResults(firstSorted); + sortQueryResults(secondSorted); + } for (size_t i = 0; i < firstSorted.size(); i++) { if (!firstSorted[i].binaryEqual(secondSorted[i])) { - return BSON("" << false); + return false; } } - return BSON("" << true); + return true; +} + +/* + * Takes two arrays of documents, and returns whether they contain the same set of BSON Objects. + * Applies more normalizations than '_resultSetsEqualUnordered()'. Used by the fuzzer comparator. + * The following edge cases are currently accepted by the fuzzer but rejected by this function: + * - String comparison with collaction. + * - Numeric string vs other numeric types. + * - Rounding numeric types down to <15 digits. The fuzzer rounds to 6 or 10, depending on the type. + */ +BSONObj _resultSetsEqualNormalized(const BSONObj& input, void*) { + auto opts = NormalizationOpts::kSortResults | NormalizationOpts::kSortBSON | + NormalizationOpts::kSortArrays | NormalizationOpts::kNormalizeNumerics | + NormalizationOpts::kRoundFloatingPointNumerics; + return BSON("" << compareNormalizedResultSets(input, opts)); +} + +/* + * Takes two arrays of documents, and returns whether they contain the same set of BSON Objects. The + * BSON do not need to be in the same order for this to return true. Has no special logic for + * handling double/NumberDecimal closeness. Used in property based jstests. + */ +BSONObj _resultSetsEqualUnordered(const BSONObj& input, void*) { + auto opts = NormalizationOpts::kSortResults | NormalizationOpts::kSortBSON; + return BSON("" << compareNormalizedResultSets(input, opts)); } /* @@ -1170,6 +1188,7 @@ void installShellUtils(Scope& scope) { scope.injectNative("_buildBsonObj", _buildBsonObj); scope.injectNative("_fnvHashToHexString", _fnvHashToHexString); scope.injectNative("_resultSetsEqualUnordered", _resultSetsEqualUnordered); + scope.injectNative("_resultSetsEqualNormalized", _resultSetsEqualNormalized); scope.injectNative("_compareStringsWithCollation", _compareStringsWithCollation); installShellUtilsLauncher(scope); diff --git a/src/mongo/shell/shell_utils.d.ts b/src/mongo/shell/shell_utils.d.ts index f35429d632a..19d4bee785b 100644 --- a/src/mongo/shell/shell_utils.d.ts +++ b/src/mongo/shell/shell_utils.d.ts @@ -10,6 +10,19 @@ declare function _isWindows() declare function _openGoldenData() declare function _rand() declare function _replMonitorStats() +/** + * Compares two result sets after applying some normalizations. This function should only + * be used in the fuzzer. + * + * @param a First result set. + * @param b Second result set. + * + * @throws {Error} If the size of the BSON representation of 'a' and 'b' exceeds the BSON size limit + * (~16mb). + * + * @returns True if the result sets compare equal and false otherwise. + */ +declare function _resultSetsEqualNormalized(a: object[], b: object[]): boolean declare function _resultSetsEqualUnordered() declare function _setShellFailPoint() declare function _srand()