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..e451c3560c8 100644 --- a/driver-core/src/main/com/mongodb/client/model/Aggregates.java +++ b/driver-core/src/main/com/mongodb/client/model/Aggregates.java @@ -54,6 +54,7 @@ import static com.mongodb.assertions.Assertions.isTrueArgument; import static com.mongodb.assertions.Assertions.notNull; import static com.mongodb.client.model.GeoNearOptions.geoNearOptions; +import static com.mongodb.client.model.ScoreOptions.scoreOptions; import static com.mongodb.client.model.densify.DensifyOptions.densifyOptions; import static com.mongodb.client.model.search.SearchOptions.searchOptions; import static com.mongodb.internal.Iterables.concat; @@ -1093,6 +1094,68 @@ public static Bson rerank( return new RerankBson(query, paths, numDocsToRerank, model); } + /** + * Creates a {@code $score} pipeline stage that computes a new score for each document + * and attaches it as {@code score} metadata. + * You may use the {@code $meta: "score"} expression to extract the computed score. + * + * @param score the expression that computes the score. Must evaluate to a numeric value. + * @param the score expression type + * @return the {@code $score} pipeline stage + * @mongodb.driver.manual reference/operator/aggregation/score/ $score + * @mongodb.server.release 8.2 + * @since 5.10 + */ + public static Bson score(final TExpression score) { + return score(score, scoreOptions()); + } + + /** + * Creates a {@code $score} pipeline stage that computes a new score for each document + * and attaches it as {@code score} metadata, with optional normalization, weighting and score details. + * You may use the {@code $meta: "score"} expression to extract the computed score. + * + * @param score the expression that computes the score. Must evaluate to a numeric value. + * @param options optional {@code $score} pipeline stage fields + * @param the score expression type + * @return the {@code $score} pipeline stage + * @mongodb.driver.manual reference/operator/aggregation/score/ $score + * @mongodb.server.release 8.2 + * @since 5.10 + */ + public static Bson score(final TExpression score, final ScoreOptions options) { + notNull("score", score); + notNull("options", options); + return new Bson() { + @Override + public BsonDocument toBsonDocument(final Class documentClass, final CodecRegistry codecRegistry) { + BsonDocumentWriter writer = new BsonDocumentWriter(new BsonDocument()); + writer.writeStartDocument(); + writer.writeStartDocument("$score"); + + writer.writeName("score"); + BuildersHelper.encodeValue(writer, score, codecRegistry); + + options.toBsonDocument(documentClass, codecRegistry).forEach((optionName, optionValue) -> { + writer.writeName(optionName); + BuildersHelper.encodeValue(writer, optionValue, codecRegistry); + }); + + writer.writeEndDocument(); + writer.writeEndDocument(); + return writer.getDocument(); + } + + @Override + public String toString() { + return "Stage{name='$score'" + + ", score=" + score + + ", options=" + options + + '}'; + } + }; + } + /** * Creates an $unset pipeline stage that removes/excludes fields from documents * diff --git a/driver-core/src/main/com/mongodb/client/model/ScoreConstructibleBson.java b/driver-core/src/main/com/mongodb/client/model/ScoreConstructibleBson.java new file mode 100644 index 00000000000..213e195073b --- /dev/null +++ b/driver-core/src/main/com/mongodb/client/model/ScoreConstructibleBson.java @@ -0,0 +1,63 @@ +/* + * 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.BsonDocument; +import org.bson.Document; +import org.bson.conversions.Bson; + +import static com.mongodb.assertions.Assertions.isTrueArgument; +import static com.mongodb.assertions.Assertions.notNull; + +final class ScoreConstructibleBson extends AbstractConstructibleBson implements ScoreOptions { + /** + * An {@linkplain Immutable immutable} {@link BsonDocument#isEmpty() empty} instance. + */ + static final ScoreOptions EMPTY_IMMUTABLE = new ScoreConstructibleBson(AbstractConstructibleBson.EMPTY_IMMUTABLE); + + private ScoreConstructibleBson(final Bson base) { + super(base); + } + + private ScoreConstructibleBson(final Bson base, final Document appended) { + super(base, appended); + } + + @Override + public ScoreOptions normalization(final ScoreNormalization normalization) { + notNull("normalization", normalization); + return newAppended("normalization", normalization.getValue()); + } + + @Override + public ScoreOptions weight(final double weight) { + isTrueArgument("weight must be in the range [0, 1]", weight >= 0 && weight <= 1); + return newAppended("weight", weight); + } + + @Override + public ScoreOptions scoreDetails(final boolean scoreDetails) { + return newAppended("scoreDetails", scoreDetails); + } + + @Override + protected ScoreConstructibleBson newSelf(final Bson base, final Document appended) { + return new ScoreConstructibleBson(base, appended); + } +} 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..5f1037a95c0 --- /dev/null +++ b/driver-core/src/main/com/mongodb/client/model/ScoreNormalization.java @@ -0,0 +1,56 @@ +/* + * 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 {@link Aggregates#score(Object, ScoreOptions) $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/ScoreOptions.java b/driver-core/src/main/com/mongodb/client/model/ScoreOptions.java new file mode 100644 index 00000000000..48ecab565be --- /dev/null +++ b/driver-core/src/main/com/mongodb/client/model/ScoreOptions.java @@ -0,0 +1,70 @@ +/* + * 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; + +/** + * The options for a {@link Aggregates#score(Object, ScoreOptions) $score} pipeline stage. + * + * @mongodb.driver.manual reference/operator/aggregation/score/ $score + * @mongodb.server.release 8.2 + * @since 5.10 + */ +public interface ScoreOptions extends Bson { + /** + * Returns {@link ScoreOptions} that represents server defaults. + * + * @return {@link ScoreOptions} that represents server defaults. + */ + static ScoreOptions scoreOptions() { + return ScoreConstructibleBson.EMPTY_IMMUTABLE; + } + + /** + * The method used to normalize the score to the range [0, 1]. + * If this option is not provided, the server default is {@link ScoreNormalization#NONE}. + * + * @param normalization the normalization method + * @return a new {@link ScoreOptions} with the provided option set + * @since 5.10 + */ + ScoreOptions normalization(ScoreNormalization normalization); + + /** + * The factor to multiply the score by after normalization. + * Must be in the range [0, 1]. + * + * @param weight the weight + * @return a new {@link ScoreOptions} with the provided option set + * @throws IllegalArgumentException if the weight is not in the range [0, 1] + * @mongodb.driver.manual reference/operator/aggregation/score/ $score + * @since 5.10 + */ + ScoreOptions weight(double weight); + + /** + * Specifies whether to populate the {@code scoreDetails} metadata field, + * which contains details on how the score was computed. + * If this option is not provided, the server default is {@code false}. + * + * @param scoreDetails whether to populate the {@code scoreDetails} metadata field + * @return a new {@link ScoreOptions} with the provided option set + * @since 5.10 + */ + ScoreOptions scoreDetails(boolean scoreDetails); +} 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..9b3d2246775 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; @@ -43,10 +44,12 @@ import static com.mongodb.client.model.Aggregates.geoNear; import static com.mongodb.client.model.Aggregates.group; import static com.mongodb.client.model.Aggregates.rerank; +import static com.mongodb.client.model.Aggregates.score; import static com.mongodb.client.model.Aggregates.unset; import static com.mongodb.client.model.Aggregates.vectorSearch; import static com.mongodb.client.model.RerankQuery.rerankQuery; import static com.mongodb.client.model.GeoNearOptions.geoNearOptions; +import static com.mongodb.client.model.ScoreOptions.scoreOptions; 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; @@ -57,6 +60,7 @@ 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.Assertions.assertThrows; import static org.junit.jupiter.api.Assumptions.assumeTrue; public class AggregatesTest extends OperationTest { @@ -453,4 +457,84 @@ public void testRerankWithMultiplePathsAndBsonQuery() { "rerank-2" )); } + + @Test + public void testScoreWithExpression() { + assertPipeline( + "{'$score': {'score': {'$multiply': ['$rating', 2]}}}", + score(new Document("$multiply", asList("$rating", 2)))); + } + + @Test + public void testScoreWithAllOptions() { + assertPipeline( + "{" + + " '$score': {" + + " 'score': '$rating'," + + " 'normalization': 'sigmoid'," + + " 'weight': 0.5," + + " 'scoreDetails': true" + + " }" + + "}", + score("$rating", scoreOptions() + .normalization(ScoreNormalization.SIGMOID) + .weight(0.5) + .scoreDetails(true))); + } + + @ParameterizedTest + @EnumSource(ScoreNormalization.class) + public void testScoreWithEachNormalization(final ScoreNormalization normalization) { + assertPipeline( + "{'$score': {'score': '$rating', 'normalization': '" + normalization.getValue() + "'}}", + score("$rating", scoreOptions().normalization(normalization))); + } + + @Test + public void testScoreWeightValidation() { + assertThrows(IllegalArgumentException.class, () -> scoreOptions().weight(-0.1)); + assertThrows(IllegalArgumentException.class, () -> scoreOptions().weight(1.1)); + assertThrows(IllegalArgumentException.class, () -> scoreOptions().weight(Double.NaN)); + assertPipeline( + "{'$score': {'score': '$rating', 'weight': 0.0}}", + score("$rating", scoreOptions().weight(0))); + assertPipeline( + "{'$score': {'score': '$rating', 'weight': 1.0}}", + score("$rating", scoreOptions().weight(1))); + } + + @Test + public void testScore() { + assumeTrue(serverVersionAtLeast(8, 2)); + getCollectionHelper().insertDocuments("[{_id: 1, rating: 2}, {_id: 2, rating: 4}]"); + + List pipeline = asList( + score(new Document("$multiply", asList("$rating", 2)), + scoreOptions().normalization(ScoreNormalization.SIGMOID)), + Aggregates.sort(ascending("_id")), + Aggregates.project(Projections.computed("score", new Document("$meta", "score")))); + + List results = getCollectionHelper().aggregate(pipeline); + assertEquals(2, results.size()); + // sigmoid normalization maps each score into the range (0, 1) + results.forEach(result -> { + double scoreValue = result.getNumber("score").doubleValue(); + Assertions.assertTrue(scoreValue > 0 && scoreValue < 1); + }); + } + + @ParameterizedTest + @EnumSource(ScoreNormalization.class) + public void testScoreOnServerWithEachNormalization(final ScoreNormalization normalization) { + assumeTrue(serverVersionAtLeast(8, 2)); + getCollectionHelper().insertDocuments("[{_id: 1, rating: 2}, {_id: 2, rating: 4}]"); + + List pipeline = asList( + score("$rating", scoreOptions().normalization(normalization)), + Aggregates.project(Projections.computed("score", new Document("$meta", "score")))); + + List results = getCollectionHelper().aggregate(pipeline); + assertEquals(2, results.size()); + results.forEach(result -> Assertions.assertTrue(result.isNumber("score"))); + } } 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..142c4f66c3e 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 @@ -851,6 +851,35 @@ object Aggregates { ): Bson = JAggregates.rerank(query, paths.toList.asJava, numDocsToRerank, model) + /** + * Creates a `\$score` pipeline stage that computes a new score for each document + * and attaches it as `score` metadata. + * You may use the `\$meta: "score"` expression to extract the computed score. + * + * @param score the expression that computes the score. Must evaluate to a numeric value. + * @tparam TExpression the score expression type + * @return the `\$score` pipeline stage + * @see [[https://www.mongodb.com/docs/manual/reference/operator/aggregation/score/ \$score]] + * @note Requires MongoDB 8.2 or greater + * @since 5.10 + */ + def score[TExpression](score: TExpression): Bson = JAggregates.score(score) + + /** + * Creates a `\$score` pipeline stage that computes a new score for each document + * and attaches it as `score` metadata, with optional normalization, weighting and score details. + * You may use the `\$meta: "score"` expression to extract the computed score. + * + * @param score the expression that computes the score. Must evaluate to a numeric value. + * @param options optional `\$score` pipeline stage fields + * @tparam TExpression the score expression type + * @return the `\$score` pipeline stage + * @see [[https://www.mongodb.com/docs/manual/reference/operator/aggregation/score/ \$score]] + * @note Requires MongoDB 8.2 or greater + * @since 5.10 + */ + def score[TExpression](score: TExpression, options: ScoreOptions): Bson = JAggregates.score(score, options) + /** * Creates an `\$unset` pipeline stage that removes/excludes fields from documents * 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..766bb7c6229 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 @@ -1095,6 +1095,10 @@ package object model { type RerankQuery = com.mongodb.client.model.RerankQuery + type ScoreOptions = com.mongodb.client.model.ScoreOptions + + type ScoreNormalization = com.mongodb.client.model.ScoreNormalization + /** * @see `QuantileMethod.approximate()` */ 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..9ef895efe33 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 @@ -39,6 +39,7 @@ import org.mongodb.scala.model.search.SearchFacet.stringFacet import org.mongodb.scala.model.search.SearchHighlight.paths import com.mongodb.client.model.{ Aggregates => JAggregates } import com.mongodb.client.model.RerankQuery +import com.mongodb.client.model.ScoreOptions.scoreOptions import com.mongodb.client.model.search.VectorSearchQuery import org.bson.BinaryVector import org.mongodb.scala.model.search.SearchCollector @@ -908,6 +909,52 @@ class AggregatesSpec extends BaseSpec { ) } + it should "render $score" in { + toBson( + Aggregates.score(Document("""{$multiply: ["$rating", 2]}""")) + ) should equal( + Document("""{ "$score": { "score": {"$multiply": ["$rating", 2]} } }""") + ) + } + + it should "render $score with options" in { + toBson( + Aggregates.score( + "$rating", + scoreOptions() + .normalization(ScoreNormalization.SIGMOID) + .weight(0.5) + .scoreDetails(true) + ) + ) should equal( + Document( + """{ + "$score": { + "score": "$rating", + "normalization": "sigmoid", + "weight": 0.5, + "scoreDetails": true + } + }""" + ) + ) + } + + it should "render $score with each normalization type" in { + Seq( + (ScoreNormalization.NONE, "none"), + (ScoreNormalization.SIGMOID, "sigmoid"), + (ScoreNormalization.MIN_MAX_SCALER, "minMaxScaler") + ).foreach { + case (normalization, expected) => + toBson( + Aggregates.score("$rating", scoreOptions().normalization(normalization)) + ) should equal( + Document(s"""{ "$$score": { "score": "$$rating", "normalization": "$expected" } }""") + ) + } + } + it should "render $unset" in { toBson( Aggregates.unset("title", "author.first")