DATAMONGO-2363 - Polishing.

Introduce support for SpEL aggregation expressions for AddFields and Set operations.

Rearrange methods. Make fields final where possible.

Original pull request: #801.
This commit is contained in:
Mark Paluch
2020-03-09 14:08:28 +01:00
parent a4e12a96c9
commit c829387c82
9 changed files with 202 additions and 83 deletions

View File

@@ -151,6 +151,13 @@ public class AddFieldsOperation extends DocumentEnhancingOperation {
valueMap.put(field, value instanceof String ? Fields.fields((String) value) : value);
return AddFieldsOperationBuilder.this;
}
@Override
public AddFieldsOperationBuilder withValueOfExpression(String operation, Object... values) {
valueMap.put(field, new ExpressionProjection(operation, values));
return AddFieldsOperationBuilder.this;
}
};
}
@@ -179,7 +186,15 @@ public class AddFieldsOperation extends DocumentEnhancingOperation {
* @return new instance of {@link AddFieldsOperation}.
*/
AddFieldsOperationBuilder withValueOf(Object value);
}
/**
* Adds a generic projection for the current field.
*
* @param operation the operation key, e.g. {@code $add}.
* @param values the values to be set for the projection operation.
* @return new instance of {@link AddFieldsOperation}.
*/
AddFieldsOperationBuilder withValueOfExpression(String operation, Object... values);
}
}
}

View File

@@ -119,7 +119,7 @@ public class Aggregation {
/**
* Creates a new {@link AggregationUpdate} from the given {@link AggregationOperation}s.
*
*
* @param operations can be {@literal empty} but must not be {@literal null}.
* @return new instance of {@link AggregationUpdate}.
* @since 3.0
@@ -202,11 +202,16 @@ public class Aggregation {
Assert.notNull(aggregationOperations, "AggregationOperations must not be null!");
Assert.notNull(options, "AggregationOptions must not be null!");
// check $out is the last operation if it exists
// check $out/$merge is the last operation if it exists
for (AggregationOperation aggregationOperation : aggregationOperations) {
if (aggregationOperation instanceof OutOperation && !isLast(aggregationOperation, aggregationOperations)) {
throw new IllegalArgumentException("The $out operator must be the last stage in the pipeline.");
}
if (aggregationOperation instanceof MergeOperation && !isLast(aggregationOperation, aggregationOperations)) {
throw new IllegalArgumentException("The $merge operator must be the last stage in the pipeline.");
}
}
this.operations = aggregationOperations;
@@ -236,6 +241,20 @@ public class Aggregation {
return "_id";
}
/**
* Obtain an {@link AddFieldsOperationBuilder builder} instance to create a new {@link AddFieldsOperation}.
* <p/>
* Starting in version 4.2, MongoDB adds a new aggregation pipeline stage {@link AggregationUpdate#set $set} that is
* an alias for {@code $addFields}.
*
* @return new instance of {@link AddFieldsOperationBuilder}.
* @see AddFieldsOperation
* @since 3.0
*/
public static AddFieldsOperationBuilder addFields() {
return AddFieldsOperation.builder();
}
/**
* Creates a new {@link ProjectionOperation} including the given fields.
*
@@ -495,6 +514,30 @@ public class Aggregation {
return new MatchOperation(criteria);
}
/**
* Creates a new {@link GeoNearOperation} instance from the given {@link NearQuery} and the {@code distanceField}. The
* {@code distanceField} defines output field that contains the calculated distance.
*
* @param query must not be {@literal null}.
* @param distanceField must not be {@literal null} or empty.
* @return
* @since 1.7
*/
public static GeoNearOperation geoNear(NearQuery query, String distanceField) {
return new GeoNearOperation(query, distanceField);
}
/**
* Obtain a {@link MergeOperationBuilder builder} instance to create a new {@link MergeOperation}.
*
* @return new instance of {@link MergeOperationBuilder}.
* @see MergeOperation
* @since 3.0
*/
public static MergeOperationBuilder merge() {
return MergeOperation.builder();
}
/**
* Creates a new {@link OutOperation} using the given collection name. This operation must be the last operation in
* the pipeline.
@@ -636,41 +679,6 @@ public class Aggregation {
return Fields.from(field(name, target));
}
/**
* Creates a new {@link GeoNearOperation} instance from the given {@link NearQuery} and the {@code distanceField}. The
* {@code distanceField} defines output field that contains the calculated distance.
*
* @param query must not be {@literal null}.
* @param distanceField must not be {@literal null} or empty.
* @return
* @since 1.7
*/
public static GeoNearOperation geoNear(NearQuery query, String distanceField) {
return new GeoNearOperation(query, distanceField);
}
/**
* Obtain a {@link MergeOperationBuilder builder} instance to create a new {@link MergeOperation}.
*
* @return new instance of {@link MergeOperationBuilder}.
* @see MergeOperation
* @since 3.0
*/
public static MergeOperationBuilder merge() {
return MergeOperation.builder();
}
/**
* Obtain an {@link AddFieldsOperationBuilder builder} instance to create a new {@link AddFieldsOperation}.
*
* @return new instance of {@link AddFieldsOperationBuilder}.
* @see AddFieldsOperation
* @since 3.0
*/
public static AddFieldsOperationBuilder addFields() {
return AddFieldsOperation.builder();
}
/**
* Returns a new {@link AggregationOptions.Builder}.
*

View File

@@ -79,7 +79,7 @@ import org.springframework.util.Assert;
public class AggregationUpdate extends Aggregation implements UpdateDefinition {
private boolean isolated = false;
private Set<String> keysTouched = new HashSet<>();
private final Set<String> keysTouched = new HashSet<>();
/**
* Create new {@link AggregationUpdate}.

View File

@@ -24,16 +24,18 @@ import java.util.stream.Collectors;
import org.bson.Document;
import org.springframework.data.mongodb.core.aggregation.ExposedFields.ExposedField;
import org.springframework.data.mongodb.core.aggregation.FieldsExposingAggregationOperation.InheritsFieldsAggregationOperation;
import org.springframework.util.Assert;
/**
* Base class for common taks required by {@link SetOperation} and {@link AddFieldsOperation}.
* Base class for common tasks required by {@link SetOperation} and {@link AddFieldsOperation}.
*
* @author Christoph Strobl
* @since 3.0
*/
abstract class DocumentEnhancingOperation implements InheritsFieldsAggregationOperation {
private Map<Object, Object> valueMap;
private final Map<Object, Object> valueMap;
private ExposedFields exposedFields = ExposedFields.empty();
protected DocumentEnhancingOperation(Map<Object, Object> source) {
@@ -112,14 +114,53 @@ abstract class DocumentEnhancingOperation implements InheritsFieldsAggregationOp
if (value instanceof Field) {
return context.getReference((Field) value).toString();
}
if (value instanceof ExpressionProjection) {
return ((ExpressionProjection) value).toExpression(context);
}
if (value instanceof AggregationExpression) {
return ((AggregationExpression) value).toDocument(context);
}
if (value instanceof Collection) {
return ((Collection) value).stream().map(it -> computeValue(it, context)).collect(Collectors.toList());
return ((Collection<?>) value).stream().map(it -> computeValue(it, context)).collect(Collectors.toList());
}
return value;
}
/**
* A {@link AggregationExpression} based on a SpEL expression.
*
* @author Mark Paluch
*/
static class ExpressionProjection {
private static final SpelExpressionTransformer TRANSFORMER = new SpelExpressionTransformer();
private final String expression;
private final Object[] params;
/**
* Creates a new {@link ProjectionOperation.ExpressionProjectionOperationBuilder.ExpressionProjection} for the given
* field, SpEL expression and parameters.
*
* @param expression must not be {@literal null} or empty.
* @param parameters must not be {@literal null}.
*/
ExpressionProjection(String expression, Object[] parameters) {
Assert.notNull(expression, "Expression must not be null!");
Assert.notNull(parameters, "Parameters must not be null!");
this.expression = expression;
this.params = parameters.clone();
}
Object toExpression(AggregationOperationContext context) {
return TRANSFORMER.transform(expression, context, params);
}
}
}

View File

@@ -33,8 +33,8 @@ import org.springframework.util.StringUtils;
/**
* Encapsulates the {@code $merge}-operation.
* <p>
* We recommend to use the {@link MergeOperationBuilder builder} via {@link MergeOperation#builder()} instead of creating
* instances of this class directly.
* We recommend to use the {@link MergeOperationBuilder builder} via {@link MergeOperation#builder()} instead of
* creating instances of this class directly.
*
* @see <a href="https://docs.mongodb.com/manual/reference/operator/aggregation/merge/">MongoDB Documentation</a>
* @author Christoph Strobl
@@ -42,15 +42,15 @@ import org.springframework.util.StringUtils;
*/
public class MergeOperation implements FieldsExposingAggregationOperation, InheritsFieldsAggregationOperation {
private MergeOperationTarget into;
private UniqueMergeId on;
private @Nullable Let let;
private @Nullable WhenDocumentsMatch whenMatched;
private @Nullable WhenDocumentsDontMatch whenNotMatched;
private final MergeOperationTarget into;
private final UniqueMergeId on;
private final @Nullable Let let;
private final @Nullable WhenDocumentsMatch whenMatched;
private final @Nullable WhenDocumentsDontMatch whenNotMatched;
/**
* Create new instance of {@link MergeOperation}.
*
*
* @param into the target (collection and database)
* @param on the unique identifier. Can be {@literal null}.
* @param let exposed variables for {@link WhenDocumentsMatch#updateWith(Aggregation)}. Can be {@literal null}.
@@ -63,7 +63,7 @@ public class MergeOperation implements FieldsExposingAggregationOperation, Inher
@Nullable WhenDocumentsMatch whenMatched, @Nullable WhenDocumentsDontMatch whenNotMatched) {
Assert.notNull(into, "Into must not be null! Please provide a target collection.");
Assert.notNull(on, "On must not be null! Use Collections.emptySet() instead.");
Assert.notNull(on, "On must not be null! Use UniqueMergeId.id() instead.");
this.into = into;
this.on = on;
@@ -93,7 +93,7 @@ public class MergeOperation implements FieldsExposingAggregationOperation, Inher
}
/*
* (non-Javadoc)
* (non-Javadoc)
* @see org.springframework.data.mongodb.core.aggregation.Aggregation#toDocument(org.springframework.data.mongodb.core.aggregation.AggregationOperationContext)
*/
@Override
@@ -109,15 +109,17 @@ public class MergeOperation implements FieldsExposingAggregationOperation, Inher
if (!on.isJustIdField()) {
$merge.putAll(on.toDocument(context));
}
if (let != null) {
$merge.append("let", let.toDocument(context).get("$let", Document.class).get("vars"));
}
if (whenMatched != null) {
$merge.putAll(whenMatched.toDocument(context));
}
if (whenNotMatched != null) {
$merge.putAll(whenNotMatched.toDocument(context));
}
return new Document("$merge", $merge);
@@ -159,13 +161,12 @@ public class MergeOperation implements FieldsExposingAggregationOperation, Inher
* collection.
*
* @author Christoph Strobl
* @since 2.3
*/
public static class UniqueMergeId {
private static final UniqueMergeId ID = new UniqueMergeId(Collections.emptyList());
private Collection<String> uniqueIdentifier;
private final Collection<String> uniqueIdentifier;
private UniqueMergeId(Collection<String> uniqueIdentifier) {
this.uniqueIdentifier = uniqueIdentifier;
@@ -173,6 +174,8 @@ public class MergeOperation implements FieldsExposingAggregationOperation, Inher
public static UniqueMergeId ofIdFields(String... fields) {
Assert.noNullElements(fields, "Fields must not contain null values!");
if (ObjectUtils.isEmpty(fields)) {
return id();
}
@@ -182,7 +185,7 @@ public class MergeOperation implements FieldsExposingAggregationOperation, Inher
/**
* Merge Documents by using the MongoDB {@literal _id} field.
*
*
* @return
*/
public static UniqueMergeId id() {
@@ -206,19 +209,19 @@ public class MergeOperation implements FieldsExposingAggregationOperation, Inher
* If not stated explicitly via {@link MergeOperationTarget#inDatabase(String)} the {@literal collection} is created
* in the very same {@literal database}. In this case {@code into} is just a single String holding the collection
* name. <br />
*
*
* <pre class="code">
* into: "target-collection-name"
* </pre>
*
*
* If the collection needs to be in a different database {@code into} will be a {@link Document} like the following
*
*
* <pre class="code">
* {
* into: {}
* }
* </pre>
*
*
* @author Christoph Strobl
* @since 2.3
*/
@@ -267,7 +270,7 @@ public class MergeOperation implements FieldsExposingAggregationOperation, Inher
/**
* Value Object specifying how to deal with a result document that matches an existing document in the collection
* based on the fields of the {@code on} property describing the unique identifier.
*
*
* @author Christoph Strobl
* @since 2.3
*/
@@ -354,7 +357,7 @@ public class MergeOperation implements FieldsExposingAggregationOperation, Inher
/**
* Value Object specifying how to deal with a result document that do not match an existing document in the collection
* based on the fields of the {@code on} property describing the unique identifier.
*
*
* @author Christoph Strobl
* @since 2.3
*/
@@ -363,9 +366,18 @@ public class MergeOperation implements FieldsExposingAggregationOperation, Inher
private final String value;
private WhenDocumentsDontMatch(String value) {
Assert.notNull(value, "Value must not be null!");
this.value = value;
}
/**
* Factory method creating {@link WhenDocumentsDontMatch} from a {@code value} literal.
*
* @param value
* @return new instance of {@link WhenDocumentsDontMatch}.
*/
public static WhenDocumentsDontMatch whenNotMatchedOf(String value) {
return new WhenDocumentsDontMatch(value);
}
@@ -412,7 +424,7 @@ public class MergeOperation implements FieldsExposingAggregationOperation, Inher
private String collection;
private @Nullable String database;
private @Nullable UniqueMergeId id;
private UniqueMergeId id = UniqueMergeId.id();
private @Nullable Let let;
private @Nullable WhenDocumentsMatch whenMatched;
private @Nullable WhenDocumentsDontMatch whenNotMatched;
@@ -447,7 +459,7 @@ public class MergeOperation implements FieldsExposingAggregationOperation, Inher
/**
* Define the target to store results in.
*
*
* @param into must not be {@literal null}.
* @return this.
*/
@@ -484,7 +496,7 @@ public class MergeOperation implements FieldsExposingAggregationOperation, Inher
/**
* Set the identifier that determines if a results document matches an already existing document in the output
* collection.
*
*
* @param id must not be {@literal null}.
* @return this.
*/
@@ -497,7 +509,7 @@ public class MergeOperation implements FieldsExposingAggregationOperation, Inher
/**
* Expose the variables defined by {@link Let} to the {@link WhenDocumentsMatch#updateWith(Aggregation) update
* aggregation}.
*
*
* @param let the variable expressions
* @return this.
*/
@@ -520,7 +532,7 @@ public class MergeOperation implements FieldsExposingAggregationOperation, Inher
/**
* The action to take place when documents already exist in the target collection.
*
*
* @param whenMatched must not be {@literal null}.
* @return this.
*/
@@ -576,8 +588,7 @@ public class MergeOperation implements FieldsExposingAggregationOperation, Inher
* @return new instance of {@link MergeOperation}.
*/
public MergeOperation build() {
return new MergeOperation(new MergeOperationTarget(database, collection), id != null ? id : UniqueMergeId.id(),
let, whenMatched, whenNotMatched);
return new MergeOperation(new MergeOperationTarget(database, collection), id, let, whenMatched, whenNotMatched);
}
}
}

View File

@@ -25,7 +25,7 @@ import org.springframework.lang.Nullable;
/**
* Adds new fields to documents. {@code $set} outputs documents that contain all existing fields from the input
* documents and newly added fields.
*
*
* <pre class="code">
* SetOperation.set("totalHomework").toValue("A+").and().set("totalQuiz").toValue("B-")
* </pre>
@@ -143,6 +143,13 @@ public class SetOperation extends DocumentEnhancingOperation {
valueMap.put(field, value instanceof String ? Fields.fields((String) value) : value);
return FieldAppender.this.build();
}
@Override
public SetOperation withValueOfExpression(String operation, Object... values) {
valueMap.put(field, new ExpressionProjection(operation, values));
return FieldAppender.this.build();
}
};
}
@@ -152,6 +159,7 @@ public class SetOperation extends DocumentEnhancingOperation {
/**
* @author Christoph Strobl
* @author Mark Paluch
* @since 3.0
*/
public interface ValueAppender {
@@ -171,6 +179,15 @@ public class SetOperation extends DocumentEnhancingOperation {
* @return new instance of {@link SetOperation}.
*/
SetOperation toValueOf(Object value);
/**
* Adds a generic projection for the current field.
*
* @param operation the operation key, e.g. {@code $add}.
* @param values the values to be set for the projection operation.
* @return new instance of {@link SetOperation}.
*/
SetOperation withValueOfExpression(String operation, Object... values);
}
}
}

View File

@@ -29,7 +29,10 @@ import org.springframework.data.mongodb.core.mapping.MongoMappingContext;
import org.springframework.lang.Nullable;
/**
* Unit tests for {@link AddFieldsOperation}.
*
* @author Christoph Strobl
* @author Mark Paluch
*/
class AddFieldsOperationUnitTests {
@@ -92,6 +95,17 @@ class AddFieldsOperationUnitTests {
"{\"$addFields\" : {\"scoresWithMappedField.student_name\":\"$scoresWithMappedField.home_work\"}}"));
}
@Test // DATAMONGO-2363
void appliesSpelExpressionCorrectly() {
AddFieldsOperation operation = AddFieldsOperation.builder().addField("totalHomework")
.withValueOfExpression("sum(homework) * [0]", 2) //
.build();
assertThat(operation.toPipelineStages(contextFor(ScoresWrapper.class))).contains(
Document.parse("{\"$addFields\" : {\"totalHomework\": { $multiply : [{ \"$sum\" : [\"$homework\"] }, 2] }}}"));
}
@Test // DATAMONGO-2363
void rendersTargetValueExpressionCorrectly() {
@@ -105,10 +119,11 @@ class AddFieldsOperationUnitTests {
ExposedFields fields = AddFieldsOperation.builder().addField("totalHomework").withValue("A+") //
.addField("totalQuiz").withValue("B-") //
.build().getFields();
.addField("computed").withValueOfExpression("totalHomework").build().getFields();
assertThat(fields.getField("totalHomework")).isNotNull();
assertThat(fields.getField("totalQuiz")).isNotNull();
assertThat(fields.getField("computed")).isNotNull();
assertThat(fields.getField("does-not-exist")).isNull();
}

View File

@@ -33,6 +33,8 @@ import org.springframework.data.mongodb.core.mapping.MongoMappingContext;
import org.springframework.lang.Nullable;
/**
* Unit tests for {@link MergeOperation}.
*
* @author Christoph Strobl
*/
class MergeOperationUnitTests {
@@ -98,7 +100,7 @@ class MergeOperationUnitTests {
}
@Test // DATAMONGO-2363
public void mapsFieldNames() {
void mapsFieldNames() {
assertThat(merge().intoCollection("newrestaurants").on("date", "postCode").build()
.toDocument(contextFor(Restaurant.class))).isEqualTo(

View File

@@ -32,30 +32,31 @@ import org.springframework.lang.Nullable;
* Unit tests for {@link SetOperation}.
*
* @author Christoph Strobl
* @author Mark Paluch
*/
public class SetOperationUnitTests {
class SetOperationUnitTests {
@Test // DATAMONGO-2331
public void raisesErrorOnNullField() {
void raisesErrorOnNullField() {
assertThatExceptionOfType(IllegalArgumentException.class).isThrownBy(() -> new SetOperation(null, "value"));
}
@Test // DATAMONGO-2331
public void rendersFieldReferenceCorrectly() {
void rendersFieldReferenceCorrectly() {
assertThat(new SetOperation("name", "value").toPipelineStages(contextFor(Scores.class)))
.containsExactly(Document.parse("{\"$set\" : {\"name\":\"value\"}}"));
}
@Test // DATAMONGO-2331
public void rendersMappedFieldReferenceCorrectly() {
void rendersMappedFieldReferenceCorrectly() {
assertThat(new SetOperation("student", "value").toPipelineStages(contextFor(ScoresWithMappedField.class)))
.containsExactly(Document.parse("{\"$set\" : {\"student_name\":\"value\"}}"));
}
@Test // DATAMONGO-2331
public void rendersNestedMappedFieldReferenceCorrectly() {
void rendersNestedMappedFieldReferenceCorrectly() {
assertThat(
new SetOperation("scoresWithMappedField.student", "value").toPipelineStages(contextFor(ScoresWrapper.class)))
@@ -63,14 +64,14 @@ public class SetOperationUnitTests {
}
@Test // DATAMONGO-2331
public void rendersTargetValueFieldReferenceCorrectly() {
void rendersTargetValueFieldReferenceCorrectly() {
assertThat(new SetOperation("name", Fields.field("value")).toPipelineStages(contextFor(Scores.class)))
.containsExactly(Document.parse("{\"$set\" : {\"name\":\"$value\"}}"));
}
@Test // DATAMONGO-2331
public void rendersMappedTargetValueFieldReferenceCorrectly() {
void rendersMappedTargetValueFieldReferenceCorrectly() {
assertThat(
new SetOperation("student", Fields.field("homework")).toPipelineStages(contextFor(ScoresWithMappedField.class)))
@@ -78,7 +79,7 @@ public class SetOperationUnitTests {
}
@Test // DATAMONGO-2331
public void rendersNestedMappedTargetValueFieldReferenceCorrectly() {
void rendersNestedMappedTargetValueFieldReferenceCorrectly() {
assertThat(new SetOperation("scoresWithMappedField.student", Fields.field("scoresWithMappedField.homework"))
.toPipelineStages(contextFor(ScoresWrapper.class)))
@@ -86,8 +87,18 @@ public class SetOperationUnitTests {
.parse("{\"$set\" : {\"scoresWithMappedField.student_name\":\"$scoresWithMappedField.home_work\"}}"));
}
@Test // DATAMONGO-2363
void appliesSpelExpressionCorrectly() {
SetOperation operation = SetOperation.builder().set("totalHomework").withValueOfExpression("sum(homework) * [0]",
2);
assertThat(operation.toPipelineStages(contextFor(AddFieldsOperationUnitTests.ScoresWrapper.class))).contains(
Document.parse("{\"$set\" : {\"totalHomework\": { $multiply : [{ \"$sum\" : [\"$homework\"] }, 2] }}}"));
}
@Test // DATAMONGO-2331
public void rendersTargetValueExpressionCorrectly() {
void rendersTargetValueExpressionCorrectly() {
assertThat(SetOperation.builder().set("totalHomework").toValueOf(ArithmeticOperators.valueOf("homework").sum())
.toPipelineStages(contextFor(Scores.class)))
@@ -95,7 +106,7 @@ public class SetOperationUnitTests {
}
@Test // DATAMONGO-2331
public void exposesFieldsCorrectly() {
void exposesFieldsCorrectly() {
ExposedFields fields = SetOperation.builder().set("totalHomework").toValue("A+") //
.and() //
@@ -138,5 +149,4 @@ public class SetOperationUnitTests {
Scores scores;
ScoresWithMappedField scoresWithMappedField;
}
}