Skip to content

Commit d87d93a

Browse files
authored
Fluent API for $score (#2023)
Add builder support for the $score aggregation stage. JAVA-6202
1 parent 0956b9f commit d87d93a

9 files changed

Lines changed: 461 additions & 0 deletions

File tree

driver-core/src/main/com/mongodb/client/model/Aggregates.java

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@
5454
import static com.mongodb.assertions.Assertions.isTrueArgument;
5555
import static com.mongodb.assertions.Assertions.notNull;
5656
import static com.mongodb.client.model.GeoNearOptions.geoNearOptions;
57+
import static com.mongodb.client.model.ScoreOptions.scoreOptions;
5758
import static com.mongodb.client.model.densify.DensifyOptions.densifyOptions;
5859
import static com.mongodb.client.model.search.SearchOptions.searchOptions;
5960
import static com.mongodb.internal.Iterables.concat;
@@ -1093,6 +1094,68 @@ public static Bson rerank(
10931094
return new RerankBson(query, paths, numDocsToRerank, model);
10941095
}
10951096

1097+
/**
1098+
* Creates a {@code $score} pipeline stage that computes a new score for each document
1099+
* and attaches it as {@code score} metadata.
1100+
* You may use the {@code $meta: "score"} expression to extract the computed score.
1101+
*
1102+
* @param score the expression that computes the score. Must evaluate to a numeric value.
1103+
* @param <TExpression> the score expression type
1104+
* @return the {@code $score} pipeline stage
1105+
* @mongodb.driver.manual reference/operator/aggregation/score/ $score
1106+
* @mongodb.server.release 8.2
1107+
* @since 5.10
1108+
*/
1109+
public static <TExpression> Bson score(final TExpression score) {
1110+
return score(score, scoreOptions());
1111+
}
1112+
1113+
/**
1114+
* Creates a {@code $score} pipeline stage that computes a new score for each document
1115+
* and attaches it as {@code score} metadata, with optional normalization, weighting and score details.
1116+
* You may use the {@code $meta: "score"} expression to extract the computed score.
1117+
*
1118+
* @param score the expression that computes the score. Must evaluate to a numeric value.
1119+
* @param options optional {@code $score} pipeline stage fields
1120+
* @param <TExpression> the score expression type
1121+
* @return the {@code $score} pipeline stage
1122+
* @mongodb.driver.manual reference/operator/aggregation/score/ $score
1123+
* @mongodb.server.release 8.2
1124+
* @since 5.10
1125+
*/
1126+
public static <TExpression> Bson score(final TExpression score, final ScoreOptions options) {
1127+
notNull("score", score);
1128+
notNull("options", options);
1129+
return new Bson() {
1130+
@Override
1131+
public <TDocument> BsonDocument toBsonDocument(final Class<TDocument> documentClass, final CodecRegistry codecRegistry) {
1132+
BsonDocumentWriter writer = new BsonDocumentWriter(new BsonDocument());
1133+
writer.writeStartDocument();
1134+
writer.writeStartDocument("$score");
1135+
1136+
writer.writeName("score");
1137+
BuildersHelper.encodeValue(writer, score, codecRegistry);
1138+
1139+
options.toBsonDocument(documentClass, codecRegistry).forEach((optionName, optionValue) -> {
1140+
writer.writeName(optionName);
1141+
BuildersHelper.encodeValue(writer, optionValue, codecRegistry);
1142+
});
1143+
1144+
writer.writeEndDocument();
1145+
writer.writeEndDocument();
1146+
return writer.getDocument();
1147+
}
1148+
1149+
@Override
1150+
public String toString() {
1151+
return "Stage{name='$score'"
1152+
+ ", score=" + score
1153+
+ ", options=" + options
1154+
+ '}';
1155+
}
1156+
};
1157+
}
1158+
10961159
/**
10971160
* Creates an $unset pipeline stage that removes/excludes fields from documents
10981161
*
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
/*
2+
* Copyright 2008-present MongoDB, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.mongodb.client.model;
18+
19+
import com.mongodb.annotations.Immutable;
20+
import com.mongodb.internal.client.model.AbstractConstructibleBson;
21+
import org.bson.BsonDocument;
22+
import org.bson.Document;
23+
import org.bson.conversions.Bson;
24+
25+
import static com.mongodb.assertions.Assertions.isTrueArgument;
26+
import static com.mongodb.assertions.Assertions.notNull;
27+
28+
final class ScoreConstructibleBson extends AbstractConstructibleBson<ScoreConstructibleBson> implements ScoreOptions {
29+
/**
30+
* An {@linkplain Immutable immutable} {@link BsonDocument#isEmpty() empty} instance.
31+
*/
32+
static final ScoreOptions EMPTY_IMMUTABLE = new ScoreConstructibleBson(AbstractConstructibleBson.EMPTY_IMMUTABLE);
33+
34+
private ScoreConstructibleBson(final Bson base) {
35+
super(base);
36+
}
37+
38+
private ScoreConstructibleBson(final Bson base, final Document appended) {
39+
super(base, appended);
40+
}
41+
42+
@Override
43+
public ScoreOptions normalization(final ScoreNormalization normalization) {
44+
notNull("normalization", normalization);
45+
return newAppended("normalization", normalization.getValue());
46+
}
47+
48+
@Override
49+
public ScoreOptions weight(final double weight) {
50+
isTrueArgument("weight must be in the range [0, 1]", weight >= 0 && weight <= 1);
51+
return newAppended("weight", weight);
52+
}
53+
54+
@Override
55+
public ScoreOptions scoreDetails(final boolean scoreDetails) {
56+
return newAppended("scoreDetails", scoreDetails);
57+
}
58+
59+
@Override
60+
protected ScoreConstructibleBson newSelf(final Bson base, final Document appended) {
61+
return new ScoreConstructibleBson(base, appended);
62+
}
63+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
/*
2+
* Copyright 2008-present MongoDB, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.mongodb.client.model;
18+
19+
/**
20+
* Normalization methods for the {@link Aggregates#score(Object, ScoreOptions) $score}
21+
* and {@code $scoreFusion} pipeline stages.
22+
*
23+
* @mongodb.driver.manual reference/operator/aggregation/score/ $score
24+
* @mongodb.driver.manual reference/operator/aggregation/scoreFusion/ $scoreFusion
25+
* @mongodb.server.release 8.2
26+
* @since 5.10
27+
*/
28+
public enum ScoreNormalization {
29+
/**
30+
* No normalization is applied.
31+
*/
32+
NONE("none"),
33+
/**
34+
* Normalizes the score to the range (0, 1) by applying the sigmoid function.
35+
*/
36+
SIGMOID("sigmoid"),
37+
/**
38+
* Normalizes the score to the range [0, 1] by applying min-max scaling.
39+
*/
40+
MIN_MAX_SCALER("minMaxScaler");
41+
42+
private final String value;
43+
44+
ScoreNormalization(final String value) {
45+
this.value = value;
46+
}
47+
48+
/**
49+
* Returns the value as expected by the server.
50+
*
51+
* @return the server value
52+
*/
53+
public String getValue() {
54+
return value;
55+
}
56+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
/*
2+
* Copyright 2008-present MongoDB, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.mongodb.client.model;
18+
19+
import org.bson.conversions.Bson;
20+
21+
/**
22+
* The options for a {@link Aggregates#score(Object, ScoreOptions) $score} pipeline stage.
23+
*
24+
* @mongodb.driver.manual reference/operator/aggregation/score/ $score
25+
* @mongodb.server.release 8.2
26+
* @since 5.10
27+
*/
28+
public interface ScoreOptions extends Bson {
29+
/**
30+
* Returns {@link ScoreOptions} that represents server defaults.
31+
*
32+
* @return {@link ScoreOptions} that represents server defaults.
33+
*/
34+
static ScoreOptions scoreOptions() {
35+
return ScoreConstructibleBson.EMPTY_IMMUTABLE;
36+
}
37+
38+
/**
39+
* The method used to normalize the score to the range [0, 1].
40+
* If this option is not provided, the server default is {@link ScoreNormalization#NONE}.
41+
*
42+
* @param normalization the normalization method
43+
* @return a new {@link ScoreOptions} with the provided option set
44+
* @since 5.10
45+
*/
46+
ScoreOptions normalization(ScoreNormalization normalization);
47+
48+
/**
49+
* The factor to multiply the score by after normalization.
50+
* Must be in the range [0, 1].
51+
*
52+
* @param weight the weight
53+
* @return a new {@link ScoreOptions} with the provided option set
54+
* @throws IllegalArgumentException if the weight is not in the range [0, 1]
55+
* @mongodb.driver.manual reference/operator/aggregation/score/ $score
56+
* @since 5.10
57+
*/
58+
ScoreOptions weight(double weight);
59+
60+
/**
61+
* Specifies whether to populate the {@code scoreDetails} metadata field,
62+
* which contains details on how the score was computed.
63+
* If this option is not provided, the server default is {@code false}.
64+
*
65+
* @param scoreDetails whether to populate the {@code scoreDetails} metadata field
66+
* @return a new {@link ScoreOptions} with the provided option set
67+
* @since 5.10
68+
*/
69+
ScoreOptions scoreDetails(boolean scoreDetails);
70+
}

driver-core/src/test/functional/com/mongodb/client/model/AggregatesTest.java

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
import org.junit.jupiter.api.Test;
3131
import org.junit.jupiter.params.ParameterizedTest;
3232
import org.junit.jupiter.params.provider.Arguments;
33+
import org.junit.jupiter.params.provider.EnumSource;
3334
import org.junit.jupiter.params.provider.MethodSource;
3435

3536
import java.math.RoundingMode;
@@ -43,10 +44,12 @@
4344
import static com.mongodb.client.model.Aggregates.geoNear;
4445
import static com.mongodb.client.model.Aggregates.group;
4546
import static com.mongodb.client.model.Aggregates.rerank;
47+
import static com.mongodb.client.model.Aggregates.score;
4648
import static com.mongodb.client.model.Aggregates.unset;
4749
import static com.mongodb.client.model.Aggregates.vectorSearch;
4850
import static com.mongodb.client.model.RerankQuery.rerankQuery;
4951
import static com.mongodb.client.model.GeoNearOptions.geoNearOptions;
52+
import static com.mongodb.client.model.ScoreOptions.scoreOptions;
5053
import static com.mongodb.client.model.Sorts.ascending;
5154
import static com.mongodb.client.model.Windows.Bound.UNBOUNDED;
5255
import static com.mongodb.client.model.Windows.documents;
@@ -57,6 +60,7 @@
5760
import static org.hamcrest.MatcherAssert.assertThat;
5861
import static org.hamcrest.Matchers.hasSize;
5962
import static org.junit.jupiter.api.Assertions.assertEquals;
63+
import static org.junit.jupiter.api.Assertions.assertThrows;
6064
import static org.junit.jupiter.api.Assumptions.assumeTrue;
6165

6266
public class AggregatesTest extends OperationTest {
@@ -453,4 +457,84 @@ public void testRerankWithMultiplePathsAndBsonQuery() {
453457
"rerank-2"
454458
));
455459
}
460+
461+
@Test
462+
public void testScoreWithExpression() {
463+
assertPipeline(
464+
"{'$score': {'score': {'$multiply': ['$rating', 2]}}}",
465+
score(new Document("$multiply", asList("$rating", 2))));
466+
}
467+
468+
@Test
469+
public void testScoreWithAllOptions() {
470+
assertPipeline(
471+
"{"
472+
+ " '$score': {"
473+
+ " 'score': '$rating',"
474+
+ " 'normalization': 'sigmoid',"
475+
+ " 'weight': 0.5,"
476+
+ " 'scoreDetails': true"
477+
+ " }"
478+
+ "}",
479+
score("$rating", scoreOptions()
480+
.normalization(ScoreNormalization.SIGMOID)
481+
.weight(0.5)
482+
.scoreDetails(true)));
483+
}
484+
485+
@ParameterizedTest
486+
@EnumSource(ScoreNormalization.class)
487+
public void testScoreWithEachNormalization(final ScoreNormalization normalization) {
488+
assertPipeline(
489+
"{'$score': {'score': '$rating', 'normalization': '" + normalization.getValue() + "'}}",
490+
score("$rating", scoreOptions().normalization(normalization)));
491+
}
492+
493+
@Test
494+
public void testScoreWeightValidation() {
495+
assertThrows(IllegalArgumentException.class, () -> scoreOptions().weight(-0.1));
496+
assertThrows(IllegalArgumentException.class, () -> scoreOptions().weight(1.1));
497+
assertThrows(IllegalArgumentException.class, () -> scoreOptions().weight(Double.NaN));
498+
assertPipeline(
499+
"{'$score': {'score': '$rating', 'weight': 0.0}}",
500+
score("$rating", scoreOptions().weight(0)));
501+
assertPipeline(
502+
"{'$score': {'score': '$rating', 'weight': 1.0}}",
503+
score("$rating", scoreOptions().weight(1)));
504+
}
505+
506+
@Test
507+
public void testScore() {
508+
assumeTrue(serverVersionAtLeast(8, 2));
509+
getCollectionHelper().insertDocuments("[{_id: 1, rating: 2}, {_id: 2, rating: 4}]");
510+
511+
List<Bson> pipeline = asList(
512+
score(new Document("$multiply", asList("$rating", 2)),
513+
scoreOptions().normalization(ScoreNormalization.SIGMOID)),
514+
Aggregates.sort(ascending("_id")),
515+
Aggregates.project(Projections.computed("score", new Document("$meta", "score"))));
516+
517+
List<BsonDocument> results = getCollectionHelper().aggregate(pipeline);
518+
assertEquals(2, results.size());
519+
// sigmoid normalization maps each score into the range (0, 1)
520+
results.forEach(result -> {
521+
double scoreValue = result.getNumber("score").doubleValue();
522+
Assertions.assertTrue(scoreValue > 0 && scoreValue < 1);
523+
});
524+
}
525+
526+
@ParameterizedTest
527+
@EnumSource(ScoreNormalization.class)
528+
public void testScoreOnServerWithEachNormalization(final ScoreNormalization normalization) {
529+
assumeTrue(serverVersionAtLeast(8, 2));
530+
getCollectionHelper().insertDocuments("[{_id: 1, rating: 2}, {_id: 2, rating: 4}]");
531+
532+
List<Bson> pipeline = asList(
533+
score("$rating", scoreOptions().normalization(normalization)),
534+
Aggregates.project(Projections.computed("score", new Document("$meta", "score"))));
535+
536+
List<BsonDocument> results = getCollectionHelper().aggregate(pipeline);
537+
assertEquals(2, results.size());
538+
results.forEach(result -> Assertions.assertTrue(result.isNumber("score")));
539+
}
456540
}

0 commit comments

Comments
 (0)