diff --git a/driver-core/src/main/com/mongodb/client/model/Accumulators.java b/driver-core/src/main/com/mongodb/client/model/Accumulators.java index e5634ef5403..e6752976613 100644 --- a/driver-core/src/main/com/mongodb/client/model/Accumulators.java +++ b/driver-core/src/main/com/mongodb/client/model/Accumulators.java @@ -67,6 +67,51 @@ public static BsonField avg(final String fieldName, final TExpress return accumulatorOperator("$avg", fieldName, expression); } + /** + * Returns a combination of a computed field and an accumulator that generates a BSON {@link org.bson.BsonType#ARRAY Array} + * containing computed values from the given {@code inExpression} based on the provided {@code pExpression}, which represents an array + * of percentiles of interest within a group, where each element is a numeric value between 0.0 and 1.0 (inclusive). + * + * @param fieldName The field computed by the accumulator. + * @param inExpression The input expression. + * @param pExpression The expression representing a percentiles of interest. + * @param method The method to be used for computing the percentiles. + * @param The type of the input expression. + * @param The type of the percentile expression. + * @return The requested {@link BsonField}. + * @mongodb.driver.manual reference/operator/aggregation/percentile/ $percentile + * @since 4.10 + * @mongodb.server.release 7.0 + */ + public static BsonField percentile(final String fieldName, final InExpression inExpression, + final PExpression pExpression, final QuantileMethod method) { + notNull("fieldName", fieldName); + notNull("inExpression", inExpression); + notNull("pExpression", inExpression); + notNull("method", method); + return quantileAccumulator("$percentile", fieldName, inExpression, pExpression, method); + } + + /** + * Returns a combination of a computed field and an accumulator that generates a BSON {@link org.bson.BsonType#DOUBLE Double } + * representing the median value computed from the given {@code inExpression} within a group. + * + * @param fieldName The field computed by the accumulator. + * @param inExpression The input expression. + * @param method The method to be used for computing the median. + * @param The type of the input expression. + * @return The requested {@link BsonField}. + * @mongodb.driver.manual reference/operator/aggregation/median/ $median + * @since 4.10 + * @mongodb.server.release 7.0 + */ + public static BsonField median(final String fieldName, final InExpression inExpression, final QuantileMethod method) { + notNull("fieldName", fieldName); + notNull("inExpression", inExpression); + notNull("method", method); + return quantileAccumulator("$median", fieldName, inExpression, null, method); + } + /** * Gets a field name for a $group operation representing the value of the given expression when applied to the first member of * the group. @@ -510,6 +555,17 @@ private static BsonField sortingPickNAccumulator( .append("n", nExpression))); } + private static BsonField quantileAccumulator(final String quantileAccumulatorName, + final String fieldName, final InExpression inExpression, + @Nullable final PExpression pExpression, final QuantileMethod method) { + Document document = new Document("input", inExpression) + .append("method", method.toBsonValue()); + if (pExpression != null) { + document.append("p", pExpression); + } + return accumulatorOperator(quantileAccumulatorName, fieldName, document); + } + private Accumulators() { } } diff --git a/driver-core/src/main/com/mongodb/client/model/ApproximateQuantileMethod.java b/driver-core/src/main/com/mongodb/client/model/ApproximateQuantileMethod.java new file mode 100644 index 00000000000..9d525b151b0 --- /dev/null +++ b/driver-core/src/main/com/mongodb/client/model/ApproximateQuantileMethod.java @@ -0,0 +1,29 @@ +/* + * Copyright 2008-present MongoDB, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.mongodb.client.model; + +import com.mongodb.annotations.Sealed; + + +/** + * @see QuantileMethod#approximate() + * @since 4.10 + * @mongodb.server.release 7.0 + */ +@Sealed +public interface ApproximateQuantileMethod extends QuantileMethod { +} + diff --git a/driver-core/src/main/com/mongodb/client/model/QuantileMethod.java b/driver-core/src/main/com/mongodb/client/model/QuantileMethod.java new file mode 100644 index 00000000000..190b6b0ba0c --- /dev/null +++ b/driver-core/src/main/com/mongodb/client/model/QuantileMethod.java @@ -0,0 +1,76 @@ +/* + * Copyright 2008-present MongoDB, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.mongodb.client.model; + +import com.mongodb.annotations.Sealed; +import org.bson.BsonString; +import org.bson.BsonValue; + +import static com.mongodb.assertions.Assertions.notNull; + +/** + * This interface represents a quantile method used in quantile accumulators of the {@code $group} and + * {@code $setWindowFields} stages. + *

+ * It provides methods for creating and converting quantile methods to {@link BsonValue}. + *

+ * + * @see Accumulators#percentile(String, Object, Object, QuantileMethod) + * @see Accumulators#median(String, Object, QuantileMethod) + * @see WindowOutputFields#percentile(String, Object, Object, QuantileMethod, Window) + * @see WindowOutputFields#median(String, Object, QuantileMethod, Window) + * @since 4.10 + * @mongodb.server.release 7.0 + */ +@Sealed +public interface QuantileMethod { + /** + * Returns a {@link QuantileMethod} instance representing the "approximate" quantile method. + * + * @return The requested {@link QuantileMethod}. + */ + static ApproximateQuantileMethod approximate() { + return new QuantileMethodBson(new BsonString("approximate")); + } + + /** + * Creates a {@link QuantileMethod} from a {@link BsonValue} in situations when there is no builder method + * that better satisfies your needs. + * This method cannot be used to validate the syntax. + *

+ * Example
+ * The following code creates two functionally equivalent {@link QuantileMethod}s, + * though they may not be {@linkplain Object#equals(Object) equal}. + *

{@code
+     *  QuantileMethod method1 = QuantileMethod.approximate();
+     *  QuantileMethod method2 = QuantileMethod.of(new BsonString("approximate"));
+     * }
+ * + * @param method A {@link BsonValue} representing the required {@link QuantileMethod}. + * @return The requested {@link QuantileMethod}. + */ + static QuantileMethod of(final BsonValue method) { + notNull("method", method); + return new QuantileMethodBson(method); + } + + /** + * Converts this object to {@link BsonValue}. + * + * @return A {@link BsonValue} representing this {@link QuantileMethod}. + */ + BsonValue toBsonValue(); +} diff --git a/driver-core/src/main/com/mongodb/client/model/QuantileMethodBson.java b/driver-core/src/main/com/mongodb/client/model/QuantileMethodBson.java new file mode 100644 index 00000000000..2aef1b4a930 --- /dev/null +++ b/driver-core/src/main/com/mongodb/client/model/QuantileMethodBson.java @@ -0,0 +1,50 @@ +/* + * Copyright 2008-present MongoDB, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.mongodb.client.model; + +import org.bson.BsonValue; + +import java.util.Objects; + +final class QuantileMethodBson implements ApproximateQuantileMethod { + private final BsonValue bsonValue; + + QuantileMethodBson(final BsonValue bsonValue) { + this.bsonValue = bsonValue; + } + + @Override + public BsonValue toBsonValue() { + return bsonValue; + } + + @Override + public boolean equals(final Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + QuantileMethodBson that = (QuantileMethodBson) o; + return Objects.equals(bsonValue, that.bsonValue); + } + + @Override + public int hashCode() { + return Objects.hash(bsonValue); + } +} diff --git a/driver-core/src/main/com/mongodb/client/model/WindowOutputFields.java b/driver-core/src/main/com/mongodb/client/model/WindowOutputFields.java index 5edaf4a127a..a2764cb6b63 100644 --- a/driver-core/src/main/com/mongodb/client/model/WindowOutputFields.java +++ b/driver-core/src/main/com/mongodb/client/model/WindowOutputFields.java @@ -111,6 +111,62 @@ public static WindowOutputField avg(final String path, final TExpr return simpleParameterWindowFunction(path, "$avg", expression, window); } + /** + * Builds a window output field of percentiles of the evaluation results of the {@code inExpression} + * over documents in the specified {@code window}. The {@code pExpression} parameter represents an array of + * percentiles of interest, with each element being a numeric value between 0.0 and 1.0 (inclusive). + * + * @param path The output field path. + * @param inExpression The input expression. + * @param pExpression The expression representing a percentiles of interest. + * @param method The method to be used for computing the percentiles. + * @param window The window. + * @param The type of the input expression. + * @param The type of the percentile expression. + * @return The constructed windowed output field. + * @mongodb.driver.manual reference/operator/aggregation/percentile/ $percentile + * @since 4.10 + * @mongodb.server.release 7.0 + */ + public static WindowOutputField percentile(final String path, final InExpression inExpression, + final PExpression pExpression, final QuantileMethod method, + @Nullable final Window window) { + notNull("path", path); + notNull("inExpression", inExpression); + notNull("pExpression", pExpression); + notNull("method", method); + Map args = new LinkedHashMap<>(3); + args.put(ParamName.INPUT, inExpression); + args.put(ParamName.P_LOWERCASE, pExpression); + args.put(ParamName.METHOD, method.toBsonValue()); + return compoundParameterWindowFunction(path, "$percentile", args, window); + } + + /** + * Builds a window output field representing the median value of the evaluation results of the {@code inExpression} + * over documents in the specified {@code window}. + * + * @param inExpression The input expression. + * @param method The method to be used for computing the median. + * @param window The window. + * @param The type of the input expression. + * @return The constructed windowed output field. + * @mongodb.driver.manual reference/operator/aggregation/median/ $median + * @since 4.10 + * @mongodb.server.release 7.0 + */ + public static WindowOutputField median(final String path, final InExpression inExpression, + final QuantileMethod method, + @Nullable final Window window) { + notNull("path", path); + notNull("inExpression", inExpression); + notNull("method", method); + Map args = new LinkedHashMap<>(2); + args.put(ParamName.INPUT, inExpression); + args.put(ParamName.METHOD, method.toBsonValue()); + return compoundParameterWindowFunction(path, "$median", args, window); + } + /** * Builds a window output field of the sample standard deviation of the evaluation results of the {@code expression} over the * {@code window}. @@ -1013,11 +1069,13 @@ private enum ParamName { UNIT("unit"), N_UPPERCASE("N"), N_LOWERCASE("n"), + P_LOWERCASE("p"), ALPHA("alpha"), OUTPUT("output"), BY("by"), DEFAULT("default"), - SORT_BY("sortBy"); + SORT_BY("sortBy"), + METHOD("method"); private final String value; diff --git a/driver-core/src/test/functional/com/mongodb/client/model/AggregatesTest.java b/driver-core/src/test/functional/com/mongodb/client/model/AggregatesTest.java index 02ea44a25a5..0589e319070 100644 --- a/driver-core/src/test/functional/com/mongodb/client/model/AggregatesTest.java +++ b/driver-core/src/test/functional/com/mongodb/client/model/AggregatesTest.java @@ -18,26 +18,120 @@ import com.mongodb.client.model.geojson.Point; import com.mongodb.client.model.geojson.Position; +import com.mongodb.client.model.mql.MqlValues; +import com.mongodb.lang.Nullable; import org.bson.BsonDocument; import org.bson.Document; +import org.bson.conversions.Bson; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; import java.util.Arrays; +import java.util.Collections; import java.util.List; - -import org.bson.conversions.Bson; -import org.junit.jupiter.api.Test; +import java.util.stream.Stream; import static com.mongodb.ClusterFixture.serverVersionAtLeast; +import static com.mongodb.client.model.Accumulators.median; +import static com.mongodb.client.model.Accumulators.percentile; import static com.mongodb.client.model.Aggregates.geoNear; +import static com.mongodb.client.model.Aggregates.group; import static com.mongodb.client.model.Aggregates.unset; import static com.mongodb.client.model.GeoNearOptions.geoNearOptions; +import static com.mongodb.client.model.Sorts.ascending; +import static com.mongodb.client.model.Windows.Bound.UNBOUNDED; +import static com.mongodb.client.model.Windows.documents; import static java.util.Arrays.asList; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.hasSize; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assumptions.assumeTrue; public class AggregatesTest extends OperationTest { + private static Stream groupWithQuantileSource() { + return Stream.of( + Arguments.of(percentile("result", "$x", MqlValues.ofNumberArray(0.95), QuantileMethod.approximate()), asList(3.0), asList(1.0)), + Arguments.of(percentile("result", "$x", MqlValues.ofNumberArray(0.95, 0.3), QuantileMethod.approximate()), asList(3.0, 2.0), asList(1.0, 1.0)), + Arguments.of(median("result", "$x", QuantileMethod.approximate()), 2.0d, 1.0d) + ); + } + + @ParameterizedTest + @MethodSource("groupWithQuantileSource") + public void shouldGroupWithQuantile(final BsonField quantileAccumulator, + final Object expectedGroup1, + final Object expectedGroup2) { + //given + assumeTrue(serverVersionAtLeast(7, 0)); + getCollectionHelper().insertDocuments("[\n" + + " { _id: 1, x: 1, z: false},\n" + + " { _id: 2, x: 2, z: true },\n" + + " { _id: 3, x: 3, z: true }\n" + + "]"); + + //when + List results = getCollectionHelper().aggregate(Collections.singletonList( + group("$z", quantileAccumulator)), DOCUMENT_DECODER); + + //then + assertThat(results, hasSize(2)); + Object result = results.stream() + .filter(document -> document.get("_id").equals(true)) + .findFirst().map(document -> document.get("result")).get(); + + + assertEquals(expectedGroup1, result); + + result = results.stream() + .filter(document -> document.get("_id").equals(false)) + .findFirst().map(document -> document.get("result")).get(); + + assertEquals(expectedGroup2, result); + } + + private static Stream setWindowFieldWithQuantileSource() { + return Stream.of( + Arguments.of(null, + WindowOutputFields.percentile("result", "$num1", new double[]{0.1, 0.9}, QuantileMethod.approximate(), + documents(UNBOUNDED, UNBOUNDED)), + asList(asList(1.0, 3.0), asList(1.0, 3.0), asList(1.0, 3.0))), + Arguments.of("$partitionId", + WindowOutputFields.percentile("result", "$num1", new double[]{0.1, 0.9}, QuantileMethod.approximate(), null), + asList(asList(1.0, 2.0), asList(1.0, 2.0), asList(3.0, 3.0))), + Arguments.of(null, + WindowOutputFields.median("result", "$num1", QuantileMethod.approximate(), documents(UNBOUNDED, UNBOUNDED)), + asList(2.0, 2.0, 2.0)), + Arguments.of("$partitionId", + WindowOutputFields.median("result", "$num1", QuantileMethod.approximate(), null), + asList(1.0, 1.0, 3.0)) + ); + } + + @ParameterizedTest + @MethodSource("setWindowFieldWithQuantileSource") + public void shouldSetWindowFieldWithQuantile(@Nullable final Object partitionBy, + final WindowOutputField output, + final List expectedFieldValues) { + //given + assumeTrue(serverVersionAtLeast(7, 0)); + Document[] original = new Document[]{ + new Document("partitionId", 1).append("num1", 1), + new Document("partitionId", 1).append("num1", 2), + new Document("partitionId", 2).append("num1", 3) + }; + getCollectionHelper().insertDocuments(original); + + //when + List actualFieldValues = aggregateWithWindowFields(partitionBy, output, ascending("num1")); + + //then + Assertions.assertEquals(actualFieldValues, expectedFieldValues); + } @Test public void testUnset() { diff --git a/driver-core/src/test/functional/com/mongodb/client/model/OperationTest.java b/driver-core/src/test/functional/com/mongodb/client/model/OperationTest.java index 8fb89a90ece..80360adb1bb 100644 --- a/driver-core/src/test/functional/com/mongodb/client/model/OperationTest.java +++ b/driver-core/src/test/functional/com/mongodb/client/model/OperationTest.java @@ -20,14 +20,18 @@ import com.mongodb.MongoNamespace; import com.mongodb.client.test.CollectionHelper; import com.mongodb.internal.connection.ServerHelper; +import com.mongodb.lang.Nullable; import org.bson.BsonArray; import org.bson.BsonDocument; +import org.bson.Document; import org.bson.codecs.BsonDocumentCodec; import org.bson.codecs.DecoderContext; +import org.bson.codecs.DocumentCodec; import org.bson.conversions.Bson; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; +import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.stream.Collectors; @@ -37,10 +41,15 @@ import static com.mongodb.ClusterFixture.getBinding; import static com.mongodb.ClusterFixture.getPrimary; import static com.mongodb.MongoClientSettings.getDefaultCodecRegistry; +import static com.mongodb.client.model.Aggregates.setWindowFields; +import static com.mongodb.client.model.Aggregates.sort; +import static java.util.stream.Collectors.toList; import static org.junit.jupiter.api.Assertions.assertEquals; public abstract class OperationTest { + protected static final DocumentCodec DOCUMENT_DECODER = new DocumentCodec(); + @BeforeEach public void beforeEach() { ServerHelper.checkPool(getPrimary()); @@ -100,4 +109,18 @@ protected void assertResults(final List pipeline, final String expectedRes List results = getCollectionHelper().aggregate(pipeline); assertEquals(expectedResults, results); } + + protected List aggregateWithWindowFields(@Nullable final Object partitionBy, + final WindowOutputField output, + final Bson sortSpecification) { + List stages = new ArrayList<>(); + stages.add(setWindowFields(partitionBy, null, output)); + stages.add(sort(sortSpecification)); + + List actual = getCollectionHelper().aggregate(stages, DOCUMENT_DECODER); + + return actual.stream() + .map(doc -> doc.get("result")) + .collect(toList()); + } } diff --git a/driver-core/src/test/unit/com/mongodb/client/model/TestWindowOutputFields.java b/driver-core/src/test/unit/com/mongodb/client/model/TestWindowOutputFields.java index dc101f2eb32..13f05d6acf0 100644 --- a/driver-core/src/test/unit/com/mongodb/client/model/TestWindowOutputFields.java +++ b/driver-core/src/test/unit/com/mongodb/client/model/TestWindowOutputFields.java @@ -26,15 +26,22 @@ import org.bson.Document; import org.bson.conversions.Bson; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; import java.util.AbstractMap; +import java.util.ArrayList; +import java.util.Arrays; import java.util.Collection; import java.util.Collections; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.function.BiFunction; import java.util.function.Function; import java.util.function.Supplier; +import java.util.stream.Stream; import static com.mongodb.assertions.Assertions.assertNotNull; import static com.mongodb.client.model.Sorts.ascending; @@ -51,6 +58,9 @@ final class TestWindowOutputFields { private static final String PATH = "newField"; private static final Bson SORT_BY = ascending("sortByField"); private static final Map.Entry INT_EXPR = new AbstractMap.SimpleImmutableEntry<>(1, new BsonInt32(1)); + private static final Map.Entry ARRAY_EXPR = + new AbstractMap.SimpleImmutableEntry<>(Arrays.asList(0.5, 0.9, "$$letValueX"), + new BsonArray(Arrays.asList(new BsonDouble(0.5), new BsonDouble(0.9), new BsonString("$$letValueX")))); private static final Map.Entry STR_EXPR = new AbstractMap.SimpleImmutableEntry<>("$fieldToRead", new BsonString("$fieldToRead")); private static final Map.Entry DOC_EXPR = new AbstractMap.SimpleImmutableEntry<>( @@ -212,6 +222,111 @@ void pick() { ); } + @ParameterizedTest + @MethodSource("percentileWindowFunctionsSource") + void percentile(final Object inExpressionParameter, + final BsonValue expectedInExpression, + final Object pExpressionParameter, + final BsonValue expectedPExpression, + @Nullable final Window window) { + String expectedFunctionName = "$percentile"; + QuantileMethod method = QuantileMethod.approximate(); + BsonField expectedWindowOutputField = getExpectedBsonField(expectedFunctionName, expectedInExpression, expectedPExpression, + method, window); + + Supplier msg = () -> "expectedFunctionName=" + expectedFunctionName + + ", path=" + PATH + + ", InExpression=" + inExpressionParameter + + ", pExpression=" + pExpressionParameter + + ", method=" + method + + ", window=" + window; + + assertWindowOutputField(expectedWindowOutputField, WindowOutputFields.percentile(PATH, inExpressionParameter, pExpressionParameter, method, window), + msg); + assertThrows(IllegalArgumentException.class, () -> WindowOutputFields.percentile(null, inExpressionParameter, pExpressionParameter, method, window), msg); + assertThrows(IllegalArgumentException.class, () -> WindowOutputFields.percentile(PATH, null, pExpressionParameter, method, window), msg); + assertThrows(IllegalArgumentException.class, () -> WindowOutputFields.percentile(PATH, inExpressionParameter, null, method, window), msg); + assertThrows(IllegalArgumentException.class, () -> WindowOutputFields.percentile(PATH, inExpressionParameter, pExpressionParameter, null, window), msg); + } + + @ParameterizedTest + @MethodSource("medianWindowFunctionsSource") + void median(final Object inExpressionParameter, + final BsonValue expectedInExpression, + @Nullable final Window window) { + String expectedFunctionName = "$median"; + QuantileMethod method = QuantileMethod.approximate(); + BsonField expectedWindowOutputField = getExpectedBsonField(expectedFunctionName, expectedInExpression, + null, method, window); + + Supplier msg = () -> "expectedFunctionName=" + expectedFunctionName + + ", path=" + PATH + + ", InExpression=" + inExpressionParameter + + ", method=" + method + + ", window=" + window; + + assertWindowOutputField(expectedWindowOutputField, WindowOutputFields.median(PATH, inExpressionParameter, method, window), + msg); + assertThrows(IllegalArgumentException.class, () -> WindowOutputFields.median(null, inExpressionParameter, method, window), + msg); + assertThrows(IllegalArgumentException.class, () -> WindowOutputFields.median(PATH, null, method, window), msg); + assertThrows(IllegalArgumentException.class, () -> WindowOutputFields.median(PATH, inExpressionParameter, null, window), msg); + } + + private static Stream percentileWindowFunctionsSource() { + Map inExpressions = new HashMap<>(); + inExpressions.put(INT_EXPR.getKey(), INT_EXPR.getValue()); + inExpressions.put(STR_EXPR.getKey(), STR_EXPR.getValue()); + inExpressions.put(DOC_EXPR.getKey(), DOC_EXPR.getValue()); + + Map pExpressions = new HashMap<>(); + pExpressions.put(ARRAY_EXPR.getKey(), ARRAY_EXPR.getValue()); + pExpressions.put(STR_EXPR.getKey(), STR_EXPR.getValue()); + + Collection windows = asList(null, POSITION_BASED_WINDOW, RANGE_BASED_WINDOW); + + // Generate different combinations of test arguments using Cartesian product of inExpressions, pExpressions, and windows. + List argumentsList = new ArrayList<>(); + inExpressions.forEach((incomingInParameter, inBsonValue) -> + pExpressions.forEach((incomingPParameter, pBsonValue) -> + windows.forEach(window -> + argumentsList.add( + Arguments.of(incomingInParameter, inBsonValue, incomingPParameter, pBsonValue, window))))); + return Stream.of(argumentsList.toArray(new Arguments[]{})); + } + + private static Stream medianWindowFunctionsSource() { + Map inExpressions = new HashMap<>(); + inExpressions.put(INT_EXPR.getKey(), INT_EXPR.getValue()); + inExpressions.put(STR_EXPR.getKey(), STR_EXPR.getValue()); + inExpressions.put(DOC_EXPR.getKey(), DOC_EXPR.getValue()); + + Collection windows = asList(null, POSITION_BASED_WINDOW, RANGE_BASED_WINDOW); + + // Generate different combinations of test arguments using Cartesian product of inExpressions and windows. + List argumentsList = new ArrayList<>(); + inExpressions.forEach((incomingInParameter, inBsonValue) -> + windows.forEach(window -> + argumentsList.add( + Arguments.of(incomingInParameter, inBsonValue, window)))); + return Stream.of(argumentsList.toArray(new Arguments[]{})); + } + + private static BsonField getExpectedBsonField(final String expectedFunctionName, final BsonValue expectedInExpression, + final @Nullable BsonValue expectedPExpression, + final QuantileMethod method, final @Nullable Window window) { + BsonDocument expectedFunctionDoc = new BsonDocument("input", expectedInExpression); + if (expectedPExpression != null) { + expectedFunctionDoc.append("p", expectedPExpression); + } + expectedFunctionDoc.append("method", method.toBsonValue()); + BsonDocument expectedFunctionAndWindow = new BsonDocument(expectedFunctionName, expectedFunctionDoc); + if (window != null) { + expectedFunctionAndWindow.append("window", window.toBsonDocument()); + } + return new BsonField(PATH, expectedFunctionAndWindow); + } + private static void assertPickNoSortWindowFunction( final String expectedFunctionName, final QuadriFunction windowOutputFieldBuilder, diff --git a/driver-scala/src/main/scala/org/mongodb/scala/model/Accumulators.scala b/driver-scala/src/main/scala/org/mongodb/scala/model/Accumulators.scala index b574d698c98..f8ffc712360 100644 --- a/driver-scala/src/main/scala/org/mongodb/scala/model/Accumulators.scala +++ b/driver-scala/src/main/scala/org/mongodb/scala/model/Accumulators.scala @@ -17,7 +17,7 @@ package org.mongodb.scala.model import scala.collection.JavaConverters._ -import com.mongodb.client.model.{ Accumulators => JAccumulators } +import com.mongodb.client.model.{ Accumulators => JAccumulators, QuantileMethod } import org.mongodb.scala.bson.conversions.Bson /** @@ -55,6 +55,50 @@ object Accumulators { */ def avg[TExpression](fieldName: String, expression: TExpression): BsonField = JAccumulators.avg(fieldName, expression) + /** + * Returns a combination of a computed field and an accumulator that generates a BSON `ARRAY` + * containing computed values from the given `inExpression` based on the provided `pExpression`, which represents an array + * of percentiles of interest within a group, where each element is a numeric value between 0.0 and 1.0 (inclusive). + * + * @param fieldName The field computed by the accumulator. + * @param inExpression The input expression. + * @param pExpression The expression representing a percentiles of interest. + * @param method The method to be used for computing the percentiles. + * @tparam InExpression The type of the input expression. + * @tparam PExpression The type of the percentile expression. + * @return The requested [[BsonField]]. + * @see [[https://www.mongodb.com/docs/manual/reference/operator/aggregation/percentile/ \$percentile]] + * @since 4.10 + * @note Requires MongoDB 7.0 or greater + */ + def percentile[InExpression, PExpression]( + fieldName: String, + inExpression: InExpression, + pExpression: PExpression, + method: QuantileMethod + ): BsonField = + JAccumulators.percentile(fieldName, inExpression, pExpression, method) + + /** + * Returns a combination of a computed field and an accumulator that generates a BSON `Double` + * representing the median value computed from the given `nExpression` within a group. + * + * @param fieldName The field computed by the accumulator. + * @param inExpression The input expression. + * @param method The method to be used for computing the median. + * @tparam InExpression The type of the input expression. + * @return The requested [[BsonField]]. + * @see [[https://www.mongodb.com/docs/manual/reference/operator/aggregation/median/ \$median]] + * @since 4.10 + * @note Requires MongoDB 7.0 or greater + */ + def median[InExpression]( + fieldName: String, + inExpression: InExpression, + method: QuantileMethod + ): BsonField = + JAccumulators.median(fieldName, inExpression, method) + /** * Gets a field name for a `\$group` operation representing the value of the given expression when applied to the first member of * the group. diff --git a/driver-scala/src/main/scala/org/mongodb/scala/model/QuantileMethod.scala b/driver-scala/src/main/scala/org/mongodb/scala/model/QuantileMethod.scala new file mode 100644 index 00000000000..99df9da410b --- /dev/null +++ b/driver-scala/src/main/scala/org/mongodb/scala/model/QuantileMethod.scala @@ -0,0 +1,43 @@ +/* + * Copyright 2008-present MongoDB, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mongodb.scala.model + +import com.mongodb.annotations.Sealed +import com.mongodb.client.model.{ QuantileMethod => JQuantileMethod } + +/** + * This interface represents a quantile method used in quantile accumulators of the `\$group` and + * `\$setWindowFields` stages. + *

+ * It provides methods for creating and converting quantile methods to `BsonValue`. + *

+ * + * @see [[org.mongodb.scala.model.Accumulators.percentile]] + * @see [[org.mongodb.scala.model.Accumulators.median]] + * @see [[org.mongodb.scala.model.WindowOutputFields.percentile]] + * @see [[org.mongodb.scala.model.WindowOutputFields.median]] + * @since 4.10 + * @note Requires MongoDB 7.0 or greater + */ +@Sealed object QuantileMethod { + + /** + * Returns a `QuantileMethod` instance representing the "approximate" quantile method. + * + * @return The requested `QuantileMethod`. + */ + def approximate: ApproximateQuantileMethod = JQuantileMethod.approximate() +} diff --git a/driver-scala/src/main/scala/org/mongodb/scala/model/WindowOutputFields.scala b/driver-scala/src/main/scala/org/mongodb/scala/model/WindowOutputFields.scala index ee3088ee35b..29209bff280 100644 --- a/driver-scala/src/main/scala/org/mongodb/scala/model/WindowOutputFields.scala +++ b/driver-scala/src/main/scala/org/mongodb/scala/model/WindowOutputFields.scala @@ -15,7 +15,11 @@ */ package org.mongodb.scala.model -import com.mongodb.client.model.{ MongoTimeUnit => JMongoTimeUnit, WindowOutputFields => JWindowOutputFields } +import com.mongodb.client.model.{ + MongoTimeUnit => JMongoTimeUnit, + QuantileMethod, + WindowOutputFields => JWindowOutputFields +} import org.mongodb.scala.bson.conversions.Bson /** @@ -84,6 +88,53 @@ object WindowOutputFields { def avg[TExpression](path: String, expression: TExpression, window: Option[_ <: Window]): WindowOutputField = JWindowOutputFields.avg(path, expression, window.orNull) + /** + * Builds a window output field of percentiles of the evaluation results of the `inExpression` + * over documents in the specified `window`. The `pExpression` parameter represents an array of + * percentiles of interest, with each element being a numeric value between 0.0 and 1.0 (inclusive). + * + * @param path The output field path. + * @param inExpression The input expression. + * @param pExpression The expression representing the percentiles of interest. + * @param method The method to be used for computing the percentiles. + * @param window The window. + * @tparam InExpression The input expression type. + * @tparam PExpression The percentile expression type. + * @return The constructed windowed computation. + * @see [[https://www.mongodb.com/docs/manual/reference/operator/aggregation/percentile/ \$percentile]] + * @since 4.10 + * @note Requires MongoDB 7.0 or greater + */ + def percentile[InExpression, PExpression]( + path: String, + inExpression: InExpression, + pExpression: PExpression, + method: QuantileMethod, + window: Option[_ <: Window] + ): WindowOutputField = + JWindowOutputFields.percentile(path, inExpression, pExpression, method, window.orNull) + + /** + * Builds a window output field representing the median value of the evaluation results of the `inExpression` + * over documents in the specified `window`. + * + * @param inExpression The input expression. + * @param method The method to be used for computing the median. + * @param window The window. + * @tparam InExpression The input expression type. + * @return The constructed windowed computation. + * @see [[https://www.mongodb.com/docs/manual/reference/operator/aggregation/medoan/ \$median]] + * @since 4.10 + * @note Requires MongoDB 7.0 or greater + */ + def median[InExpression]( + path: String, + inExpression: InExpression, + method: QuantileMethod, + window: Option[_ <: Window] + ): WindowOutputField = + JWindowOutputFields.median(path, inExpression, method, window.orNull) + /** * Builds a computation of the sample standard deviation of the evaluation results of the `expression` over the `window`. * diff --git a/driver-scala/src/main/scala/org/mongodb/scala/model/package.scala b/driver-scala/src/main/scala/org/mongodb/scala/model/package.scala index 4e28793a4a7..a1e8486d9d4 100644 --- a/driver-scala/src/main/scala/org/mongodb/scala/model/package.scala +++ b/driver-scala/src/main/scala/org/mongodb/scala/model/package.scala @@ -16,7 +16,7 @@ package org.mongodb.scala -import com.mongodb.annotations.Beta +import com.mongodb.annotations.{ Beta, Sealed } import scala.collection.JavaConverters._ import com.mongodb.client.model.{ GeoNearOptions, MongoTimeUnit => JMongoTimeUnit, WindowOutputField } @@ -937,6 +937,12 @@ package object model { type WindowOutputField = com.mongodb.client.model.WindowOutputField type GeoNearOptions = com.mongodb.client.model.GeoNearOptions + + /** + * @see `QuantileMethod.approximate()` + */ + @Sealed + type ApproximateQuantileMethod = com.mongodb.client.model.ApproximateQuantileMethod } // scalastyle:on number.of.methods number.of.types diff --git a/driver-scala/src/test/scala/org/mongodb/scala/model/AggregatesSpec.scala b/driver-scala/src/test/scala/org/mongodb/scala/model/AggregatesSpec.scala index 9e4217bbc86..2d6424c73c8 100644 --- a/driver-scala/src/test/scala/org/mongodb/scala/model/AggregatesSpec.scala +++ b/driver-scala/src/test/scala/org/mongodb/scala/model/AggregatesSpec.scala @@ -17,6 +17,7 @@ package org.mongodb.scala.model import com.mongodb.client.model.GeoNearOptions.geoNearOptions +import com.mongodb.client.model.QuantileMethod import com.mongodb.client.model.fill.FillOutputField import java.lang.reflect.Modifier._ @@ -382,6 +383,8 @@ class AggregatesSpec extends BaseSpec { _id : null, sum: { $sum: { $multiply: [ "$price", "$quantity" ] } }, avg: { $avg: "$quantity" }, + percentile: { $percentile: { input: "$quantity", method: "approximate", p: [0.95, 0.3] } }, + median: { $median: { input: "$quantity", method: "approximate" } }, min: { $min: "$quantity" }, minN: { $minN: { input: "$quantity", n: 2 } }, max: { $max: "$quantity" }, @@ -406,6 +409,8 @@ class AggregatesSpec extends BaseSpec { null, sum("sum", Document("""{ $multiply: [ "$price", "$quantity" ] }""")), avg("avg", "$quantity"), + percentile("percentile", "$quantity", List(0.95, 0.3), QuantileMethod.approximate()), + median("median", "$quantity", QuantileMethod.approximate()), min("min", "$quantity"), minN("minN", "$quantity", 2), max("max", "$quantity"), @@ -443,6 +448,8 @@ class AggregatesSpec extends BaseSpec { ), WindowOutputFields.sum("newField01", "$field01", Some(range(1, CURRENT))), WindowOutputFields.avg("newField02", "$field02", Some(range(UNBOUNDED, 1))), + WindowOutputFields.percentile("newField02P", "$field02P", List(0.3, 0.9), QuantileMethod.approximate(), Some(range(UNBOUNDED, 1))), + WindowOutputFields.median("newField02M", "$field02M", QuantileMethod.approximate(), Some(range(UNBOUNDED, 1))), WindowOutputFields.stdDevSamp("newField03", "$field03", Some(window)), WindowOutputFields.stdDevPop("newField04", "$field04", Some(window)), WindowOutputFields.min("newField05", "$field05", Some(window)), @@ -485,6 +492,8 @@ class AggregatesSpec extends BaseSpec { "newField00": { "$sum": "$field00", "window": { "range": [{"$numberInt": "1"}, "current"] } }, "newField01": { "$sum": "$field01", "window": { "range": [{"$numberLong": "1"}, "current"] } }, "newField02": { "$avg": "$field02", "window": { "range": ["unbounded", {"$numberLong": "1"}] } }, + "newField02P": { "$percentile": { input: "$field02P", p: [0.3, 0.9], method: "approximate"} "window": { "range": ["unbounded", {"$numberLong": "1"}] } }, + "newField02M": { "$median": { input: "$field02M", method: "approximate"} "window": { "range": ["unbounded", {"$numberLong": "1"}] } }, "newField03": { "$stdDevSamp": "$field03", "window": { "documents": [1, 2] } }, "newField04": { "$stdDevPop": "$field04", "window": { "documents": [1, 2] } }, "newField05": { "$min": "$field05", "window": { "documents": [1, 2] } },