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..fa6d6db193 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 @@ -19,6 +19,7 @@ import static org.evomaster.client.java.sql.heuristic.ConversionHelper.convertToInstant; import java.util.*; +import java.util.function.Function; import java.util.regex.Matcher; import java.util.regex.Pattern; import java.util.stream.StreamSupport; @@ -228,11 +229,14 @@ private Truthness computeHeuristic(TrueOperation operation, Object document) { private Truthness computeHeuristicComparisonNonNullValues(Object actualValue, Object expectedValue, SqlExpressionEvaluator.ComparisonOperatorType comparisonOperatorType) { Objects.requireNonNull(actualValue); Objects.requireNonNull(expectedValue); - if (!isTypeSupportedForComparison(actualValue)) { - throw new IllegalArgumentException("Unsupported type: " + actualValue.getClass().getName()); - } - if (!isTypeSupportedForComparison(expectedValue)) { - throw new IllegalArgumentException("Unsupported type: " + expectedValue.getClass().getName()); + if (!isTypeSupportedForComparison(actualValue) || !isTypeSupportedForComparison(expectedValue)) { + /* + A value of a type this calculator cannot compare, eg a sub-document. MongoDB does + not fail on those, it simply does not match them, so neither should we: throwing + here would escape all the way out of the heuristics computation for the action. + They are handled like any other pair of incomparable values, see below. + */ + return incomparableValues(comparisonOperatorType); } final Truthness truthnessOfComparison; @@ -274,13 +278,25 @@ 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; + // no comparison logic is defined for this combination of types + truthnessOfComparison = incomparableValues(comparisonOperatorType); } return truthnessOfComparison; } + /** + * The score of a comparison between two values that cannot be compared with each other, + * either because they are of different BSON types or because this calculator has no + * comparison logic for them. 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. + */ + private static Truthness incomparableValues(SqlExpressionEvaluator.ComparisonOperatorType comparisonOperatorType) { + return comparisonOperatorType == SqlExpressionEvaluator.ComparisonOperatorType.NOT_EQUALS_TO + ? TRUE_C + : C_FALSE; + } + private static int toIntValue(Boolean actualValue) { return actualValue ? 1 : 0; } @@ -327,10 +343,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) { @@ -384,10 +397,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(); } @@ -401,11 +411,11 @@ private Truthness computeHeuristic(GreaterThanOperation operation, Object doc return C_FALSE; } else { Object actualValue = getValue(document, fieldName); - return computeHeuristicComparisonNullableValues( - expectedValue, - actualValue, - SqlExpressionEvaluator.ComparisonOperatorType.GREATER_THAN); - + return computeHeuristicOnFieldValue(actualValue, + value -> computeHeuristicComparisonNullableValues( + expectedValue, + value, + SqlExpressionEvaluator.ComparisonOperatorType.GREATER_THAN)); } } @@ -419,11 +429,11 @@ private Truthness computeHeuristic(GreaterThanEqualsOperation operation, Obje return C_FALSE; } else { Object actualValue = getValue(document, fieldName); - return computeHeuristicComparisonNullableValues( - expectedValue, - actualValue, - SqlExpressionEvaluator.ComparisonOperatorType.GREATER_THAN_EQUALS); - + return computeHeuristicOnFieldValue(actualValue, + value -> computeHeuristicComparisonNullableValues( + expectedValue, + value, + SqlExpressionEvaluator.ComparisonOperatorType.GREATER_THAN_EQUALS)); } } @@ -437,10 +447,11 @@ private Truthness computeHeuristic(LessThanOperation operation, Object docume return C_FALSE; } else { Object actualValue = getValue(document, fieldName); - return computeHeuristicComparisonNullableValues( - expectedValue, - actualValue, - SqlExpressionEvaluator.ComparisonOperatorType.MINOR_THAN); + return computeHeuristicOnFieldValue(actualValue, + value -> computeHeuristicComparisonNullableValues( + expectedValue, + value, + SqlExpressionEvaluator.ComparisonOperatorType.MINOR_THAN)); } } @@ -454,11 +465,11 @@ private Truthness computeHeuristic(LessThanEqualsOperation operation, Object return C_FALSE; } else { Object actualValue = getValue(document, fieldName); - return computeHeuristicComparisonNullableValues( - expectedValue, - actualValue, - SqlExpressionEvaluator.ComparisonOperatorType.MINOR_THAN_EQUALS); - + return computeHeuristicOnFieldValue(actualValue, + value -> computeHeuristicComparisonNullableValues( + expectedValue, + value, + SqlExpressionEvaluator.ComparisonOperatorType.MINOR_THAN_EQUALS)); } } @@ -484,8 +495,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": [...]}}. 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 + * @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)) { @@ -497,41 +527,39 @@ 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)) - .toArray(Truthness[]::new)); - } else { - res = computeHeuristic(actualValue, expectedValueList); - } - return res; - } - - 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) { 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(); } + /** + * Computes the heuristic score for a {"f",{"$all": [v1, ..., vn] }} query. + * 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 + * @return a Truthness object representing the distance of the document from meeting the condition + */ private Truthness computeHeuristic(AllOperation operation, Object document) { requireNonNullQueryAndDocument(operation, document); @@ -542,25 +570,61 @@ 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(actualValuesList - .stream() - .map(actualValuesListElement -> - computeHeuristic(actualValuesListElement, expectedValues)) - .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 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 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 computeHeuristicForMatchedValue(Object expectedValue, Object actualValue) { + return computeHeuristicOnFieldValue(actualValue, + value -> computeHeuristicComparisonNullableValues(expectedValue, value, + SqlExpressionEvaluator.ComparisonOperatorType.EQUALS_TO)); + } + + /** + * Scores a condition on a field, following MongoDB's rule that a field holding an array + * satisfies a condition when the array itself does, or when any one of its elements does. + * The rule applies to every condition expressed on a field, not only to equality: for + * example {"a": [1,2,3]} satisfies {"$gt": 2}, because one of its elements does. + * Keeping the array itself among the options is also what makes an empty array work: it + * holds no element to score, but it can still satisfy the condition on its own. + * + * @param actualValue the value held by the field in the document, possibly an array or null + * @param scoreOfValue the score of the condition on a single value + * @return a Truthness object representing how close the field is to satisfying the condition + */ + private Truthness computeHeuristicOnFieldValue(Object actualValue, Function scoreOfValue) { + + Truthness wholeValue = scoreOfValue.apply(actualValue); + + if (!(actualValue instanceof List)) { + return wholeValue; + } + + 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] = scoreOfValue.apply(actualValueList.get(i)); + } + return buildOrAggregationTruthness(options); + } + private static Truthness buildSafeScaledTruthness(Truthness truthness) { return buildSafeScaledTruthness(truthness.getOfTrue()); } @@ -677,13 +741,13 @@ private Truthness computeHeuristic(ModOperation operation, Object document) { actualValue = getValue(document, fieldName); } - if (actualValue == null || !(actualValue instanceof Number)) { - return C_FALSE; - } else { - long actualRemainder = ((Number) actualValue).longValue() % divisor; - Truthness res = getEqualityTruthness(actualRemainder, expectedRemainder); - return buildSafeScaledTruthness(res); - } + return computeHeuristicOnFieldValue(actualValue, value -> { + if (!(value instanceof Number)) { + return C_FALSE; + } + long actualRemainder = ((Number) value).longValue() % divisor; + return buildSafeScaledTruthness(getEqualityTruthness(actualRemainder, expectedRemainder)); + }); } private Truthness computeHeuristic(BitsOperation operation, Object document) { @@ -695,10 +759,23 @@ private Truthness computeHeuristic(BitsOperation operation, Object document) { } Object actualValue = getValue(document, fieldName); + return computeHeuristicOnFieldValue(actualValue, value -> scoreOfBits(operation, value)); + } + + private Truthness scoreOfBits(BitsOperation operation, Object actualValue) { if (!(actualValue instanceof Number)) { return C_FALSE; } + /* + A bitwise operator only applies to a number that is an integer: 3.0 is matched, + whereas 3.5 is not, rather than being truncated to 3. + */ + double asDouble = ((Number) actualValue).doubleValue(); + if (Double.isNaN(asDouble) || Double.isInfinite(asDouble) || asDouble != Math.floor(asDouble)) { + return C_FALSE; + } + final long bitmask = operation.getBitmask(); final long maskedValue = ((Number) actualValue).longValue() & bitmask; final int numberOfSetBitsInMaskedValue = Long.bitCount(maskedValue); @@ -724,13 +801,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) { @@ -909,6 +987,9 @@ private Truthness calculateTruthnessForListComparison(List actualList, List(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))); @@ -273,6 +314,189 @@ public void testAllBothActualAndExpectedListsAreEmpty() { assertTrue(calculator.computeHeuristicDocument(convertToDocument(allQuery), document).isFalse()); } + @Test + public void testValuesThatCannotBeComparedDoNotThrow() { + /* + A sub-document cannot be compared by this calculator. MongoDB does not fail on such a + query, it simply does not match, so the score must be false rather than an exception. + Arrays of sub-documents are the common case, since every element is scored. + */ + Document subDocument = new Document().append("a", new Document().append("x", 1)); + Document arrayOfSubDocuments = new Document().append("a", + new ArrayList<>(Arrays.asList(new Document().append("x", 1)))); + MongoHeuristicsCalculator calculator = new MongoHeuristicsCalculator(); + + assertTrue(calculator.computeHeuristicDocument(convertToDocument(Filters.eq("a", 5)), subDocument).isFalse()); + assertTrue(calculator.computeHeuristicDocument(convertToDocument(Filters.gt("a", 2)), subDocument).isFalse()); + assertTrue(calculator.computeHeuristicDocument(convertToDocument(Filters.gt("a", 2)), arrayOfSubDocuments).isFalse()); + assertTrue(calculator.computeHeuristicDocument(convertToDocument(Filters.in("a", Arrays.asList(1, 5))), arrayOfSubDocuments).isFalse()); + + // being different from a number is exactly what makes the inequality hold + assertTrue(calculator.computeHeuristicDocument(convertToDocument(Filters.ne("a", 5)), subDocument).isTrue()); + } + + @Test + public void testOrderingOperatorsMatchingAnElementOfAnArrayField() { + // a field holding an array satisfies a comparison when any of its elements does + Document doc = new Document().append("a", new ArrayList<>(Arrays.asList(1, 2, 3))); + MongoHeuristicsCalculator calculator = new MongoHeuristicsCalculator(); + + assertTrue(calculator.computeHeuristicDocument(convertToDocument(Filters.gt("a", 2)), doc).isTrue()); + assertTrue(calculator.computeHeuristicDocument(convertToDocument(Filters.gte("a", 3)), doc).isTrue()); + assertTrue(calculator.computeHeuristicDocument(convertToDocument(Filters.lt("a", 2)), doc).isTrue()); + assertTrue(calculator.computeHeuristicDocument(convertToDocument(Filters.lte("a", 1)), doc).isTrue()); + + // no element is greater than 3, so the condition does not hold + assertTrue(calculator.computeHeuristicDocument(convertToDocument(Filters.gt("a", 3)), doc).isFalse()); + } + + @Test + public void testOrderingOperatorsOnAnEmptyArrayField() { + Document doc = new Document().append("a", Collections.emptyList()); + assertTrue(new MongoHeuristicsCalculator() + .computeHeuristicDocument(convertToDocument(Filters.gt("a", 2)), doc).isFalse()); + } + + @Test + public void testModMatchingAnElementOfAnArrayField() { + Document doc = new Document().append("a", new ArrayList<>(Arrays.asList(1, 2, 3))); + MongoHeuristicsCalculator calculator = new MongoHeuristicsCalculator(); + + assertTrue(calculator.computeHeuristicDocument( + convertToDocument(Filters.mod("a", 2L, 1L)), doc).isTrue()); + assertTrue(calculator.computeHeuristicDocument( + convertToDocument(Filters.mod("a", 10L, 7L)), doc).isFalse()); + } + + @Test + public void testBitsMatchingAnElementOfAnArrayField() { + Document doc = new Document().append("a", new ArrayList<>(Arrays.asList(1, 2, 3))); + assertTrue(new MongoHeuristicsCalculator() + .computeHeuristicDocument(convertToDocument(Filters.bitsAllSet("a", 1L)), doc).isTrue()); + } + + @Test + public void testBitsDoesNotMatchANonIntegralNumber() { + // 3.0 is an integer and is matched, whereas 3.5 is not truncated to 3 + MongoHeuristicsCalculator calculator = new MongoHeuristicsCalculator(); + Bson query = Filters.bitsAllSet("a", 1L); + + assertTrue(calculator.computeHeuristicDocument(convertToDocument(query), + new Document().append("a", 3.0d)).isTrue()); + assertTrue(calculator.computeHeuristicDocument(convertToDocument(query), + new Document().append("a", 3.5d)).isFalse()); + } + + @Test + public void testBitsWithAnIntegerBitmask() { + // the same query, with the bitmask arriving as an Integer rather than a Long + Document doc = new Document().append("a", 5); + Document query = new Document().append("a", new Document().append("$bitsAllSet", 1)); + assertTrue(new MongoHeuristicsCalculator().computeHeuristicDocument(query, doc).isTrue()); + } + + @Test + public void testAllOnScalarField() { + // a scalar matches a $all that only lists values equal to it + Document doc = new Document().append("tag", "a"); + MongoHeuristicsCalculator calculator = new MongoHeuristicsCalculator(); + + assertTrue(calculator.computeHeuristicDocument( + convertToDocument(Filters.all("tag", Arrays.asList("a"))), doc).isTrue()); + assertTrue(calculator.computeHeuristicDocument( + convertToDocument(Filters.all("tag", Arrays.asList("a", "a"))), doc).isTrue()); + assertTrue(calculator.computeHeuristicDocument( + convertToDocument(Filters.all("tag", Arrays.asList("a", "b"))), doc).isFalse()); + assertTrue(calculator.computeHeuristicDocument( + convertToDocument(Filters.all("tag", Arrays.asList("b"))), doc).isFalse()); + } + + @Test + public void testAllMatchingTheArrayAsAWhole() { + // the expected value can be the array itself, not only one of its elements + Document doc = new Document().append("tags", new ArrayList<>(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 + 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() { @@ -527,6 +751,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); @@ -655,6 +913,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); @@ -975,6 +1266,48 @@ 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 + 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")); diff --git a/client-java/controller/src/test/java/org/evomaster/client/java/controller/mongo/QueryParserTest.java b/client-java/controller/src/test/java/org/evomaster/client/java/controller/mongo/QueryParserTest.java index 66623599f8..df814e1f26 100644 --- a/client-java/controller/src/test/java/org/evomaster/client/java/controller/mongo/QueryParserTest.java +++ b/client-java/controller/src/test/java/org/evomaster/client/java/controller/mongo/QueryParserTest.java @@ -1616,7 +1616,6 @@ void testParseInvalidBitwiseValues() { assertAll( () -> assertInvalidQuery( new Document("flags", new Document("$bitsAllClear", "5"))), - () -> assertInvalidQuery(new Document("flags", new Document("$bitsAllSet", 5))), () -> assertInvalidQuery( new Document("flags", new Document("$bitsAnyClear", true))), () -> assertInvalidQuery( @@ -1624,6 +1623,17 @@ void testParseInvalidBitwiseValues() { ); } + @Test + void testParseBitwiseValuesAcceptsIntegerAndLongBitmasks() { + // MongoDB accepts any integer bitmask, so it can arrive as an Integer as well as a Long + assertTrue(parser.parse( + new Document("flags", new Document("$bitsAllSet", 5))) instanceof BitsAllSetOperation); + assertTrue(parser.parse( + new Document("flags", new Document("$bitsAllSet", 5L))) instanceof BitsAllSetOperation); + assertTrue(parser.parse( + new Document("flags", new Document("$bitsAnyClear", 5))) instanceof BitsAnyClearOperation); + } + @Test void testParseNearSphereWithoutOptionalDistances() { Document geometry = new Document("type", "Point") 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);