From a24881ad194e858dd9332ad9cf33cadcf0b7b99f Mon Sep 17 00:00:00 2001 From: LautaroPetaccio Date: Tue, 18 Aug 2026 10:10:31 -0300 Subject: [PATCH 1/7] Fix $all heuristic to quantify over the expected values The truthness for {"f": {"$all": [...]}} was aggregated as an AND over the elements of the array held by "f", where each element was scored by an OR over the expected values. That asks "is every stored element one of the queried values", which is the inverse of $all: the operator requires every queried value to be present in the stored array, and is indifferent to extra elements. The direction was inverted in both ways: {tags: {$all: ["a"]}} + {tags: ["a", "b"]} matches in MongoDB, but was scored as false; {tags: {$all: ["a","b","c"]}} + {tags: ["a"]} does not match, but was scored as fully true, ie distance 0. The second case is the damaging one: a condition no data can satisfy is reported as already covered, so the search stops trying to reach it. Aggregate as an AND over the expected values instead, scoring each one with an OR over the elements of the array, which also restores a usable gradient: a document missing fewer of the expected values now scores closer to true. The existing tests only compared identical lists, where both directions agree, so they did not catch this. The three added tests cover each failure direction and the gradient. --- .../mongo/MongoHeuristicsCalculator.java | 34 +++++++++++++++++-- .../mongo/MongoHeuristicsCalculatorTest.java | 34 +++++++++++++++++++ 2 files changed, 65 insertions(+), 3 deletions(-) diff --git a/client-java/controller/src/main/java/org/evomaster/client/java/controller/mongo/MongoHeuristicsCalculator.java b/client-java/controller/src/main/java/org/evomaster/client/java/controller/mongo/MongoHeuristicsCalculator.java index bfc6c52ff5..205c4c646e 100644 --- a/client-java/controller/src/main/java/org/evomaster/client/java/controller/mongo/MongoHeuristicsCalculator.java +++ b/client-java/controller/src/main/java/org/evomaster/client/java/controller/mongo/MongoHeuristicsCalculator.java @@ -532,6 +532,17 @@ private Truthness computeHeuristic(NotInOperation operation, Object document) } } + /** + * Computes the heuristic score for a {"f",{"$all": [v1, ..., vn] }} query. + * The condition holds when the array held by "f" contains every one of the expected values, + * so the score is an AND aggregation over the expected values, each of which is scored by an + * OR aggregation over the elements of the array. Note the direction: extra elements in the + * document are irrelevant, whereas a missing expected value makes the condition false. + * + * @param operation the {"f",{"$all": [...]}} query encapsulated as an AllOperation + * @param document the BSON document to evaluate the heuristic score against + * @return a Truthness object representing the distance of the document from meeting the condition + */ private Truthness computeHeuristic(AllOperation operation, Object document) { requireNonNullQueryAndDocument(operation, document); @@ -550,10 +561,10 @@ private Truthness computeHeuristic(AllOperation operation, Object document) { if (actualValuesList.isEmpty()) { return C_FALSE; } else { - Truthness res = buildAndAggregationTruthness(actualValuesList + Truthness res = buildAndAggregationTruthness(expectedValues .stream() - .map(actualValuesListElement -> - computeHeuristic(actualValuesListElement, expectedValues)) + .map(expectedValue -> + computeHeuristicForContainedValue(expectedValue, actualValuesList)) .toArray(Truthness[]::new)); return buildSafeScaledTruthness(res); } @@ -561,6 +572,23 @@ private Truthness computeHeuristic(AllOperation operation, Object document) { } } + /** + * Computes the heuristic score for the presence of a single expected value inside the array + * held by the queried field, ie an OR aggregation over the elements of that array. + * + * @param expectedValue a value that a {"f",{"$all": [...]}} query requires to be present + * @param actualValues the non-empty array held by the field "f" in the document + * @return a Truthness object representing how close the array is to containing the expected value + */ + private Truthness computeHeuristicForContainedValue(Object expectedValue, List actualValues) { + Objects.requireNonNull(actualValues); + + return buildOrAggregationTruthness(actualValues.stream() + .map(actualValue -> computeHeuristicComparisonNullableValues(expectedValue, actualValue, + SqlExpressionEvaluator.ComparisonOperatorType.EQUALS_TO)) + .toArray(Truthness[]::new)); + } + private static Truthness buildSafeScaledTruthness(Truthness truthness) { return buildSafeScaledTruthness(truthness.getOfTrue()); } diff --git a/client-java/controller/src/test/java/org/evomaster/client/java/controller/mongo/MongoHeuristicsCalculatorTest.java b/client-java/controller/src/test/java/org/evomaster/client/java/controller/mongo/MongoHeuristicsCalculatorTest.java index 50675c235e..b330d2c219 100644 --- a/client-java/controller/src/test/java/org/evomaster/client/java/controller/mongo/MongoHeuristicsCalculatorTest.java +++ b/client-java/controller/src/test/java/org/evomaster/client/java/controller/mongo/MongoHeuristicsCalculatorTest.java @@ -273,6 +273,40 @@ public void testAllBothActualAndExpectedListsAreEmpty() { assertTrue(calculator.computeHeuristicDocument(convertToDocument(allQuery), document).isFalse()); } + @Test + public void testAllMatchesWhenDocumentArrayHasExtraElements() { + // $all only requires the expected values to be present; extra elements are irrelevant + Document doc = new Document().append("employees", new ArrayList<>(Arrays.asList(1, 5, 6))); + Bson allQuery = Filters.all("employees", new ArrayList<>(Arrays.asList(1, 5))); + MongoHeuristicsCalculator calculator = new MongoHeuristicsCalculator(); + assertTrue(calculator.computeHeuristicDocument(convertToDocument(allQuery), doc).isTrue()); + } + + @Test + public void testAllDoesNotMatchWhenAnExpectedValueIsMissing() { + // the array contains 1, but not 2 nor 3, so the condition does not hold + Document doc = new Document().append("employees", new ArrayList<>(Arrays.asList(1))); + Bson allQuery = Filters.all("employees", new ArrayList<>(Arrays.asList(1, 2, 3))); + MongoHeuristicsCalculator calculator = new MongoHeuristicsCalculator(); + assertTrue(calculator.computeHeuristicDocument(convertToDocument(allQuery), doc).isFalse()); + } + + @Test + public void testAllGivesBetterScoreWhenFewerExpectedValuesAreMissing() { + Bson allQuery = Filters.all("employees", new ArrayList<>(Arrays.asList(1, 2, 3))); + Document closer = new Document().append("employees", new ArrayList<>(Arrays.asList(1, 2))); + Document farther = new Document().append("employees", new ArrayList<>(Arrays.asList(1))); + + MongoHeuristicsCalculator calculator = new MongoHeuristicsCalculator(); + Truthness closerTruthness = calculator.computeHeuristicDocument(convertToDocument(allQuery), closer); + Truthness fartherTruthness = calculator.computeHeuristicDocument(convertToDocument(allQuery), farther); + + assertTrue(closerTruthness.isFalse()); + assertTrue(fartherTruthness.isFalse()); + assertTrue(closerTruthness.getOfTrue() > fartherTruthness.getOfTrue(), + "a document missing fewer expected values must be scored closer to true"); + } + @Test public void testSize() { From d438457b5b27aef3418cc0b310ece8dac3b60ff5 Mon Sep 17 00:00:00 2001 From: LautaroPetaccio Date: Sat, 5 Sep 2026 20:06:54 -0300 Subject: [PATCH 2/7] Fix $ne between incomparable BSON types computeHeuristicComparisonNonNullValues falls back to C_FALSE when no comparison logic is defined for a pair of types, regardless of the operator. That is right for equality and for the ordering operators, but wrong for $ne: MongoDB treats values of different, mutually incomparable BSON types as different from each other, so an inequality check on them holds. {age: {$ne: "abc"}} against {age: 20} matches in MongoDB (verified on 7.0.40), but was scored as definitely false, with no gradient that could ever reach true. Return TRUE_C for NOT_EQUALS_TO in that fallback, and keep C_FALSE for the other operators. Ordering comparisons across different BSON types do not match in MongoDB either, so those were already correct; a test now pins that down. --- .../mongo/MongoHeuristicsCalculator.java | 14 ++++++-- .../mongo/MongoHeuristicsCalculatorTest.java | 33 +++++++++++++++++++ 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/client-java/controller/src/main/java/org/evomaster/client/java/controller/mongo/MongoHeuristicsCalculator.java b/client-java/controller/src/main/java/org/evomaster/client/java/controller/mongo/MongoHeuristicsCalculator.java index 205c4c646e..caf01bc778 100644 --- a/client-java/controller/src/main/java/org/evomaster/client/java/controller/mongo/MongoHeuristicsCalculator.java +++ b/client-java/controller/src/main/java/org/evomaster/client/java/controller/mongo/MongoHeuristicsCalculator.java @@ -274,9 +274,17 @@ private Truthness computeHeuristicComparisonNonNullValues(Object actualValue, Ob truthnessOfComparison = SqlExpressionEvaluator.calculateTruthnessForStringComparison(actualString, expectedString, comparisonOperatorType); } else { - // If both types are supported, but no actual comparison logic is defined, - // we considered them to be incompatible, therefore the comparison returns false. - truthnessOfComparison = C_FALSE; + /* + Both types are supported, but no comparison logic is defined for this combination, + ie the two values are of different, mutually incomparable BSON types. + MongoDB considers such values to be different from each other: an equality check is + then false, but an inequality check is true. Ordering comparisons do not match across + different BSON types either, so those stay false as well. + */ + truthnessOfComparison = + comparisonOperatorType == SqlExpressionEvaluator.ComparisonOperatorType.NOT_EQUALS_TO + ? TRUE_C + : C_FALSE; } return truthnessOfComparison; } diff --git a/client-java/controller/src/test/java/org/evomaster/client/java/controller/mongo/MongoHeuristicsCalculatorTest.java b/client-java/controller/src/test/java/org/evomaster/client/java/controller/mongo/MongoHeuristicsCalculatorTest.java index b330d2c219..2b0083abb2 100644 --- a/client-java/controller/src/test/java/org/evomaster/client/java/controller/mongo/MongoHeuristicsCalculatorTest.java +++ b/client-java/controller/src/test/java/org/evomaster/client/java/controller/mongo/MongoHeuristicsCalculatorTest.java @@ -689,6 +689,39 @@ public void testEqualsStringOnBooleanField() { assertTrue(distance.isFalse()); } + @Test + public void testNotEqualsStringOnIntegerField() { + // values of different, incomparable BSON types are different, so $ne holds + Document doc = new Document().append("value", 42); + Bson bson = Filters.ne("value", "abc"); + + Truthness distance = new MongoHeuristicsCalculator().computeHeuristicDocument(convertToDocument(bson), doc); + + assertTrue(distance.isTrue()); + } + + @Test + public void testNotEqualsIntegerOnBooleanField() { + Document doc = new Document().append("value", true); + Bson bson = Filters.ne("value", 1); + + Truthness distance = new MongoHeuristicsCalculator().computeHeuristicDocument(convertToDocument(bson), doc); + + assertTrue(distance.isTrue()); + } + + @Test + public void testOrderingComparisonsStayFalseAcrossIncomparableTypes() { + // unlike $ne, ordering operators do not match across different BSON types + Document doc = new Document().append("value", 42); + MongoHeuristicsCalculator calculator = new MongoHeuristicsCalculator(); + + assertTrue(calculator.computeHeuristicDocument(convertToDocument(Filters.gt("value", "abc")), doc).isFalse()); + assertTrue(calculator.computeHeuristicDocument(convertToDocument(Filters.gte("value", "abc")), doc).isFalse()); + assertTrue(calculator.computeHeuristicDocument(convertToDocument(Filters.lt("value", "abc")), doc).isFalse()); + assertTrue(calculator.computeHeuristicDocument(convertToDocument(Filters.lte("value", "abc")), doc).isFalse()); + } + @Test public void testEqualsDouble() { Document doc = new Document().append("score", 10.5d); From 27195bcc0bc641cba8627e807ec2b51b034f088e Mon Sep 17 00:00:00 2001 From: LautaroPetaccio Date: Sat, 5 Sep 2026 20:07:39 -0300 Subject: [PATCH 3/7] Fix $nin to consider the elements of an array-valued field $in checks each element when the queried field holds an array, but $nin passed the whole array to the comparison instead. A List compared against a scalar has no comparison logic defined, so it fell back to false, and the negation turned that into a full match with distance 0. {tags: {$nin: ["a"]}} against {tags: ["a", "b"]} does not match in MongoDB (verified on 7.0.40), but was reported as satisfied. $nin is the negation of $in, so both now share computeHeuristicForMembership and differ only by that negation. Two guards are needed by the shared path, and each fixes an IllegalArgumentException thrown out of the empty aggregation that would otherwise be reachable: an empty stored array holds none of the expected values, and an empty list of expected values cannot be matched. In MongoDB {tags: {$in: []}} matches nothing and {tags: {$nin: []}} matches everything, which is what these guards produce. --- .../mongo/MongoHeuristicsCalculator.java | 45 ++++++++++++++----- .../mongo/MongoHeuristicsCalculatorTest.java | 41 +++++++++++++++++ 2 files changed, 75 insertions(+), 11 deletions(-) diff --git a/client-java/controller/src/main/java/org/evomaster/client/java/controller/mongo/MongoHeuristicsCalculator.java b/client-java/controller/src/main/java/org/evomaster/client/java/controller/mongo/MongoHeuristicsCalculator.java index caf01bc778..4911154ca6 100644 --- a/client-java/controller/src/main/java/org/evomaster/client/java/controller/mongo/MongoHeuristicsCalculator.java +++ b/client-java/controller/src/main/java/org/evomaster/client/java/controller/mongo/MongoHeuristicsCalculator.java @@ -492,8 +492,27 @@ private Truthness computeHeuristic(AndOperation operation, Object document) { private Truthness computeHeuristic(InOperation operation, Object document) { requireNonNullQueryAndDocument(operation, document); - List expectedValueList = operation.getValues(); - final String fieldName = operation.getFieldName(); + return computeHeuristicForMembership(operation.getFieldName(), operation.getValues(), document); + } + + /** + * Computes the heuristic score for the membership of a field's value in a list of expected + * values, ie the condition shared by {"f",{"$in": [...]}} and, negated, by + * {"f",{"$nin": [...]}}. When the field holds an array, MongoDB checks each of its elements, + * and the condition holds if any of them is one of the expected values. + * + * @param fieldName the name of the field the query is about + * @param expectedValues the values the query lists as candidates + * @param document the BSON document to evaluate the heuristic score against + * @return a Truthness object representing how close the field is to holding one of the values + */ + private Truthness computeHeuristicForMembership(String fieldName, List expectedValues, Object document) { + Objects.requireNonNull(expectedValues); + + if (expectedValues.isEmpty()) { + // no candidate value can be matched + return C_FALSE; + } final Object actualValue; if (documentContainsField(document, fieldName)) { @@ -505,16 +524,18 @@ private Truthness computeHeuristic(InOperation operation, Object document) { actualValue = null; } - final Truthness res; if (actualValue instanceof List) { List actualValueList = (List) actualValue; - res = buildOrAggregationTruthness(actualValueList.stream() - .map(value -> computeHeuristic(value, expectedValueList)) + if (actualValueList.isEmpty()) { + // an empty array holds none of the expected values + return C_FALSE; + } + return buildOrAggregationTruthness(actualValueList.stream() + .map(value -> computeHeuristic(value, expectedValues)) .toArray(Truthness[]::new)); } else { - res = computeHeuristic(actualValue, expectedValueList); + return computeHeuristic(actualValue, expectedValues); } - return res; } private Truthness computeHeuristic(Object actualValue, List expectedValueList) { @@ -529,15 +550,17 @@ private Truthness computeHeuristic(Object actualValue, List expectedValueList private Truthness computeHeuristic(NotInOperation operation, Object document) { requireNonNullQueryAndDocument(operation, document); - List expectedValues = operation.getValues(); final String fieldName = operation.getFieldName(); if (!documentContainsField(document, fieldName)) { + // a value that is not there cannot be one of the excluded ones return TRUE_C; - } else { - Object actualValue = getValue(document, fieldName); - return computeHeuristic(actualValue, expectedValues).invert(); } + /* + $nin is the negation of $in, so it must consider the array elements in the same way: + a document whose array holds any of the excluded values does not match. + */ + return computeHeuristicForMembership(fieldName, operation.getValues(), document).invert(); } /** diff --git a/client-java/controller/src/test/java/org/evomaster/client/java/controller/mongo/MongoHeuristicsCalculatorTest.java b/client-java/controller/src/test/java/org/evomaster/client/java/controller/mongo/MongoHeuristicsCalculatorTest.java index 2b0083abb2..ea444aede3 100644 --- a/client-java/controller/src/test/java/org/evomaster/client/java/controller/mongo/MongoHeuristicsCalculatorTest.java +++ b/client-java/controller/src/test/java/org/evomaster/client/java/controller/mongo/MongoHeuristicsCalculatorTest.java @@ -219,6 +219,47 @@ public void testNotInMissingField() { assertTrue(distanceMatch.isTrue()); } + @Test + public void testNotInArrayFieldHoldingAnExcludedValue() { + // the array holds "a", which is excluded, so the document does not match + Document doc = new Document().append("tags", new ArrayList<>(Arrays.asList("a", "b"))); + Bson bson = Filters.nin("tags", new ArrayList<>(Arrays.asList("a"))); + Truthness distance = new MongoHeuristicsCalculator().computeHeuristicDocument(convertToDocument(bson), doc); + assertTrue(distance.isFalse()); + } + + @Test + public void testNotInArrayFieldHoldingNoExcludedValue() { + Document doc = new Document().append("tags", new ArrayList<>(Arrays.asList("a", "b"))); + Bson bson = Filters.nin("tags", new ArrayList<>(Arrays.asList("z"))); + Truthness distance = new MongoHeuristicsCalculator().computeHeuristicDocument(convertToDocument(bson), doc); + assertTrue(distance.isTrue()); + } + + @Test + public void testInAndNotInOnEmptyArrayField() { + Document doc = new Document().append("tags", Collections.emptyList()); + MongoHeuristicsCalculator calculator = new MongoHeuristicsCalculator(); + + Bson in = Filters.in("tags", new ArrayList<>(Arrays.asList("a"))); + Bson nin = Filters.nin("tags", new ArrayList<>(Arrays.asList("a"))); + + assertTrue(calculator.computeHeuristicDocument(convertToDocument(in), doc).isFalse()); + assertTrue(calculator.computeHeuristicDocument(convertToDocument(nin), doc).isTrue()); + } + + @Test + public void testInAndNotInOnEmptyExpectedList() { + Document doc = new Document().append("tags", new ArrayList<>(Arrays.asList("a", "b"))); + MongoHeuristicsCalculator calculator = new MongoHeuristicsCalculator(); + + Bson in = Filters.in("tags", Collections.emptyList()); + Bson nin = Filters.nin("tags", Collections.emptyList()); + + assertTrue(calculator.computeHeuristicDocument(convertToDocument(in), doc).isFalse()); + assertTrue(calculator.computeHeuristicDocument(convertToDocument(nin), doc).isTrue()); + } + @Test public void testAll() { Document doc = new Document().append("employees", new ArrayList<>(Arrays.asList(1, 5, 6))); From cc88c10b14742b97d693d1cc6b5dd4b1e889c3cd Mon Sep 17 00:00:00 2001 From: LautaroPetaccio Date: Sat, 5 Sep 2026 20:08:12 -0300 Subject: [PATCH 4/7] Fix $not on a missing field overriding the inner operator computeHeuristic for $not returned true whenever the queried field was absent from the document. That is right only for inner operators that do not match an absent field. Several do match one, and for those the negation must be false. {a: {$not: {$ne: 5}}} against {} does not match in MongoDB (verified on {a: {$not: {$nin: [1, 2]}}} 7.0.40), because the inner operator matches the {a: {$not: {$exists: false}}} absent field. All three were reported as satisfied. The generic path below the shortcut already produced the right answer in these cases, since the inner operators treat an absent field as a null value, so the shortcut was actively discarding a correct result. Removing it also keeps the complementary cases ($eq, $in, $exists:true, $gt, $size) true, as they were before. --- .../mongo/MongoHeuristicsCalculator.java | 15 ++++---- .../mongo/MongoHeuristicsCalculatorTest.java | 34 +++++++++++++++++++ 2 files changed, 42 insertions(+), 7 deletions(-) diff --git a/client-java/controller/src/main/java/org/evomaster/client/java/controller/mongo/MongoHeuristicsCalculator.java b/client-java/controller/src/main/java/org/evomaster/client/java/controller/mongo/MongoHeuristicsCalculator.java index 4911154ca6..66e0e8fd09 100644 --- a/client-java/controller/src/main/java/org/evomaster/client/java/controller/mongo/MongoHeuristicsCalculator.java +++ b/client-java/controller/src/main/java/org/evomaster/client/java/controller/mongo/MongoHeuristicsCalculator.java @@ -783,13 +783,14 @@ private Truthness computeHeuristic(BitsOperation operation, Object document) { private Truthness computeHeuristic(NotOperation operation, Object document) { requireNonNullQueryAndDocument(operation, document); - String fieldName = operation.getFieldName(); - if (!documentContainsField(document, fieldName)) { - return TRUE_C; - } else { - QueryOperation condition = operation.getCondition(); - return computeHeuristicOnDocument(condition, document).invert(); - } + /* + No special case for a missing field here. Several operators do match a document in + which the field is absent (eg $ne, $nin, and $exists with "false"), and $not must then + be false. The inner operators already treat an absent field as a null value, so + negating their score is correct whether or not the field is there. + */ + QueryOperation condition = operation.getCondition(); + return computeHeuristicOnDocument(condition, document).invert(); } private Truthness computeHeuristic(NorOperation operation, Object document) { diff --git a/client-java/controller/src/test/java/org/evomaster/client/java/controller/mongo/MongoHeuristicsCalculatorTest.java b/client-java/controller/src/test/java/org/evomaster/client/java/controller/mongo/MongoHeuristicsCalculatorTest.java index ea444aede3..d6bbea425c 100644 --- a/client-java/controller/src/test/java/org/evomaster/client/java/controller/mongo/MongoHeuristicsCalculatorTest.java +++ b/client-java/controller/src/test/java/org/evomaster/client/java/controller/mongo/MongoHeuristicsCalculatorTest.java @@ -602,6 +602,40 @@ public void testNotMissingField() { assertTrue(distanceMatch.isTrue()); } + @Test + public void testNotMissingFieldWithInnerOperatorThatMatchesAbsentField() { + /* + $ne, $nin and $exists:false all match a document in which the field is absent, + so negating them must not match. This is the case a blanket "true when the field + is missing" answer gets wrong. + */ + Document doc = new Document().append("name", "Bob"); // "age" field is undefined + MongoHeuristicsCalculator calculator = new MongoHeuristicsCalculator(); + + Bson notNotEquals = Filters.not(Filters.ne("age", 5)); + Bson notNotIn = Filters.not(Filters.nin("age", new ArrayList<>(Arrays.asList(1, 2)))); + Bson notExistsFalse = Filters.not(Filters.exists("age", false)); + + assertTrue(calculator.computeHeuristicDocument(convertToDocument(notNotEquals), doc).isFalse()); + assertTrue(calculator.computeHeuristicDocument(convertToDocument(notNotIn), doc).isFalse()); + assertTrue(calculator.computeHeuristicDocument(convertToDocument(notExistsFalse), doc).isFalse()); + } + + @Test + public void testNotMissingFieldWithInnerOperatorThatDoesNotMatchAbsentField() { + // the complementary cases, which must stay true once the missing-field shortcut is gone + Document doc = new Document().append("name", "Bob"); // "age" field is undefined + MongoHeuristicsCalculator calculator = new MongoHeuristicsCalculator(); + + Bson notEquals = Filters.not(Filters.eq("age", 5)); + Bson notIn = Filters.not(Filters.in("age", new ArrayList<>(Arrays.asList(1, 2)))); + Bson notExistsTrue = Filters.not(Filters.exists("age", true)); + + assertTrue(calculator.computeHeuristicDocument(convertToDocument(notEquals), doc).isTrue()); + assertTrue(calculator.computeHeuristicDocument(convertToDocument(notIn), doc).isTrue()); + assertTrue(calculator.computeHeuristicDocument(convertToDocument(notExistsTrue), doc).isTrue()); + } + @Test public void testNotNullValue() { Document doc = new Document().append("age", null); From 401d0e302a463df51e3145afda27022daec7f8dc Mon Sep 17 00:00:00 2001 From: LautaroPetaccio Date: Sat, 5 Sep 2026 21:58:17 -0300 Subject: [PATCH 5/7] Add E2E coverage for the $ne, $nin and $not operator shapes that were fixed The existing endpoints exercise these operators only in shapes that were already handled correctly: "ne" compares two numbers, "nin" is applied to a scalar field, and "not" wraps a $gt, which does not match an absent field. None of them reaches the paths that were wrong. Three endpoints are added for the shapes that were: neCrossType $ne between a string field and a number, ie two incomparable BSON types ninArrayField $nin on a field holding an array, which must be checked element-wise notExistsFalse $not wrapping an operator that does match a document lacking the field Note that "ninArrayField" is deliberately not satisfied by the two documents that saveData inserts, as both of them hold "a", so it can only be covered by data that EvoMaster generates itself. --- .../mongoqueries/MongoQueriesController.java | 28 +++++++++++++++++++ .../mongoqueries/MongoQueriesEMTest.java | 3 ++ 2 files changed, 31 insertions(+) diff --git a/core-tests/e2e-tests/spring/spring-rest-mongo/src/main/java/com/mongo/mongoqueries/MongoQueriesController.java b/core-tests/e2e-tests/spring/spring-rest-mongo/src/main/java/com/mongo/mongoqueries/MongoQueriesController.java index 64ddbb0594..b2f32ee577 100644 --- a/core-tests/e2e-tests/spring/spring-rest-mongo/src/main/java/com/mongo/mongoqueries/MongoQueriesController.java +++ b/core-tests/e2e-tests/spring/spring-rest-mongo/src/main/java/com/mongo/mongoqueries/MongoQueriesController.java @@ -155,6 +155,34 @@ public ResponseEntity findAll() { return executeQuery(new Document("tags", new Document("$all", Arrays.asList("a", "b")))); } + /** + * "name" holds a string, so it is of a different, incomparable BSON type than the + * number being compared against. MongoDB considers such values different, so $ne holds. + */ + @GetMapping("neCrossType") + public ResponseEntity findNeCrossType() { + return executeQuery(new Document("name", new Document("$ne", 42))); + } + + /** + * "tags" holds an array, so $nin must look at its elements: only a document whose + * array does not hold "a" satisfies this. + */ + @GetMapping("ninArrayField") + public ResponseEntity findNinArrayField() { + return executeQuery(new Document("tags", new Document("$nin", Arrays.asList("a")))); + } + + /** + * $exists with "false" matches a document in which the field is absent, so negating it + * must match only the documents that do have the field. + */ + @GetMapping("notExistsFalse") + public ResponseEntity findNotExistsFalse() { + return executeQuery(new Document("description", + new Document("$not", new Document("$exists", false)))); + } + @GetMapping("type") public ResponseEntity findType() { return executeQuery(new Document("name", new Document("$type", 2))); diff --git a/core-tests/e2e-tests/spring/spring-rest-mongo/src/test/java/org/evomaster/e2etests/spring/rest/mongo/mongoqueries/MongoQueriesEMTest.java b/core-tests/e2e-tests/spring/spring-rest-mongo/src/test/java/org/evomaster/e2etests/spring/rest/mongo/mongoqueries/MongoQueriesEMTest.java index 86a9ce5cb5..034b48d6dc 100644 --- a/core-tests/e2e-tests/spring/spring-rest-mongo/src/test/java/org/evomaster/e2etests/spring/rest/mongo/mongoqueries/MongoQueriesEMTest.java +++ b/core-tests/e2e-tests/spring/spring-rest-mongo/src/test/java/org/evomaster/e2etests/spring/rest/mongo/mongoqueries/MongoQueriesEMTest.java @@ -57,6 +57,9 @@ public void testRunEM() throws Throwable { assertHasAtLeastOne(solution, HttpVerb.GET, 200, "/mongoqueries/bitsAllSet", null); assertHasAtLeastOne(solution, HttpVerb.GET, 200, "/mongoqueries/bitsAnyClear", null); assertHasAtLeastOne(solution, HttpVerb.GET, 200, "/mongoqueries/all", null); + assertHasAtLeastOne(solution, HttpVerb.GET, 200, "/mongoqueries/neCrossType", null); + assertHasAtLeastOne(solution, HttpVerb.GET, 200, "/mongoqueries/ninArrayField", null); + assertHasAtLeastOne(solution, HttpVerb.GET, 200, "/mongoqueries/notExistsFalse", null); assertHasAtLeastOne(solution, HttpVerb.GET, 200, "/mongoqueries/type", null); assertHasAtLeastOne(solution, HttpVerb.GET, 200, "/mongoqueries/exists", null); assertHasAtLeastOne(solution, HttpVerb.GET, 200, "/mongoqueries/nor", null); From 5ee8c1bf70be67cb1c2b3187ab5a1c0ccf4dc2db Mon Sep 17 00:00:00 2001 From: LautaroPetaccio Date: Sun, 6 Sep 2026 08:29:56 -0300 Subject: [PATCH 6/7] Match a field against a value the way MongoDB does $all, $in and $nin each decided on their own what it means for a field to hold a value, and each got a different part of it wrong: {f: {$all: ["a"]}} against {f: "a"} matches in MongoDB, but any non-array field was scored false {tags: {$in: [["a","b"]]}} against {tags: ["a","b"]} matches in MongoDB, but only the elements were ever compared, never the array itself MongoDB has one rule for all of them: a field matches a value when its own value is that value, or when it holds an array of which at least one element is. Both readings apply at once, so {"tags": ["a","b"]} is matched both by ["a","b"] and by "a". computeHeuristicForMatchedValue now implements that rule, and $all, $in and $nin are expressed in terms of it: $all is an AND over the expected values, $in an OR, and $nin the negation of $in. This drops the special cases for scalar and empty arrays, which the rule covers on its own, and removes 11 lines overall. Verified against MongoDB 7.0.40, including the cases where the two readings differ: ["b","a"] does not match ["a","b"], since arrays are equal only in the same order, while an array holding ["a","b"] as an element does. Also fixes an unrelated crash this uncovered, present since before this series: comparing two empty arrays took the branch for lists of equal size, and aggregated over their zero elements, throwing IllegalArgumentException instead of reporting the two as equal. It is reachable from a plain {f: {$eq: []}}, and now from {f: {$in: [[]]}}. --- .../mongo/MongoHeuristicsCalculator.java | 99 +++++++++---------- .../mongo/MongoHeuristicsCalculatorTest.java | 81 +++++++++++++++ 2 files changed, 127 insertions(+), 53 deletions(-) diff --git a/client-java/controller/src/main/java/org/evomaster/client/java/controller/mongo/MongoHeuristicsCalculator.java b/client-java/controller/src/main/java/org/evomaster/client/java/controller/mongo/MongoHeuristicsCalculator.java index 66e0e8fd09..04fff7905a 100644 --- a/client-java/controller/src/main/java/org/evomaster/client/java/controller/mongo/MongoHeuristicsCalculator.java +++ b/client-java/controller/src/main/java/org/evomaster/client/java/controller/mongo/MongoHeuristicsCalculator.java @@ -498,8 +498,8 @@ private Truthness computeHeuristic(InOperation operation, Object document) { /** * Computes the heuristic score for the membership of a field's value in a list of expected * values, ie the condition shared by {"f",{"$in": [...]}} and, negated, by - * {"f",{"$nin": [...]}}. When the field holds an array, MongoDB checks each of its elements, - * and the condition holds if any of them is one of the expected values. + * {"f",{"$nin": [...]}}. The condition holds when the field matches any one of the expected + * values, in the sense of {@link #computeHeuristicForMatchedValue(Object, Object)}. * * @param fieldName the name of the field the query is about * @param expectedValues the values the query lists as candidates @@ -524,27 +524,9 @@ private Truthness computeHeuristicForMembership(String fieldName, List expect actualValue = null; } - if (actualValue instanceof List) { - List actualValueList = (List) actualValue; - if (actualValueList.isEmpty()) { - // an empty array holds none of the expected values - return C_FALSE; - } - return buildOrAggregationTruthness(actualValueList.stream() - .map(value -> computeHeuristic(value, expectedValues)) - .toArray(Truthness[]::new)); - } else { - return computeHeuristic(actualValue, expectedValues); - } - } - - private Truthness computeHeuristic(Object actualValue, List expectedValueList) { - Objects.requireNonNull(expectedValueList); - - Truthness res = buildOrAggregationTruthness(expectedValueList.stream() - .map(expectedValue -> computeHeuristicComparisonNullableValues(expectedValue, actualValue, SqlExpressionEvaluator.ComparisonOperatorType.EQUALS_TO)) + return buildOrAggregationTruthness(expectedValues.stream() + .map(expectedValue -> computeHeuristicForMatchedValue(expectedValue, actualValue)) .toArray(Truthness[]::new)); - return res; } private Truthness computeHeuristic(NotInOperation operation, Object document) { @@ -565,10 +547,11 @@ private Truthness computeHeuristic(NotInOperation operation, Object document) /** * Computes the heuristic score for a {"f",{"$all": [v1, ..., vn] }} query. - * The condition holds when the array held by "f" contains every one of the expected values, - * so the score is an AND aggregation over the expected values, each of which is scored by an - * OR aggregation over the elements of the array. Note the direction: extra elements in the - * document are irrelevant, whereas a missing expected value makes the condition false. + * The condition holds when "f" matches every one of the expected values, so the score is an + * AND aggregation over them. Note the direction: extra elements in the document are + * irrelevant, whereas a missing expected value makes the condition false. + * The field does not have to hold an array: a scalar matches a $all listing only values + * equal to it, which is why {"f": "a"} is matched by {"$all": ["a"]}. * * @param operation the {"f",{"$all": [...]}} query encapsulated as an AllOperation * @param document the BSON document to evaluate the heuristic score against @@ -584,40 +567,47 @@ private Truthness computeHeuristic(AllOperation operation, Object document) { } else if (expectedValues.isEmpty()) { return C_FALSE; } else { - Object actualValues = getValue(document, fieldName); - if (actualValues == null || !(actualValues instanceof List)) { - return C_FALSE; - } else { - List actualValuesList = (List) actualValues; - if (actualValuesList.isEmpty()) { - return C_FALSE; - } else { - Truthness res = buildAndAggregationTruthness(expectedValues - .stream() - .map(expectedValue -> - computeHeuristicForContainedValue(expectedValue, actualValuesList)) - .toArray(Truthness[]::new)); - return buildSafeScaledTruthness(res); - } - } + Object actualValue = getValue(document, fieldName); + Truthness res = buildAndAggregationTruthness(expectedValues + .stream() + .map(expectedValue -> computeHeuristicForMatchedValue(expectedValue, actualValue)) + .toArray(Truthness[]::new)); + return buildSafeScaledTruthness(res); } } /** - * Computes the heuristic score for the presence of a single expected value inside the array - * held by the queried field, ie an OR aggregation over the elements of that array. + * Computes the heuristic score for MongoDB's matching of a field against a single value, + * ie the condition that the field holds that value. The field matches when its own value is + * the expected one, and also, if it holds an array, when any element of that array is. + * Both readings apply at once: {"f": ["a","b"]} is matched both by the value ["a","b"] and + * by the value "a". * - * @param expectedValue a value that a {"f",{"$all": [...]}} query requires to be present - * @param actualValues the non-empty array held by the field "f" in the document - * @return a Truthness object representing how close the array is to containing the expected value + * @param expectedValue the value the query requires the field to hold + * @param actualValue the value held by the field in the document, possibly an array or null + * @return a Truthness object representing how close the field is to holding the expected value */ - private Truthness computeHeuristicForContainedValue(Object expectedValue, List actualValues) { - Objects.requireNonNull(actualValues); + private Truthness computeHeuristicForMatchedValue(Object expectedValue, Object actualValue) { - return buildOrAggregationTruthness(actualValues.stream() - .map(actualValue -> computeHeuristicComparisonNullableValues(expectedValue, actualValue, - SqlExpressionEvaluator.ComparisonOperatorType.EQUALS_TO)) - .toArray(Truthness[]::new)); + Truthness wholeValue = computeHeuristicComparisonNullableValues(expectedValue, actualValue, + SqlExpressionEvaluator.ComparisonOperatorType.EQUALS_TO); + + if (!(actualValue instanceof List)) { + return wholeValue; + } + + /* + The array as a whole is one of the options, which is also what makes an empty array + work: it holds no element to compare, but it can still be equal to the expected value. + */ + List actualValueList = (List) actualValue; + Truthness[] options = new Truthness[actualValueList.size() + 1]; + options[0] = wholeValue; + for (int i = 0; i < actualValueList.size(); i++) { + options[i + 1] = computeHeuristicComparisonNullableValues(expectedValue, + actualValueList.get(i), SqlExpressionEvaluator.ComparisonOperatorType.EQUALS_TO); + } + return buildOrAggregationTruthness(options); } private static Truthness buildSafeScaledTruthness(Truthness truthness) { @@ -969,6 +959,9 @@ private Truthness calculateTruthnessForListComparison(List actualList, List(Arrays.asList("a", "b"))); + Bson query = Filters.all("tags", Arrays.asList(Arrays.asList("a", "b"))); + assertTrue(new MongoHeuristicsCalculator() + .computeHeuristicDocument(convertToDocument(query), doc).isTrue()); + } + + @Test + public void testInMatchingTheArrayAsAWhole() { + Document doc = new Document().append("tags", new ArrayList<>(Arrays.asList("a", "b"))); + MongoHeuristicsCalculator calculator = new MongoHeuristicsCalculator(); + + Bson sameOrder = Filters.in("tags", Arrays.asList(Arrays.asList("a", "b"))); + Bson otherOrder = Filters.in("tags", Arrays.asList(Arrays.asList("b", "a"))); + + // an array is equal to another one only when their elements are in the same order + assertTrue(calculator.computeHeuristicDocument(convertToDocument(sameOrder), doc).isTrue()); + assertTrue(calculator.computeHeuristicDocument(convertToDocument(otherOrder), doc).isFalse()); + } + + @Test + public void testNotInMatchingTheArrayAsAWhole() { + Document doc = new Document().append("tags", new ArrayList<>(Arrays.asList("a", "b"))); + Bson query = Filters.nin("tags", Arrays.asList(Arrays.asList("a", "b"))); + assertTrue(new MongoHeuristicsCalculator() + .computeHeuristicDocument(convertToDocument(query), doc).isFalse()); + } + + @Test + public void testInOnEmptyArrayFieldMatchingTheEmptyArray() { + // the empty array holds no element, but it is still equal to the empty array + Document doc = new Document().append("tags", Collections.emptyList()); + MongoHeuristicsCalculator calculator = new MongoHeuristicsCalculator(); + + Bson in = Filters.in("tags", Arrays.asList(Collections.emptyList())); + Bson nin = Filters.nin("tags", Arrays.asList(Collections.emptyList())); + + assertTrue(calculator.computeHeuristicDocument(convertToDocument(in), doc).isTrue()); + assertTrue(calculator.computeHeuristicDocument(convertToDocument(nin), doc).isFalse()); + } + + @Test + public void testInMatchingAnArrayElementThatIsItselfAnArray() { + Document doc = new Document().append("tags", + new ArrayList<>(Arrays.asList(Arrays.asList("a", "b"), "c"))); + Bson query = Filters.in("tags", Arrays.asList(Arrays.asList("a", "b"))); + assertTrue(new MongoHeuristicsCalculator() + .computeHeuristicDocument(convertToDocument(query), doc).isTrue()); + } + @Test public void testAllMatchesWhenDocumentArrayHasExtraElements() { // $all only requires the expected values to be present; extra elements are irrelevant @@ -1117,6 +1185,19 @@ public void testEqualsLists() { assertTrue(distanceNotMatch.isFalse()); } + @Test + public void testEqualsBetweenEmptyLists() { + // two empty arrays are equal; comparing them used to have no element to aggregate over + Document doc = new Document().append("employees", Collections.emptyList()); + MongoHeuristicsCalculator calculator = new MongoHeuristicsCalculator(); + + Bson equals = Filters.eq("employees", Collections.emptyList()); + Bson notEquals = Filters.ne("employees", Collections.emptyList()); + + assertTrue(calculator.computeHeuristicDocument(convertToDocument(equals), doc).isTrue()); + assertTrue(calculator.computeHeuristicDocument(convertToDocument(notEquals), doc).isFalse()); + } + @Test public void testNotEqualsLists() { Document doc = new Document().append("employees", Arrays.asList("Alice")); From 20de6fa847e444cf5743bebe52602717b99e620d Mon Sep 17 00:00:00 2001 From: LautaroPetaccio Date: Sun, 6 Sep 2026 08:47:06 -0300 Subject: [PATCH 7/7] Apply the value matching rule to $eq and $ne as well $eq compared the field's value as a whole, so it missed the other half of MongoDB's matching rule: a field holding an array is also matched by any one of its elements. {f: {$eq: "a"}} against {f: ["a", "b"]} matches in MongoDB (verified on 7.0.40), but was scored false. $ne is the exact negation of $eq, and had the mirrored problem: it reported true for the same document, where MongoDB does not match it. Both now go through computeHeuristicForMatchedValue, which is what $all, $in and $nin already use, so a single value is now interpreted the same way everywhere. This also removes a live inconsistency: $in with one value already looked at array elements while $eq with the same value did not, although MongoDB treats the two identically. Note the whole-array reading already worked for $eq, since two lists were compared element by element; only the reading that looks inside the array was missing. The second added test pins the part that already held, so it passes either way. --- .../mongo/MongoHeuristicsCalculator.java | 10 ++----- .../mongo/MongoHeuristicsCalculatorTest.java | 29 +++++++++++++++++++ 2 files changed, 31 insertions(+), 8 deletions(-) diff --git a/client-java/controller/src/main/java/org/evomaster/client/java/controller/mongo/MongoHeuristicsCalculator.java b/client-java/controller/src/main/java/org/evomaster/client/java/controller/mongo/MongoHeuristicsCalculator.java index 04fff7905a..a153cf4108 100644 --- a/client-java/controller/src/main/java/org/evomaster/client/java/controller/mongo/MongoHeuristicsCalculator.java +++ b/client-java/controller/src/main/java/org/evomaster/client/java/controller/mongo/MongoHeuristicsCalculator.java @@ -335,10 +335,7 @@ private Truthness computeHeuristic(EqualsOperation operation, Object document actualValue = null; } - return computeHeuristicComparisonNullableValues( - expectedValue, - actualValue, - SqlExpressionEvaluator.ComparisonOperatorType.EQUALS_TO); + return computeHeuristicForMatchedValue(expectedValue, actualValue); } private Truthness computeHeuristicComparisonNullableValues(Object expectedValue, Object actualValue, SqlExpressionEvaluator.ComparisonOperatorType comparisonOperatorType) { @@ -392,10 +389,7 @@ private Truthness computeHeuristic(NotEqualsOperation operation, Object docum } else { actualValue = null; } - return computeHeuristicComparisonNullableValues( - expectedValue, - actualValue, - SqlExpressionEvaluator.ComparisonOperatorType.NOT_EQUALS_TO); + return computeHeuristicForMatchedValue(expectedValue, actualValue).invert(); } diff --git a/client-java/controller/src/test/java/org/evomaster/client/java/controller/mongo/MongoHeuristicsCalculatorTest.java b/client-java/controller/src/test/java/org/evomaster/client/java/controller/mongo/MongoHeuristicsCalculatorTest.java index 914d757465..5a03b87828 100644 --- a/client-java/controller/src/test/java/org/evomaster/client/java/controller/mongo/MongoHeuristicsCalculatorTest.java +++ b/client-java/controller/src/test/java/org/evomaster/client/java/controller/mongo/MongoHeuristicsCalculatorTest.java @@ -1185,6 +1185,35 @@ public void testEqualsLists() { assertTrue(distanceNotMatch.isFalse()); } + @Test + public void testEqualsMatchingAnElementOfAnArrayField() { + // a field holding an array matches a value when any of its elements is that value + Document doc = new Document().append("tags", new ArrayList<>(Arrays.asList("a", "b"))); + MongoHeuristicsCalculator calculator = new MongoHeuristicsCalculator(); + + assertTrue(calculator.computeHeuristicDocument( + convertToDocument(Filters.eq("tags", "a")), doc).isTrue()); + assertTrue(calculator.computeHeuristicDocument( + convertToDocument(Filters.ne("tags", "a")), doc).isFalse()); + + assertTrue(calculator.computeHeuristicDocument( + convertToDocument(Filters.eq("tags", "z")), doc).isFalse()); + assertTrue(calculator.computeHeuristicDocument( + convertToDocument(Filters.ne("tags", "z")), doc).isTrue()); + } + + @Test + public void testEqualsMatchingAnArrayFieldAsAWhole() { + // the same field is also matched by the array itself, not only by its elements + Document doc = new Document().append("tags", new ArrayList<>(Arrays.asList("a", "b"))); + MongoHeuristicsCalculator calculator = new MongoHeuristicsCalculator(); + + assertTrue(calculator.computeHeuristicDocument( + convertToDocument(Filters.eq("tags", Arrays.asList("a", "b"))), doc).isTrue()); + assertTrue(calculator.computeHeuristicDocument( + convertToDocument(Filters.ne("tags", Arrays.asList("a", "b"))), doc).isFalse()); + } + @Test public void testEqualsBetweenEmptyLists() { // two empty arrays are equal; comparing them used to have no element to aggregate over