diff --git a/driver-core/src/main/com/mongodb/client/model/Aggregates.java b/driver-core/src/main/com/mongodb/client/model/Aggregates.java index 29531e76e16..ec17764eba7 100644 --- a/driver-core/src/main/com/mongodb/client/model/Aggregates.java +++ b/driver-core/src/main/com/mongodb/client/model/Aggregates.java @@ -46,9 +46,11 @@ import org.bson.codecs.configuration.CodecRegistry; import org.bson.conversions.Bson; +import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.Set; import static com.mongodb.assertions.Assertions.assertTrue; import static com.mongodb.assertions.Assertions.isTrueArgument; @@ -1093,6 +1095,55 @@ public static Bson rerank( return new RerankBson(query, paths, numDocsToRerank, model); } + /** + * Creates a {@code $scoreFusion} pipeline stage, which combines the results of the given input pipelines, + * normalizing and combining the scores they produce. It must be the first stage of an aggregation pipeline + * run on a collection, and the input pipelines must be scored selection pipelines on the same collection, + * e.g., starting with a {@link #search(SearchOperator, SearchOptions) $search} + * or {@link #vectorSearch(FieldSearchPath, Iterable, String, long, VectorSearchOptions) $vectorSearch} stage. + * You may use the {@code $meta: "score"} expression to extract the combined score of a document. + * + * @param pipelines The non-empty input pipelines with unique names. + * @param normalization The way in which the scores produced by the input pipelines are normalized. + * @return The {@code $scoreFusion} pipeline stage. + * @mongodb.driver.manual reference/operator/aggregation/scoreFusion/ $scoreFusion + * @mongodb.server.release 8.2 + * @since 5.10 + */ + public static Bson scoreFusion(final List pipelines, final ScoreNormalization normalization) { + return scoreFusion(pipelines, normalization, ScoreFusionOptions.scoreFusionOptions()); + } + + /** + * Creates a {@code $scoreFusion} pipeline stage, which combines the results of the given input pipelines, + * normalizing and combining the scores they produce. It must be the first stage of an aggregation pipeline + * run on a collection, and the input pipelines must be scored selection pipelines on the same collection, + * e.g., starting with a {@link #search(SearchOperator, SearchOptions) $search} + * or {@link #vectorSearch(FieldSearchPath, Iterable, String, long, VectorSearchOptions) $vectorSearch} stage. + * You may use the {@code $meta: "score"} expression to extract the combined score of a document. + * + * @param pipelines The non-empty input pipelines with unique names. + * @param normalization The way in which the scores produced by the input pipelines are normalized. + * @param options Optional {@code $scoreFusion} pipeline stage fields. + * @return The {@code $scoreFusion} pipeline stage. + * @mongodb.driver.manual reference/operator/aggregation/scoreFusion/ $scoreFusion + * @mongodb.server.release 8.2 + * @since 5.10 + */ + public static Bson scoreFusion(final List pipelines, final ScoreNormalization normalization, + final ScoreFusionOptions options) { + notNull("pipelines", pipelines); + isTrueArgument("pipelines must not be empty", !pipelines.isEmpty()); + Set names = new HashSet<>(); + for (FusionPipeline pipeline : pipelines) { + notNull("pipeline", pipeline); + isTrueArgument("pipeline names must be unique", names.add(pipeline.getName())); + } + notNull("normalization", normalization); + notNull("options", options); + return new ScoreFusionStage(pipelines, normalization, options); + } + /** * Creates an $unset pipeline stage that removes/excludes fields from documents * @@ -2344,6 +2395,45 @@ public String toString() { } } + private static final class ScoreFusionStage implements Bson { + private final List pipelines; + private final ScoreNormalization normalization; + private final ScoreFusionOptions options; + + ScoreFusionStage(final List pipelines, final ScoreNormalization normalization, + final ScoreFusionOptions options) { + this.pipelines = pipelines; + this.normalization = normalization; + this.options = options; + } + + @Override + public BsonDocument toBsonDocument(final Class documentClass, final CodecRegistry codecRegistry) { + BsonDocument pipelinesDoc = new BsonDocument(); + for (FusionPipeline pipeline : pipelines) { + BsonArray stages = new BsonArray(); + for (Bson stage : pipeline.getPipeline()) { + stages.add(stage.toBsonDocument(documentClass, codecRegistry)); + } + pipelinesDoc.append(pipeline.getName(), stages); + } + BsonDocument specificationDoc = new BsonDocument("input", + new BsonDocument("pipelines", pipelinesDoc) + .append("normalization", new BsonString(normalization.getValue()))); + specificationDoc.putAll(options.toBsonDocument(documentClass, codecRegistry)); + return new BsonDocument("$scoreFusion", specificationDoc); + } + + @Override + public String toString() { + return "Stage{name=$scoreFusion" + + ", pipelines=" + pipelines + + ", normalization=" + normalization + + ", options=" + options + + '}'; + } + } + private static class RerankBson implements Bson { private final RerankQuery query; private final List paths; diff --git a/driver-core/src/main/com/mongodb/client/model/FusionPipeline.java b/driver-core/src/main/com/mongodb/client/model/FusionPipeline.java new file mode 100644 index 00000000000..4c95b8d6b61 --- /dev/null +++ b/driver-core/src/main/com/mongodb/client/model/FusionPipeline.java @@ -0,0 +1,123 @@ +/* + * 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.conversions.Bson; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +import static com.mongodb.assertions.Assertions.isTrueArgument; +import static com.mongodb.assertions.Assertions.notNull; +import static java.util.Arrays.asList; +import static java.util.Collections.unmodifiableList; + +/** + * A named aggregation pipeline used as an input to a fusion pipeline stage, e.g., + * {@link Aggregates#scoreFusion(List, ScoreNormalization, ScoreFusionOptions) $scoreFusion}. + * The name uniquely identifies the pipeline within the stage and may be referred to + * by other parts of the stage, e.g., as the {@code "$$name"} variable in a + * {@linkplain ScoreFusionCombination#expression(Bson) combination expression}. + * A name must not be empty, must not start with {@code $}, and must not contain {@code .} + * or the null character. + * + * @since 5.10 + * @mongodb.server.release 8.2 + */ +public final class FusionPipeline { + private final String name; + private final List pipeline; + + /** + * Creates a new {@link FusionPipeline}. + * + * @param name The pipeline name, unique within the containing stage. It must not be empty, + * must not start with {@code $}, and must not contain {@code .} or the null character. + * @param pipeline The non-empty pipeline. + * @return The requested {@link FusionPipeline}. + */ + public static FusionPipeline of(final String name, final List pipeline) { + return new FusionPipeline(name, pipeline); + } + + /** + * Creates a new {@link FusionPipeline}. + * + * @param name The pipeline name, unique within the containing stage. It must not be empty, + * must not start with {@code $}, and must not contain {@code .} or the null character. + * @param pipeline The non-empty pipeline. + * @return The requested {@link FusionPipeline}. + */ + public static FusionPipeline of(final String name, final Bson... pipeline) { + return new FusionPipeline(name, asList(pipeline)); + } + + private FusionPipeline(final String name, final List pipeline) { + notNull("name", name); + isTrueArgument("name must not be empty", !name.trim().isEmpty()); + isTrueArgument("name must not start with '$'", !name.startsWith("$")); + isTrueArgument("name must not contain '.'", !name.contains(".")); + isTrueArgument("name must not contain the null character", name.indexOf('\u0000') < 0); + notNull("pipeline", pipeline); + isTrueArgument("pipeline must not be empty", !pipeline.isEmpty()); + for (Bson stage : pipeline) { + notNull("stage", stage); + } + this.name = name; + this.pipeline = unmodifiableList(new ArrayList(pipeline)); + } + + /** + * @return the pipeline name + */ + public String getName() { + return name; + } + + /** + * @return the pipeline + */ + public List getPipeline() { + return pipeline; + } + + @Override + public boolean equals(final Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + FusionPipeline that = (FusionPipeline) o; + return name.equals(that.name) && pipeline.equals(that.pipeline); + } + + @Override + public int hashCode() { + return Objects.hash(name, pipeline); + } + + @Override + public String toString() { + return "FusionPipeline{" + + "name='" + name + '\'' + + ", pipeline=" + pipeline + + '}'; + } +} diff --git a/driver-core/src/main/com/mongodb/client/model/ScoreFusionCombination.java b/driver-core/src/main/com/mongodb/client/model/ScoreFusionCombination.java new file mode 100644 index 00000000000..da4d4881324 --- /dev/null +++ b/driver-core/src/main/com/mongodb/client/model/ScoreFusionCombination.java @@ -0,0 +1,62 @@ +/* + * 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.Document; +import org.bson.conversions.Bson; + +import static com.mongodb.assertions.Assertions.notNull; + +/** + * The way in which the normalized scores produced by the input pipelines of the + * {@linkplain Aggregates#scoreFusion(java.util.List, ScoreNormalization, ScoreFusionOptions) $scoreFusion} + * stage are combined into the final score. The server rejects specifying both + * {@linkplain #weighted(Bson) weights} and an {@linkplain #expression(Bson) expression}, + * which is why they are separate factory methods. + * + * @see ScoreFusionOptions#combination(ScoreFusionCombination) + * @since 5.10 + * @mongodb.server.release 8.2 + */ +@Sealed +public interface ScoreFusionCombination extends Bson { + /** + * Returns a {@link WeightedScoreFusionCombination} combining the scores using per-pipeline weights. + * + * @param weights A document mapping {@linkplain FusionPipeline#getName() pipeline names} to non-negative + * numeric weights. Pipelines not mentioned have the server-default weight 1. + * @return The requested {@link WeightedScoreFusionCombination}. + */ + static WeightedScoreFusionCombination weighted(final Bson weights) { + return new ScoreFusionConstructibleBson(new Document("weights", notNull("weights", weights))); + } + + /** + * Returns a {@link ScoreFusionCombination} combining the scores using a custom expression. + * The normalized, weighted score of each input pipeline is available to the expression + * as the variable named after the pipeline, e.g., {@code "$$name"}. + * + * @param expression The combination expression. + * @return The requested {@link ScoreFusionCombination}. + */ + static ScoreFusionCombination expression(final Bson expression) { + return new ScoreFusionConstructibleBson(new Document("method", new BsonString("expression")) + .append("expression", notNull("expression", expression))); + } +} diff --git a/driver-core/src/main/com/mongodb/client/model/ScoreFusionConstructibleBson.java b/driver-core/src/main/com/mongodb/client/model/ScoreFusionConstructibleBson.java new file mode 100644 index 00000000000..e7c4fb5683b --- /dev/null +++ b/driver-core/src/main/com/mongodb/client/model/ScoreFusionConstructibleBson.java @@ -0,0 +1,69 @@ +/* + * 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.Immutable; +import com.mongodb.internal.client.model.AbstractConstructibleBson; +import org.bson.BsonBoolean; +import org.bson.BsonDocument; +import org.bson.BsonString; +import org.bson.Document; +import org.bson.conversions.Bson; + +import static com.mongodb.assertions.Assertions.notNull; + +final class ScoreFusionConstructibleBson extends AbstractConstructibleBson + implements ScoreFusionOptions, WeightedScoreFusionCombination { + /** + * An {@linkplain Immutable immutable} {@link BsonDocument#isEmpty() empty} instance. + */ + static final ScoreFusionConstructibleBson EMPTY_IMMUTABLE = + new ScoreFusionConstructibleBson(AbstractConstructibleBson.EMPTY_IMMUTABLE); + + ScoreFusionConstructibleBson(final Bson base) { + super(base); + } + + private ScoreFusionConstructibleBson(final Bson base, final Document appended) { + super(base, appended); + } + + @Override + protected ScoreFusionConstructibleBson newSelf(final Bson base, final Document appended) { + return new ScoreFusionConstructibleBson(base, appended); + } + + @Override + public ScoreFusionOptions combination(final ScoreFusionCombination combination) { + return newAppended("combination", notNull("combination", combination)); + } + + @Override + public ScoreFusionOptions scoreDetails(final boolean scoreDetails) { + return newAppended("scoreDetails", BsonBoolean.valueOf(scoreDetails)); + } + + @Override + public ScoreFusionOptions option(final String name, final Object value) { + return newAppended(notNull("name", name), notNull("value", value)); + } + + @Override + public WeightedScoreFusionCombination avg() { + return newAppended("method", new BsonString("avg")); + } +} diff --git a/driver-core/src/main/com/mongodb/client/model/ScoreFusionOptions.java b/driver-core/src/main/com/mongodb/client/model/ScoreFusionOptions.java new file mode 100644 index 00000000000..c967a55a25f --- /dev/null +++ b/driver-core/src/main/com/mongodb/client/model/ScoreFusionOptions.java @@ -0,0 +1,69 @@ +/* + * 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.conversions.Bson; + +/** + * Represents optional fields of the {@linkplain Aggregates#scoreFusion(java.util.List, ScoreNormalization, + * ScoreFusionOptions) $scoreFusion} pipeline stage of an aggregation pipeline. + * + * @since 5.10 + * @mongodb.server.release 8.2 + */ +@Sealed +public interface ScoreFusionOptions extends Bson { + /** + * Returns {@link ScoreFusionOptions} that represents server defaults. + * + * @return {@link ScoreFusionOptions} that represents server defaults. + */ + static ScoreFusionOptions scoreFusionOptions() { + return ScoreFusionConstructibleBson.EMPTY_IMMUTABLE; + } + + /** + * Creates a new {@link ScoreFusionOptions} with the combination specified. + * If not specified, the server combines the normalized scores using its default method. + * + * @param combination The way in which the normalized scores are combined. + * @return A new {@link ScoreFusionOptions}. + */ + ScoreFusionOptions combination(ScoreFusionCombination combination); + + /** + * Creates a new {@link ScoreFusionOptions} with the scoreDetails flag specified. + * When {@code true}, the server exposes score details via the {@code {$meta: "scoreDetails"}} expression. + * Server default is {@code false}. + * + * @param scoreDetails Whether to include score details. + * @return A new {@link ScoreFusionOptions}. + */ + ScoreFusionOptions scoreDetails(boolean scoreDetails); + + /** + * Creates a new {@link ScoreFusionOptions} with the specified option in situations when there is no builder method + * that better satisfies your needs. + * This method cannot be used to validate the syntax. + * + * @param name The option name. + * @param value The option value. + * @return A new {@link ScoreFusionOptions}. + */ + ScoreFusionOptions option(String name, Object value); +} diff --git a/driver-core/src/main/com/mongodb/client/model/ScoreNormalization.java b/driver-core/src/main/com/mongodb/client/model/ScoreNormalization.java new file mode 100644 index 00000000000..5cad4c7ade2 --- /dev/null +++ b/driver-core/src/main/com/mongodb/client/model/ScoreNormalization.java @@ -0,0 +1,55 @@ +/* + * 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; + +/** + * Normalization methods for the {@code $score} and {@code $scoreFusion} pipeline stages. + * + * @mongodb.driver.manual reference/operator/aggregation/score/ $score + * @mongodb.driver.manual reference/operator/aggregation/scoreFusion/ $scoreFusion + * @mongodb.server.release 8.2 + * @since 5.10 + */ +public enum ScoreNormalization { + /** + * No normalization is applied. + */ + NONE("none"), + /** + * Normalizes the score to the range (0, 1) by applying the sigmoid function. + */ + SIGMOID("sigmoid"), + /** + * Normalizes the score to the range [0, 1] by applying min-max scaling. + */ + MIN_MAX_SCALER("minMaxScaler"); + + private final String value; + + ScoreNormalization(final String value) { + this.value = value; + } + + /** + * Returns the value as expected by the server. + * + * @return the server value + */ + public String getValue() { + return value; + } +} diff --git a/driver-core/src/main/com/mongodb/client/model/WeightedScoreFusionCombination.java b/driver-core/src/main/com/mongodb/client/model/WeightedScoreFusionCombination.java new file mode 100644 index 00000000000..5d073207f67 --- /dev/null +++ b/driver-core/src/main/com/mongodb/client/model/WeightedScoreFusionCombination.java @@ -0,0 +1,36 @@ +/* + * 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; + +/** + * A {@linkplain ScoreFusionCombination#weighted(org.bson.conversions.Bson) weighted} {@link ScoreFusionCombination}. + * + * @since 5.10 + * @mongodb.server.release 8.2 + */ +@Sealed +public interface WeightedScoreFusionCombination extends ScoreFusionCombination { + /** + * Returns a new {@link WeightedScoreFusionCombination} that instructs the server to combine the + * weighted scores using their average instead of the default combination method. + * + * @return A new {@link WeightedScoreFusionCombination}. + */ + WeightedScoreFusionCombination avg(); +} 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 5cb70d4e2ef..755ac523375 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 @@ -30,6 +30,7 @@ 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.EnumSource; import org.junit.jupiter.params.provider.MethodSource; import java.math.RoundingMode; @@ -42,11 +43,16 @@ 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.match; +import static com.mongodb.client.model.Aggregates.project; import static com.mongodb.client.model.Aggregates.rerank; +import static com.mongodb.client.model.Aggregates.scoreFusion; import static com.mongodb.client.model.Aggregates.unset; import static com.mongodb.client.model.Aggregates.vectorSearch; +import static com.mongodb.client.model.Filters.exists; import static com.mongodb.client.model.RerankQuery.rerankQuery; import static com.mongodb.client.model.GeoNearOptions.geoNearOptions; +import static com.mongodb.client.model.ScoreFusionOptions.scoreFusionOptions; 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; @@ -54,6 +60,7 @@ import static com.mongodb.client.model.search.VectorSearchOptions.approximateVectorSearchOptions; import static com.mongodb.client.model.search.VectorSearchQuery.textQuery; import static java.util.Arrays.asList; +import static java.util.stream.Collectors.toList; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.hasSize; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -453,4 +460,136 @@ public void testRerankWithMultiplePathsAndBsonQuery() { "rerank-2" )); } + + // TODO JAVA-6202 replace the raw $score documents in these tests with the $score builder once available + private static final Bson SCORE_BY_X = BsonDocument.parse("{$score: {score: '$x'}}"); + private static final Bson SCORE_BY_Y = BsonDocument.parse("{$score: {score: '$y'}}"); + + private void insertScoreFusionDocuments() { + getCollectionHelper().insertDocuments( + BsonDocument.parse("{_id: 1, x: 10, y: 1}"), + BsonDocument.parse("{_id: 2, x: 5, y: 2}"), + BsonDocument.parse("{_id: 3, x: 1, y: 3}")); + } + + private List resultsWithScoresFor(final Bson scoreFusionStage) { + return getCollectionHelper().aggregate(asList( + scoreFusionStage, + project(Projections.meta("score", "score")))); + } + + private List idsFor(final List results) { + return results.stream() + .map(doc -> doc.getInt32("_id").getValue()) + .collect(toList()); + } + + private List scoresFor(final List results) { + return results.stream() + .map(doc -> doc.getNumber("score").doubleValue()) + .collect(toList()); + } + + @ParameterizedTest + @EnumSource(ScoreNormalization.class) + public void shouldScoreFusionWithEachNormalization(final ScoreNormalization normalization) { + assumeTrue(serverVersionAtLeast(8, 2)); + insertScoreFusionDocuments(); + List results = resultsWithScoresFor(scoreFusion( + asList( + FusionPipeline.of("byX", match(exists("x")), SCORE_BY_X), + FusionPipeline.of("byY", match(exists("y")), SCORE_BY_Y)), + normalization)); + assertEquals(3, results.size()); + if (normalization == ScoreNormalization.NONE) { + // the server combines the scores of the two pipelines with avg by default: (x + y) / 2 + // https://www.mongodb.com/docs/manual/reference/operator/aggregation/scoreFusion/ + assertEquals(asList(5.5, 3.5, 2.0), scoresFor(results)); + } else { + // sigmoid maps each pipeline score into (0, 1) and minMaxScaler into [0, 1], + // so the average of the two is within [0, 1] + scoresFor(results).forEach(score -> Assertions.assertTrue(score >= 0 && score <= 1)); + } + } + + @Test + public void shouldScoreFusionWithWeights() { + assumeTrue(serverVersionAtLeast(8, 2)); + insertScoreFusionDocuments(); + // weight only the "byY" pipeline: expected order is descending y, each score is y / 2 + // because the server combines the weighted scores of the two pipelines with avg by default + // https://www.mongodb.com/docs/manual/reference/operator/aggregation/scoreFusion/ + List results = resultsWithScoresFor(scoreFusion( + asList( + FusionPipeline.of("byX", match(exists("x")), SCORE_BY_X), + FusionPipeline.of("byY", match(exists("y")), SCORE_BY_Y)), + ScoreNormalization.NONE, + scoreFusionOptions().combination( + ScoreFusionCombination.weighted(new Document("byX", 0).append("byY", 1))))); + assertEquals(asList(3, 2, 1), idsFor(results)); + assertEquals(asList(1.5, 1.0, 0.5), scoresFor(results)); + } + + @Test + public void shouldScoreFusionWithWeightsAndAvgMethod() { + assumeTrue(serverVersionAtLeast(8, 2)); + insertScoreFusionDocuments(); + // weight only the "byX" pipeline and average over the two pipelines: each score is x / 2 + List results = resultsWithScoresFor(scoreFusion( + asList( + FusionPipeline.of("byX", match(exists("x")), SCORE_BY_X), + FusionPipeline.of("byY", match(exists("y")), SCORE_BY_Y)), + ScoreNormalization.NONE, + scoreFusionOptions().combination( + ScoreFusionCombination.weighted(new Document("byX", 1).append("byY", 0)).avg()))); + assertEquals(asList(1, 2, 3), idsFor(results)); + assertEquals(asList(5.0, 2.5, 0.5), scoresFor(results)); + } + + @Test + public void shouldScoreFusionWithExpressionCombination() { + assumeTrue(serverVersionAtLeast(8, 2)); + insertScoreFusionDocuments(); + // ignore byX, use 10 * byY: expected order is descending y, each score is exactly 10 * y + List results = resultsWithScoresFor(scoreFusion( + asList( + FusionPipeline.of("byX", match(exists("x")), SCORE_BY_X), + FusionPipeline.of("byY", match(exists("y")), SCORE_BY_Y)), + ScoreNormalization.NONE, + scoreFusionOptions().combination(ScoreFusionCombination.expression( + new Document("$sum", asList( + new Document("$multiply", asList("$$byX", 0)), + new Document("$multiply", asList("$$byY", 10)))))))); + assertEquals(asList(3, 2, 1), idsFor(results)); + assertEquals(asList(30.0, 20.0, 10.0), scoresFor(results)); + } + + @Test + public void shouldScoreFusionWithScoreDetails() { + assumeTrue(serverVersionAtLeast(8, 2)); + insertScoreFusionDocuments(); + List results = getCollectionHelper().aggregate(asList( + scoreFusion( + asList( + FusionPipeline.of("byX", match(exists("x")), SCORE_BY_X), + FusionPipeline.of("byY", match(exists("y")), SCORE_BY_Y)), + ScoreNormalization.SIGMOID, + scoreFusionOptions().scoreDetails(true)), + project(Projections.fields( + Projections.meta("score", "score"), + Projections.meta("scoreDetails", "scoreDetails"))))); + assertEquals(3, results.size()); + results.forEach(result -> { + double score = result.getNumber("score").doubleValue(); + Assertions.assertTrue(score > 0); + BsonDocument scoreDetails = result.getDocument("scoreDetails"); + // "value" holds the same combined score as the score metadata + assertEquals(score, scoreDetails.getNumber("value").doubleValue()); + Assertions.assertFalse(scoreDetails.getString("description").getValue().isEmpty()); + assertEquals("sigmoid", scoreDetails.getString("normalization").getValue()); + Assertions.assertNotNull(scoreDetails.getDocument("combination")); + // one entry per input pipeline + assertEquals(2, scoreDetails.getArray("details").size()); + }); + } } diff --git a/driver-core/src/test/functional/com/mongodb/client/model/search/AggregatesSearchIntegrationTest.java b/driver-core/src/test/functional/com/mongodb/client/model/search/AggregatesSearchIntegrationTest.java index bc34cb0060c..7bc8fcd89e7 100644 --- a/driver-core/src/test/functional/com/mongodb/client/model/search/AggregatesSearchIntegrationTest.java +++ b/driver-core/src/test/functional/com/mongodb/client/model/search/AggregatesSearchIntegrationTest.java @@ -19,16 +19,22 @@ import com.mongodb.MongoNamespace; import com.mongodb.assertions.Assertions; import com.mongodb.client.model.Aggregates; +import com.mongodb.client.model.Projections; +import com.mongodb.client.model.FusionPipeline; +import com.mongodb.client.model.ScoreFusionCombination; +import com.mongodb.client.model.ScoreNormalization; import com.mongodb.client.model.geojson.Point; import com.mongodb.client.model.geojson.Position; import com.mongodb.client.test.CollectionHelper; import org.bson.BsonDocument; import org.bson.BsonString; +import org.bson.Document; import org.bson.codecs.BsonDocumentCodec; import org.bson.conversions.Bson; import org.bson.json.JsonWriterSettings; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; +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; @@ -69,6 +75,7 @@ import static com.mongodb.client.model.Projections.metaSearchHighlights; import static com.mongodb.client.model.Projections.metaSearchScore; import static com.mongodb.client.model.Projections.metaVectorSearchScore; +import static com.mongodb.client.model.ScoreFusionOptions.scoreFusionOptions; import static com.mongodb.client.model.search.FuzzySearchOptions.fuzzySearchOptions; import static com.mongodb.client.model.search.SearchCollector.facet; import static com.mongodb.client.model.search.SearchCount.lowerBound; @@ -330,6 +337,49 @@ void vectorSearchSupportedFilters(final VectorSearchOptions vectorSearchOptions) ); } + @Test + void scoreFusion() { + assumeTrue(serverVersionAtLeast(8, 2)); + CollectionHelper collectionHelper = collectionHelpers.get(MFLIX_EMBEDDED_MOVIES_NS); + List pipeline = asList( + Aggregates.scoreFusion( + asList( + FusionPipeline.of("vector", Aggregates.vectorSearch( + fieldPath("plot_embedding"), QUERY_VECTOR, "sample_mflix__embedded_movies", LIMIT, + approximateVectorSearchOptions(LIMIT + 1))), + FusionPipeline.of("text", + Aggregates.search(SearchOperator.text(fieldPath("title"), "train"), + searchOptions().index("sample_mflix__embedded_movies")), + Aggregates.limit(LIMIT))), + ScoreNormalization.SIGMOID, + scoreFusionOptions() + .combination(ScoreFusionCombination.weighted( + new Document("vector", 0.7).append("text", 0.3))) + .scoreDetails(true)), + Aggregates.limit(LIMIT), + Aggregates.project(Projections.fields( + Projections.meta("score", "score"), + Projections.meta("scoreDetails", "scoreDetails")))); + List results = collectionHelper.aggregate(pipeline); + Asserters.nonEmpty().accept(results, msgSupplier(pipeline)); + assertTrue(results.size() <= LIMIT, msgSupplier(pipeline)); + double previousScore = Double.MAX_VALUE; + for (BsonDocument result : results) { + double score = result.getNumber("score").doubleValue(); + // each sigmoid-normalized pipeline score is in (0, 1) and the weighted scores are averaged + assertTrue(score > 0 && score < 1, msgSupplier(pipeline)); + // results are ordered by descending fused score + assertTrue(score <= previousScore, msgSupplier(pipeline)); + previousScore = score; + BsonDocument scoreDetails = result.getDocument("scoreDetails"); + assertEquals(score, scoreDetails.getNumber("value").doubleValue(), msgSupplier(pipeline)); + assertEquals("sigmoid", scoreDetails.getString("normalization").getValue(), msgSupplier(pipeline)); + assertFalse(scoreDetails.getString("description").getValue().isEmpty(), msgSupplier(pipeline)); + // one details entry per input pipeline + assertEquals(2, scoreDetails.getArray("details").size(), msgSupplier(pipeline)); + } + } + /** * @param stageUnderTestCreator A {@link CustomizableSearchStageCreator} that is used to create both * {@code $search} and {@code $searchMeta} stages. Any combination of an {@link SearchOperator}/{@link SearchCollector} and diff --git a/driver-core/src/test/unit/com/mongodb/client/model/AggregatesSpecification.groovy b/driver-core/src/test/unit/com/mongodb/client/model/AggregatesSpecification.groovy index f78aefd51b4..38a4e9c59c7 100644 --- a/driver-core/src/test/unit/com/mongodb/client/model/AggregatesSpecification.groovy +++ b/driver-core/src/test/unit/com/mongodb/client/model/AggregatesSpecification.groovy @@ -68,6 +68,7 @@ import static com.mongodb.client.model.Aggregates.project import static com.mongodb.client.model.Aggregates.replaceRoot import static com.mongodb.client.model.Aggregates.replaceWith import static com.mongodb.client.model.Aggregates.sample +import static com.mongodb.client.model.Aggregates.scoreFusion import static com.mongodb.client.model.Aggregates.search import static com.mongodb.client.model.Aggregates.searchMeta import static com.mongodb.client.model.Aggregates.set @@ -101,6 +102,7 @@ import static com.mongodb.client.model.search.SearchPath.fieldPath import static com.mongodb.client.model.search.SearchPath.wildcardPath import static com.mongodb.client.model.search.VectorSearchOptions.approximateVectorSearchOptions import static com.mongodb.client.model.search.VectorSearchOptions.exactVectorSearchOptions +import static com.mongodb.client.model.ScoreFusionOptions.scoreFusionOptions import static java.util.Arrays.asList import static org.bson.BsonDocument.parse @@ -1362,4 +1364,138 @@ class AggregatesSpecification extends Specification { replaceRoot('$a1.b').hashCode() == replaceRoot('$a1.b').hashCode() replaceRoot('$a1').hashCode() == replaceRoot('$a1').hashCode() } + + + def 'should create FusionPipeline'() { + when: + def pipeline = FusionPipeline.of('p1', match(eq('x', 1)), limit(2)) + + then: + pipeline.name == 'p1' + pipeline.pipeline.size() == 2 + pipeline == FusionPipeline.of('p1', [match(eq('x', 1)), limit(2)]) + + when: + FusionPipeline.of('', match(eq('x', 1))) + + then: + thrown(IllegalArgumentException) + + when: + FusionPipeline.of('p1', []) + + then: + thrown(IllegalArgumentException) + + when: + FusionPipeline.of(' ', match(eq('x', 1))) + + then: + thrown(IllegalArgumentException) + + when: + FusionPipeline.of('$vector', match(eq('x', 1))) + + then: + thrown(IllegalArgumentException) + + when: + FusionPipeline.of('a.b', match(eq('x', 1))) + + then: + thrown(IllegalArgumentException) + + when: + FusionPipeline.of('a\u0000b', match(eq('x', 1))) + + then: + thrown(IllegalArgumentException) + + when: + FusionPipeline.of(null, match(eq('x', 1))) + + then: + thrown(IllegalArgumentException) + + when: + FusionPipeline.of('p1', match(eq('x', 1)), null) + + then: + thrown(IllegalArgumentException) + } + + def 'should render ScoreFusionCombination and ScoreFusionOptions'() { + expect: + toBson(ScoreFusionCombination.weighted(new Document('p1', 0.3d).append('p2', 0.7d))) == + parse('{weights: {p1: 0.3, p2: 0.7}}') + toBson(ScoreFusionCombination.weighted(new Document('p1', 0.3d)).avg()) == + parse('{weights: {p1: 0.3}, method: "avg"}') + toBson(ScoreFusionCombination.expression(new Document('$sum', ['$$p1', '$$p2']))) == + parse('{method: "expression", expression: {$sum: ["$$p1", "$$p2"]}}') + toBson(scoreFusionOptions()) == parse('{}') + toBson(scoreFusionOptions() + .combination(ScoreFusionCombination.expression(new Document('$sum', ['$$p1', '$$p2']))) + .scoreDetails(true)) == + parse('{combination: {method: "expression", expression: {$sum: ["$$p1", "$$p2"]}}, scoreDetails: true}') + toBson(scoreFusionOptions().option('scoreDetails', true)) == parse('{scoreDetails: true}') + } + + def 'should render $scoreFusion'() { + expect: + toBson(scoreFusion( + [FusionPipeline.of('p1', match(eq('x', 1))), FusionPipeline.of('p2', match(eq('x', 2)))], + ScoreNormalization.SIGMOID)) == + parse('''{$scoreFusion: {input: { + pipelines: {p1: [{$match: {x: 1}}], p2: [{$match: {x: 2}}]}, + normalization: "sigmoid"}}}''') + + toBson(scoreFusion( + [FusionPipeline.of('p1', match(eq('x', 1))), FusionPipeline.of('p2', match(eq('x', 2)), limit(5))], + ScoreNormalization.MIN_MAX_SCALER, + scoreFusionOptions() + .combination(ScoreFusionCombination.weighted(new Document('p1', 0.3d).append('p2', 0.7d)).avg()) + .scoreDetails(true))) == + parse('''{$scoreFusion: {input: { + pipelines: {p1: [{$match: {x: 1}}], p2: [{$match: {x: 2}}, {$limit: 5}]}, + normalization: "minMaxScaler"}, + combination: {weights: {p1: 0.3, p2: 0.7}, method: "avg"}, + scoreDetails: true}}''') + + toBson(scoreFusion( + [FusionPipeline.of('p1', match(eq('x', 1))), FusionPipeline.of('p2', match(eq('x', 2)))], + ScoreNormalization.NONE, + scoreFusionOptions().combination( + ScoreFusionCombination.expression(new Document('$sum', ['$$p1', '$$p2']))))) == + parse('''{$scoreFusion: {input: { + pipelines: {p1: [{$match: {x: 1}}], p2: [{$match: {x: 2}}]}, + normalization: "none"}, + combination: {method: "expression", expression: {$sum: ["$$p1", "$$p2"]}}}}''') + } + + def 'should validate $scoreFusion arguments'() { + when: + scoreFusion([], ScoreNormalization.NONE) + + then: + thrown(IllegalArgumentException) + + when: + scoreFusion([FusionPipeline.of('p1', match(eq('x', 1))), FusionPipeline.of('p1', match(eq('x', 2)))], + ScoreNormalization.NONE) + + then: + thrown(IllegalArgumentException) + + when: + scoreFusion(null, ScoreNormalization.NONE) + + then: + thrown(IllegalArgumentException) + + when: + scoreFusion([FusionPipeline.of('p1', match(eq('x', 1)))], null) + + then: + thrown(IllegalArgumentException) + } } diff --git a/driver-scala/src/main/scala/org/mongodb/scala/model/Aggregates.scala b/driver-scala/src/main/scala/org/mongodb/scala/model/Aggregates.scala index 31c8c65ec79..6f3aee8e1c8 100644 --- a/driver-scala/src/main/scala/org/mongodb/scala/model/Aggregates.scala +++ b/driver-scala/src/main/scala/org/mongodb/scala/model/Aggregates.scala @@ -176,6 +176,39 @@ object Aggregates { */ def facet(facets: Facet*): Bson = JAggregates.facet(facets.asJava) + /** + * Creates a `\$scoreFusion` pipeline stage, which combines the results of the given input pipelines, + * normalizing and combining the scores they produce. + * + * @param pipelines the non-empty input pipelines with unique names + * @param normalization the way in which the scores produced by the input pipelines are normalized + * @return the `\$scoreFusion` pipeline stage + * @see [[https://www.mongodb.com/docs/manual/reference/operator/aggregation/scoreFusion/ \$scoreFusion]] + * @note Requires MongoDB 8.2 or greater + * @since 5.10 + */ + def scoreFusion(pipelines: Seq[FusionPipeline], normalization: ScoreNormalization): Bson = + JAggregates.scoreFusion(pipelines.asJava, normalization) + + /** + * Creates a `\$scoreFusion` pipeline stage, which combines the results of the given input pipelines, + * normalizing and combining the scores they produce. + * + * @param pipelines the non-empty input pipelines with unique names + * @param normalization the way in which the scores produced by the input pipelines are normalized + * @param options optional `\$scoreFusion` pipeline stage fields + * @return the `\$scoreFusion` pipeline stage + * @see [[https://www.mongodb.com/docs/manual/reference/operator/aggregation/scoreFusion/ \$scoreFusion]] + * @note Requires MongoDB 8.2 or greater + * @since 5.10 + */ + def scoreFusion( + pipelines: Seq[FusionPipeline], + normalization: ScoreNormalization, + options: ScoreFusionOptions + ): Bson = + JAggregates.scoreFusion(pipelines.asJava, normalization, options) + /** * Creates a `\$graphLookup` pipeline stage for the specified filter * diff --git a/driver-scala/src/main/scala/org/mongodb/scala/model/ScoreFusionCombination.scala b/driver-scala/src/main/scala/org/mongodb/scala/model/ScoreFusionCombination.scala new file mode 100644 index 00000000000..3a3a9b716b6 --- /dev/null +++ b/driver-scala/src/main/scala/org/mongodb/scala/model/ScoreFusionCombination.scala @@ -0,0 +1,44 @@ +/* + * 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.client.model.{ ScoreFusionCombination => JScoreFusionCombination } +import org.mongodb.scala.bson.conversions.Bson + +/** + * The way in which the normalized scores produced by the `\$scoreFusion` input pipelines are combined. + * + * @note Requires MongoDB 8.2 or greater + * @since 5.10 + */ +object ScoreFusionCombination { + + /** + * Returns a `WeightedScoreFusionCombination` that combines the normalized scores using the given weights. + * + * @param weights the weights + * @return The requested `WeightedScoreFusionCombination`. + */ + def weighted(weights: Bson): WeightedScoreFusionCombination = JScoreFusionCombination.weighted(weights) + + /** + * Returns a `ScoreFusionCombination` that combines the normalized scores using the given expression. + * + * @param expression the expression + * @return The requested `ScoreFusionCombination`. + */ + def expression(expression: Bson): ScoreFusionCombination = JScoreFusionCombination.expression(expression) +} diff --git a/driver-scala/src/main/scala/org/mongodb/scala/model/ScoreFusionOptions.scala b/driver-scala/src/main/scala/org/mongodb/scala/model/ScoreFusionOptions.scala new file mode 100644 index 00000000000..42263d9dea3 --- /dev/null +++ b/driver-scala/src/main/scala/org/mongodb/scala/model/ScoreFusionOptions.scala @@ -0,0 +1,34 @@ +/* + * 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.client.model.{ ScoreFusionOptions => JScoreFusionOptions } + +/** + * Represents optional fields of the `\$scoreFusion` pipeline stage of an aggregation pipeline. + * + * @note Requires MongoDB 8.2 or greater + * @since 5.10 + */ +object ScoreFusionOptions { + + /** + * Returns `ScoreFusionOptions` that represents server defaults. + * + * @return `ScoreFusionOptions` that represents server defaults. + */ + def scoreFusionOptions(): ScoreFusionOptions = JScoreFusionOptions.scoreFusionOptions() +} diff --git a/driver-scala/src/main/scala/org/mongodb/scala/model/ScoreNormalization.scala b/driver-scala/src/main/scala/org/mongodb/scala/model/ScoreNormalization.scala new file mode 100644 index 00000000000..0fbe45a2f4d --- /dev/null +++ b/driver-scala/src/main/scala/org/mongodb/scala/model/ScoreNormalization.scala @@ -0,0 +1,45 @@ +/* + * 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.client.model.{ ScoreNormalization => JScoreNormalization } + +/** + * Normalization methods for the `\$score` and `\$scoreFusion` pipeline stages. + * + * @see [[https://www.mongodb.com/docs/manual/reference/operator/aggregation/score/ \$score]] + * @see [[https://www.mongodb.com/docs/manual/reference/operator/aggregation/scoreFusion/ \$scoreFusion]] + * @note Requires MongoDB 8.2 or greater + * @since 5.10 + */ +object ScoreNormalization { + + /** + * No normalization is applied. + */ + val NONE: ScoreNormalization = JScoreNormalization.NONE + + /** + * Normalizes the score to the range (0, 1) by applying the sigmoid function. + */ + val SIGMOID: ScoreNormalization = JScoreNormalization.SIGMOID + + /** + * Normalizes the score to the range [0, 1] by applying min-max scaling. + */ + val MIN_MAX_SCALER: ScoreNormalization = JScoreNormalization.MIN_MAX_SCALER +} 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 7a920092581..939390ecc9d 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 @@ -361,6 +361,69 @@ package object model { } } + /** + * A named aggregation pipeline used as an input to a fusion pipeline stage, e.g., `\$scoreFusion`. + * + * @note Requires MongoDB 8.2 or greater + * @since 5.10 + */ + type FusionPipeline = com.mongodb.client.model.FusionPipeline + + /** + * A named aggregation pipeline used as an input to a fusion pipeline stage, e.g., `\$scoreFusion`. + * + * @note Requires MongoDB 8.2 or greater + * @since 5.10 + */ + object FusionPipeline { + + /** + * Construct a new instance + * + * @param name the non-empty pipeline name, unique within the containing stage + * @param pipeline the non-empty pipeline + * @return the new FusionPipeline + */ + def apply(name: String, pipeline: Bson*): FusionPipeline = { + com.mongodb.client.model.FusionPipeline.of(name, pipeline.asJava) + } + } + + /** + * Normalization methods for the `\$score` and `\$scoreFusion` pipeline stages. + * + * @note Requires MongoDB 8.2 or greater + * @since 5.10 + */ + type ScoreNormalization = com.mongodb.client.model.ScoreNormalization + + /** + * The way in which the normalized scores produced by the `\$scoreFusion` input pipelines are combined. + * + * @note Requires MongoDB 8.2 or greater + * @since 5.10 + */ + @Sealed + type ScoreFusionCombination = com.mongodb.client.model.ScoreFusionCombination + + /** + * A weighted `ScoreFusionCombination`. + * + * @note Requires MongoDB 8.2 or greater + * @since 5.10 + */ + @Sealed + type WeightedScoreFusionCombination = com.mongodb.client.model.WeightedScoreFusionCombination + + /** + * Represents optional fields of the `\$scoreFusion` pipeline stage of an aggregation pipeline. + * + * @note Requires MongoDB 8.2 or greater + * @since 5.10 + */ + @Sealed + type ScoreFusionOptions = com.mongodb.client.model.ScoreFusionOptions + /** * A helper to define new fields for the `\$addFields` pipeline stage * 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 4969b149699..e1edb4e78e8 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 @@ -908,6 +908,96 @@ class AggregatesSpec extends BaseSpec { ) } + it should "render $scoreFusion" in { + toBson( + Aggregates.scoreFusion( + Seq( + FusionPipeline("p1", Aggregates.filter(Filters.equal("x", 1))), + FusionPipeline("p2", Aggregates.filter(Filters.equal("x", 2))) + ), + ScoreNormalization.SIGMOID + ) + ) should equal( + Document( + """{ + "$scoreFusion": { + "input": { + "pipelines": { + "p1": [{"$match": {"x": 1}}], + "p2": [{"$match": {"x": 2}}] + }, + "normalization": "sigmoid" + } + } + }""" + ) + ) + } + + it should "render $scoreFusion with options" in { + toBson( + Aggregates.scoreFusion( + Seq( + FusionPipeline("p1", Aggregates.filter(Filters.equal("x", 1))), + FusionPipeline("p2", Aggregates.filter(Filters.equal("x", 2))) + ), + ScoreNormalization.MIN_MAX_SCALER, + ScoreFusionOptions + .scoreFusionOptions() + .combination(ScoreFusionCombination.weighted(Document("p1" -> 0.3, "p2" -> 0.7)).avg()) + .scoreDetails(true) + ) + ) should equal( + Document( + """{ + "$scoreFusion": { + "input": { + "pipelines": { + "p1": [{"$match": {"x": 1}}], + "p2": [{"$match": {"x": 2}}] + }, + "normalization": "minMaxScaler" + }, + "combination": {"weights": {"p1": 0.3, "p2": 0.7}, "method": "avg"}, + "scoreDetails": true + } + }""" + ) + ) + } + + it should "render $scoreFusion with expression combination" in { + toBson( + Aggregates.scoreFusion( + Seq( + FusionPipeline("p1", Aggregates.filter(Filters.equal("x", 1))), + FusionPipeline("p2", Aggregates.filter(Filters.equal("x", 2))) + ), + ScoreNormalization.NONE, + ScoreFusionOptions + .scoreFusionOptions() + .combination( + ScoreFusionCombination.expression(Document("""{$sum: ["$$p1", "$$p2"]}""")) + ) + ) + ) should equal( + Document( + """{ + "$scoreFusion": { + "input": { + "pipelines": { + "p1": [{"$match": {"x": 1}}], + "p2": [{"$match": {"x": 2}}] + }, + "normalization": "none" + }, + "combination": {"method": "expression", "expression": {"$sum": ["$$p1", "$$p2"]}} + } + }""" + ) + ) + } + it should "render $unset" in { toBson( Aggregates.unset("title", "author.first")