entry, AggregationOperationContext context) {
+
+ String field = entry.getKey() instanceof String ? context.getReference((String) entry.getKey()).getRaw()
+ : context.getReference((Field) entry.getKey()).getRaw();
+
+ Object value = computeValue(entry.getValue(), context);
+
+ return new Document(field, value);
+ }
+
+ private static Object computeValue(Object value, AggregationOperationContext context) {
+
+ if (value instanceof Field) {
+ return context.getReference((Field) value).toString();
+ }
+ 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 value;
+ }
+
+}
diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/MergeOperation.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/MergeOperation.java
new file mode 100644
index 000000000..188f9d72d
--- /dev/null
+++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/MergeOperation.java
@@ -0,0 +1,583 @@
+/*
+ * Copyright 2020 the original author or authors.
+ *
+ * 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
+ *
+ * https://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.springframework.data.mongodb.core.aggregation;
+
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+import java.util.stream.Collectors;
+
+import org.bson.Document;
+import org.springframework.data.mongodb.core.aggregation.ExposedFields.FieldReference;
+import org.springframework.data.mongodb.core.aggregation.FieldsExposingAggregationOperation.InheritsFieldsAggregationOperation;
+import org.springframework.data.mongodb.core.aggregation.VariableOperators.Let;
+import org.springframework.lang.Nullable;
+import org.springframework.util.Assert;
+import org.springframework.util.ObjectUtils;
+import org.springframework.util.StringUtils;
+
+/**
+ * Encapsulates the {@code $merge}-operation.
+ *
+ * We recommend to use the {@link MergeOperationBuilder builder} via {@link MergeOperation#builder()} instead of creating
+ * instances of this class directly.
+ *
+ * @see MongoDB Documentation
+ * @author Christoph Strobl
+ * @since 3.0
+ */
+public class MergeOperation implements FieldsExposingAggregationOperation, InheritsFieldsAggregationOperation {
+
+ private MergeOperationTarget into;
+ private UniqueMergeId on;
+ private @Nullable Let let;
+ private @Nullable WhenDocumentsMatch whenMatched;
+ private @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}.
+ * @param whenMatched behavior if a result document matches an existing one in the target collection. Can be
+ * {@literal null}.
+ * @param whenNotMatched behavior if a result document does not match an existing one in the target collection. Can be
+ * {@literal null}.
+ */
+ public MergeOperation(MergeOperationTarget into, UniqueMergeId on, @Nullable Let let,
+ @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.");
+
+ this.into = into;
+ this.on = on;
+ this.let = let;
+ this.whenMatched = whenMatched;
+ this.whenNotMatched = whenNotMatched;
+ }
+
+ /**
+ * Simplified form to apply all default options for {@code $merge} (including writing to a collection in the same
+ * database).
+ *
+ * @param collection the output collection within the same database.
+ * @return new instance of {@link MergeOperation}.
+ */
+ public static MergeOperation mergeInto(String collection) {
+ return builder().intoCollection(collection).build();
+ }
+
+ /**
+ * Access the {@link MergeOperationBuilder builder API} to create a new instance of {@link MergeOperation}.
+ *
+ * @return new instance of {@link MergeOperationBuilder}.
+ */
+ public static MergeOperationBuilder builder() {
+ return new MergeOperationBuilder();
+ }
+
+ /*
+ * (non-Javadoc)
+ * @see org.springframework.data.mongodb.core.aggregation.Aggregation#toDocument(org.springframework.data.mongodb.core.aggregation.AggregationOperationContext)
+ */
+ @Override
+ public Document toDocument(AggregationOperationContext context) {
+
+ if (isJustCollection()) {
+ return new Document("$merge", into.collection);
+ }
+
+ Document $merge = new Document();
+ $merge.putAll(into.toDocument(context));
+
+ 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);
+ }
+
+ /*
+ * (non-Javadoc)
+ * @see org.springframework.data.mongodb.core.aggregation.FieldsExposingAggregationOperation#getFields()
+ */
+ @Override
+ public ExposedFields getFields() {
+
+ if (let == null) {
+ return ExposedFields.from();
+ }
+
+ return ExposedFields.synthetic(Fields.fields(let.getVariableNames()));
+ }
+
+ /*
+ * (non-Javadoc)
+ * @see org.springframework.data.mongodb.core.aggregation.FieldsExposingAggregationOperation.InheritsFieldsAggregationOperation#inheritsFields()
+ */
+ @Override
+ public boolean inheritsFields() {
+ return true;
+ }
+
+ /**
+ * @return true if nothing more than the collection is specified.
+ */
+ private boolean isJustCollection() {
+ return into.isTargetingSameDatabase() && on.isJustIdField() && let == null && whenMatched == null
+ && whenNotMatched == null;
+ }
+
+ /**
+ * Value object representing the unique id used during the merge operation to identify duplicates in the target
+ * collection.
+ *
+ * @author Christoph Strobl
+ * @since 2.3
+ */
+ public static class UniqueMergeId {
+
+ private static final UniqueMergeId ID = new UniqueMergeId(Collections.emptyList());
+
+ private Collection uniqueIdentifier;
+
+ private UniqueMergeId(Collection uniqueIdentifier) {
+ this.uniqueIdentifier = uniqueIdentifier;
+ }
+
+ public static UniqueMergeId ofIdFields(String... fields) {
+
+ if (ObjectUtils.isEmpty(fields)) {
+ return id();
+ }
+
+ return new UniqueMergeId(Arrays.asList(fields));
+ }
+
+ /**
+ * Merge Documents by using the MongoDB {@literal _id} field.
+ *
+ * @return
+ */
+ public static UniqueMergeId id() {
+ return ID;
+ }
+
+ boolean isJustIdField() {
+ return this.equals(ID);
+ }
+
+ Document toDocument(AggregationOperationContext context) {
+
+ List mappedOn = uniqueIdentifier.stream().map(context::getReference).map(FieldReference::getRaw)
+ .collect(Collectors.toList());
+ return new Document("on", mappedOn.size() == 1 ? mappedOn.iterator().next() : mappedOn);
+ }
+ }
+
+ /**
+ * Value Object representing the {@code into} field of a {@code $merge} aggregation stage.
+ * 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.
+ *
+ *
+ * into: "target-collection-name"
+ *
+ *
+ * If the collection needs to be in a different database {@code into} will be a {@link Document} like the following
+ *
+ *
+ * {
+ * into: {}
+ * }
+ *
+ *
+ * @author Christoph Strobl
+ * @since 2.3
+ */
+ public static class MergeOperationTarget {
+
+ private final @Nullable String database;
+ private final String collection;
+
+ private MergeOperationTarget(@Nullable String database, String collection) {
+
+ Assert.hasText(collection, "Collection must not be null nor empty!");
+
+ this.database = database;
+ this.collection = collection;
+ }
+
+ /**
+ * @param collection The output collection results will be stored in. Must not be {@literal null}.
+ * @return new instance of {@link MergeOperationTarget}.
+ */
+ public static MergeOperationTarget collection(String collection) {
+ return new MergeOperationTarget(null, collection);
+ }
+
+ /**
+ * Optionally specify the target database if different from the source one.
+ *
+ * @param database must not be {@literal null}.
+ * @return new instance of {@link MergeOperationTarget}.
+ */
+ public MergeOperationTarget inDatabase(String database) {
+ return new MergeOperationTarget(database, collection);
+ }
+
+ boolean isTargetingSameDatabase() {
+ return !StringUtils.hasText(database);
+ }
+
+ Document toDocument(AggregationOperationContext context) {
+
+ return new Document("into",
+ !StringUtils.hasText(database) ? collection : new Document("db", database).append("coll", collection));
+ }
+ }
+
+ /**
+ * 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
+ */
+ public static class WhenDocumentsMatch {
+
+ private final Object value;
+
+ private WhenDocumentsMatch(Object value) {
+ this.value = value;
+ }
+
+ public static WhenDocumentsMatch whenMatchedOf(String value) {
+ return new WhenDocumentsMatch(value);
+ }
+
+ /**
+ * Replace the existing document in the output collection with the matching results document.
+ *
+ * @return new instance of {@link WhenDocumentsMatch}.
+ */
+ public static WhenDocumentsMatch replaceDocument() {
+ return whenMatchedOf("replace");
+ }
+
+ /**
+ * Keep the existing document in the output collection.
+ *
+ * @return new instance of {@link WhenDocumentsMatch}.
+ */
+ public static WhenDocumentsMatch keepExistingDocument() {
+ return whenMatchedOf("keepExisting");
+ }
+
+ /**
+ * Merge the matching documents. Please see the MongoDB reference documentation for details.
+ *
+ * @return new instance of {@link WhenDocumentsMatch}.
+ */
+ public static WhenDocumentsMatch mergeDocuments() {
+ return whenMatchedOf("merge");
+ }
+
+ /**
+ * Stop and fail the aggregation operation. Does not revert already performed changes on previous documents.
+ *
+ * @return new instance of {@link WhenDocumentsMatch}.
+ */
+ public static WhenDocumentsMatch failOnMatch() {
+ return whenMatchedOf("fail");
+ }
+
+ /**
+ * Use an {@link Aggregation} to update the document in the collection. Please see the MongoDB reference
+ * documentation for details.
+ *
+ * @param aggregation must not be {@literal null}.
+ * @return new instance of {@link WhenDocumentsMatch}.
+ */
+ public static WhenDocumentsMatch updateWith(Aggregation aggregation) {
+ return new WhenDocumentsMatch(aggregation);
+ }
+
+ /**
+ * Use an aggregation pipeline to update the document in the collection. Please see the MongoDB reference
+ * documentation for details.
+ *
+ * @param aggregationPipeline must not be {@literal null}.
+ * @return new instance of {@link WhenDocumentsMatch}.
+ */
+ public static WhenDocumentsMatch updateWith(List aggregationPipeline) {
+ return new WhenDocumentsMatch(aggregationPipeline);
+ }
+
+ Document toDocument(AggregationOperationContext context) {
+
+ if (value instanceof Aggregation) {
+ return new Document("whenMatched", ((Aggregation) value).toPipeline(context));
+ }
+
+ return new Document("whenMatched", value);
+ }
+ }
+
+ /**
+ * 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
+ */
+ public static class WhenDocumentsDontMatch {
+
+ private final String value;
+
+ private WhenDocumentsDontMatch(String value) {
+ this.value = value;
+ }
+
+ public static WhenDocumentsDontMatch whenNotMatchedOf(String value) {
+ return new WhenDocumentsDontMatch(value);
+ }
+
+ /**
+ * Insert the document into the output collection.
+ *
+ * @return new instance of {@link WhenDocumentsDontMatch}.
+ */
+ public static WhenDocumentsDontMatch insertNewDocument() {
+ return whenNotMatchedOf("insert");
+ }
+
+ /**
+ * Discard the document - do not insert the document into the output collection.
+ *
+ * @return new instance of {@link WhenDocumentsDontMatch}.
+ */
+ public static WhenDocumentsDontMatch discardDocument() {
+ return whenNotMatchedOf("discard");
+ }
+
+ /**
+ * Stop and fail the aggregation operation. Does not revert already performed changes on previous documents.
+ *
+ * @return
+ */
+ public static WhenDocumentsDontMatch failWhenNotMatch() {
+ return whenNotMatchedOf("fail");
+ }
+
+ public Document toDocument(AggregationOperationContext context) {
+ return new Document("whenNotMatched", value);
+ }
+ }
+
+ /**
+ * Builder API to construct a {@link MergeOperation}.
+ *
+ * @author Christoph Strobl
+ * @since 2.3
+ */
+ public static class MergeOperationBuilder {
+
+ private String collection;
+ private @Nullable String database;
+ private @Nullable UniqueMergeId id;
+ private @Nullable Let let;
+ private @Nullable WhenDocumentsMatch whenMatched;
+ private @Nullable WhenDocumentsDontMatch whenNotMatched;
+
+ public MergeOperationBuilder() {}
+
+ /**
+ * Required output collection name to store results to.
+ *
+ * @param collection must not be {@literal null} nor empty.
+ * @return this.
+ */
+ public MergeOperationBuilder intoCollection(String collection) {
+
+ Assert.hasText(collection, "Collection must not be null nor empty!");
+
+ this.collection = collection;
+ return this;
+ }
+
+ /**
+ * Optionally define a target database if different from the current one.
+ *
+ * @param database must not be {@literal null}.
+ * @return this.
+ */
+ public MergeOperationBuilder inDatabase(String database) {
+
+ this.database = database;
+ return this;
+ }
+
+ /**
+ * Define the target to store results in.
+ *
+ * @param into must not be {@literal null}.
+ * @return this.
+ */
+ public MergeOperationBuilder into(MergeOperationTarget into) {
+
+ this.database = into.database;
+ this.collection = into.collection;
+ return this;
+ }
+
+ /**
+ * Define the target to store results in.
+ *
+ * @param target must not be {@literal null}.
+ * @return this.
+ */
+ public MergeOperationBuilder target(MergeOperationTarget target) {
+ return into(target);
+ }
+
+ /**
+ * Appends a single field or multiple fields that act as a unique identifier for a document. The identifier
+ * determines if a results document matches an already existing document in the output collection.
+ * The aggregation results documents must contain the field(s) specified via {@code on}, unless it's the {@code _id}
+ * field.
+ *
+ * @param fields must not be {@literal null}.
+ * @return this.
+ */
+ public MergeOperationBuilder on(String... fields) {
+ return id(UniqueMergeId.ofIdFields(fields));
+ }
+
+ /**
+ * 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.
+ */
+ public MergeOperationBuilder id(UniqueMergeId id) {
+
+ this.id = id;
+ return this;
+ }
+
+ /**
+ * Expose the variables defined by {@link Let} to the {@link WhenDocumentsMatch#updateWith(Aggregation) update
+ * aggregation}.
+ *
+ * @param let the variable expressions
+ * @return this.
+ */
+ public MergeOperationBuilder let(Let let) {
+
+ this.let = let;
+ return this;
+ }
+
+ /**
+ * Expose the variables defined by {@link Let} to the {@link WhenDocumentsMatch#updateWith(Aggregation) update
+ * aggregation}.
+ *
+ * @param let the variable expressions
+ * @return this.
+ */
+ public MergeOperationBuilder exposeVariablesOf(Let let) {
+ return let(let);
+ }
+
+ /**
+ * The action to take place when documents already exist in the target collection.
+ *
+ * @param whenMatched must not be {@literal null}.
+ * @return this.
+ */
+ public MergeOperationBuilder whenMatched(WhenDocumentsMatch whenMatched) {
+
+ this.whenMatched = whenMatched;
+ return this;
+ }
+
+ /**
+ * The action to take place when documents already exist in the target collection.
+ *
+ * @param whenMatched must not be {@literal null}.
+ * @return this.
+ */
+ public MergeOperationBuilder whenDocumentsMatch(WhenDocumentsMatch whenMatched) {
+ return whenMatched(whenMatched);
+ }
+
+ /**
+ * The {@link Aggregation action} to take place when documents already exist in the target collection.
+ *
+ * @param aggregation must not be {@literal null}.
+ * @return this.
+ */
+ public MergeOperationBuilder whenDocumentsMatchApply(Aggregation aggregation) {
+ return whenMatched(WhenDocumentsMatch.updateWith(aggregation));
+ }
+
+ /**
+ * The action to take place when documents do not already exist in the target collection.
+ *
+ * @param whenNotMatched must not be {@literal null}.
+ * @return this.
+ */
+ public MergeOperationBuilder whenNotMatched(WhenDocumentsDontMatch whenNotMatched) {
+
+ this.whenNotMatched = whenNotMatched;
+ return this;
+ }
+
+ /**
+ * The action to take place when documents do not already exist in the target collection.
+ *
+ * @param whenNotMatched must not be {@literal null}.
+ * @return this.
+ */
+ public MergeOperationBuilder whenDocumentsDontMatch(WhenDocumentsDontMatch whenNotMatched) {
+ return whenNotMatched(whenNotMatched);
+ }
+
+ /**
+ * @return new instance of {@link MergeOperation}.
+ */
+ public MergeOperation build() {
+ return new MergeOperation(new MergeOperationTarget(database, collection), id != null ? id : UniqueMergeId.id(),
+ let, whenMatched, whenNotMatched);
+ }
+ }
+}
diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/SetOperation.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/SetOperation.java
index d7b97297e..5516ece3d 100644
--- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/SetOperation.java
+++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/SetOperation.java
@@ -15,16 +15,10 @@
*/
package org.springframework.data.mongodb.core.aggregation;
-import java.util.Collection;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
-import java.util.Map.Entry;
-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.data.mongodb.core.aggregation.SetOperation.FieldAppender.ValueAppender;
import org.springframework.lang.Nullable;
@@ -41,10 +35,7 @@ import org.springframework.lang.Nullable;
* @see MongoDB Aggregation Framework:
* $set
*/
-public class SetOperation implements InheritsFieldsAggregationOperation {
-
- private Map valueMap;
- private ExposedFields exposedFields = ExposedFields.empty();
+public class SetOperation extends DocumentEnhancingOperation {
/**
* Create new instance of {@link SetOperation} adding map keys as exposed fields.
@@ -52,11 +43,7 @@ public class SetOperation implements InheritsFieldsAggregationOperation {
* @param source must not be {@literal null}.
*/
private SetOperation(Map source) {
-
- this.valueMap = new LinkedHashMap<>(source);
- for (Object key : source.keySet()) {
- this.exposedFields = add(key);
- }
+ super(source);
}
/**
@@ -97,7 +84,7 @@ public class SetOperation implements InheritsFieldsAggregationOperation {
*/
public SetOperation set(Object field, Object value) {
- LinkedHashMap target = new LinkedHashMap<>(this.valueMap);
+ LinkedHashMap target = new LinkedHashMap<>(getValueMap());
target.put(field, value);
return new SetOperation(target);
@@ -109,73 +96,12 @@ public class SetOperation implements InheritsFieldsAggregationOperation {
* @return new instance of {@link FieldAppender}.
*/
public FieldAppender and() {
- return new FieldAppender(this.valueMap);
+ return new FieldAppender(getValueMap());
}
- /*
- * (non-Javadoc)
- * @see org.springframework.data.mongodb.core.aggregation.AggregationOperation#toDocument(org.springframework.data.mongodb.core.aggregation.AggregationOperationContext)
- */
@Override
- public Document toDocument(AggregationOperationContext context) {
-
- InheritingExposedFieldsAggregationOperationContext operationContext = new InheritingExposedFieldsAggregationOperationContext(
- exposedFields, context);
-
- if (valueMap.size() == 1) {
- return context
- .getMappedObject(new Document("$set", toSetEntry(valueMap.entrySet().iterator().next(), operationContext)));
- }
-
- Document $set = new Document();
- valueMap.entrySet().stream().map(it -> toSetEntry(it, operationContext)).forEach($set::putAll);
- return context.getMappedObject(new Document("$set", $set));
- }
-
- /*
- * (non-Javadoc)
- * @see org.springframework.data.mongodb.core.aggregation.FieldsExposingAggregationOperation#getFields()
- */
- @Override
- public ExposedFields getFields() {
- return exposedFields;
- }
-
- private ExposedFields add(Object field) {
-
- if (field instanceof Field) {
- return exposedFields.and(new ExposedField((Field) field, true));
- }
- if (field instanceof String) {
- return exposedFields.and(new ExposedField(Fields.field((String) field), true));
- }
-
- throw new IllegalArgumentException(String.format("Expected %s to be a field/property.", field));
- }
-
- private static Document toSetEntry(Entry entry, AggregationOperationContext context) {
-
- String field = entry.getKey() instanceof String ? context.getReference((String) entry.getKey()).getRaw()
- : context.getReference((Field) entry.getKey()).getRaw();
-
- Object value = computeValue(entry.getValue(), context);
-
- return new Document(field, value);
- }
-
- private static Object computeValue(Object value, AggregationOperationContext context) {
-
- if (value instanceof Field) {
- return context.getReference((Field) value).toString();
- }
- 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 value;
+ protected String mongoOperator() {
+ return "$set";
}
/**
diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/TypeBasedAggregationOperationContext.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/TypeBasedAggregationOperationContext.java
index 59a5eddcf..5ba22e960 100644
--- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/TypeBasedAggregationOperationContext.java
+++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/TypeBasedAggregationOperationContext.java
@@ -127,6 +127,15 @@ public class TypeBasedAggregationOperationContext implements AggregationOperatio
return Fields.fields(fields.toArray(new String[0]));
}
+ /*
+ * (non-Javadoc)
+ * @see org.springframework.data.mongodb.core.aggregation.AggregationOperationContext#continueOnMissingFieldReference()
+ */
+ @Override
+ public AggregationOperationContext continueOnMissingFieldReference() {
+ return new RelaxedTypeBasedAggregationOperationContext(type, mappingContext, mapper);
+ }
+
protected FieldReference getReferenceFor(Field field) {
PersistentPropertyPath propertyPath = mappingContext
diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/VariableOperators.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/VariableOperators.java
index 4daef44e6..a89ba122f 100644
--- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/VariableOperators.java
+++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/VariableOperators.java
@@ -296,7 +296,7 @@ public class VariableOperators {
return toLet(ExposedFields.synthetic(Fields.fields(getVariableNames())), context);
}
- private String[] getVariableNames() {
+ String[] getVariableNames() {
String[] varNames = new String[this.vars.size()];
for (int i = 0; i < this.vars.size(); i++) {
diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/aggregation/AddFieldsOperationUnitTests.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/aggregation/AddFieldsOperationUnitTests.java
new file mode 100644
index 000000000..829a1ba8a
--- /dev/null
+++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/aggregation/AddFieldsOperationUnitTests.java
@@ -0,0 +1,146 @@
+/*
+ * Copyright 2020 the original author or authors.
+ *
+ * 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
+ *
+ * https://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.springframework.data.mongodb.core.aggregation;
+
+import static org.assertj.core.api.Assertions.*;
+
+import java.util.List;
+
+import org.bson.Document;
+import org.junit.jupiter.api.Test;
+import org.springframework.data.mongodb.core.convert.MappingMongoConverter;
+import org.springframework.data.mongodb.core.convert.NoOpDbRefResolver;
+import org.springframework.data.mongodb.core.convert.QueryMapper;
+import org.springframework.data.mongodb.core.mapping.Field;
+import org.springframework.data.mongodb.core.mapping.MongoMappingContext;
+import org.springframework.lang.Nullable;
+
+/**
+ * @author Christoph Strobl
+ */
+class AddFieldsOperationUnitTests {
+
+ @Test // DATAMONGO-2363
+ void raisesErrorOnNullField() {
+ assertThatExceptionOfType(IllegalArgumentException.class).isThrownBy(() -> new AddFieldsOperation(null, "value"));
+ }
+
+ @Test // DATAMONGO-2363
+ void rendersFieldReferenceCorrectly() {
+
+ assertThat(new AddFieldsOperation("name", "value").toPipelineStages(contextFor(Scores.class)))
+ .containsExactly(Document.parse("{\"$addFields\" : {\"name\":\"value\"}}"));
+ }
+
+ @Test // DATAMONGO-2363
+ void rendersMultipleEntriesCorrectly() {
+
+ assertThat(new AddFieldsOperation("name", "value").addField("field-2", "value2")
+ .toPipelineStages(contextFor(Scores.class)))
+ .containsExactly(Document.parse("{\"$addFields\" : {\"name\":\"value\", \"field-2\":\"value2\"}}"));
+ }
+
+ @Test // DATAMONGO-2363
+ void rendersMappedFieldReferenceCorrectly() {
+
+ assertThat(new AddFieldsOperation("student", "value").toPipelineStages(contextFor(ScoresWithMappedField.class)))
+ .containsExactly(Document.parse("{\"$addFields\" : {\"student_name\":\"value\"}}"));
+ }
+
+ @Test // DATAMONGO-2363
+ void rendersNestedMappedFieldReferenceCorrectly() {
+
+ assertThat(new AddFieldsOperation("scoresWithMappedField.student", "value")
+ .toPipelineStages(contextFor(ScoresWrapper.class)))
+ .containsExactly(Document.parse("{\"$addFields\" : {\"scoresWithMappedField.student_name\":\"value\"}}"));
+ }
+
+ @Test // DATAMONGO-2363
+ void rendersTargetValueFieldReferenceCorrectly() {
+
+ assertThat(new AddFieldsOperation("name", Fields.field("value")).toPipelineStages(contextFor(Scores.class)))
+ .containsExactly(Document.parse("{\"$addFields\" : {\"name\":\"$value\"}}"));
+ }
+
+ @Test // DATAMONGO-2363
+ void rendersMappedTargetValueFieldReferenceCorrectly() {
+
+ assertThat(new AddFieldsOperation("student", Fields.field("homework"))
+ .toPipelineStages(contextFor(ScoresWithMappedField.class)))
+ .containsExactly(Document.parse("{\"$addFields\" : {\"student_name\":\"$home_work\"}}"));
+ }
+
+ @Test // DATAMONGO-2363
+ void rendersNestedMappedTargetValueFieldReferenceCorrectly() {
+
+ assertThat(new AddFieldsOperation("scoresWithMappedField.student", Fields.field("scoresWithMappedField.homework"))
+ .toPipelineStages(contextFor(ScoresWrapper.class)))
+ .containsExactly(Document.parse(
+ "{\"$addFields\" : {\"scoresWithMappedField.student_name\":\"$scoresWithMappedField.home_work\"}}"));
+ }
+
+ @Test // DATAMONGO-2363
+ void rendersTargetValueExpressionCorrectly() {
+
+ assertThat(AddFieldsOperation.builder().addField("totalHomework")
+ .withValueOf(ArithmeticOperators.valueOf("homework").sum()).build().toPipelineStages(contextFor(Scores.class)))
+ .containsExactly(Document.parse("{\"$addFields\" : {\"totalHomework\": { \"$sum\" : \"$homework\" }}}"));
+ }
+
+ @Test // DATAMONGO-2363
+ void exposesFieldsCorrectly() {
+
+ ExposedFields fields = AddFieldsOperation.builder().addField("totalHomework").withValue("A+") //
+ .addField("totalQuiz").withValue("B-") //
+ .build().getFields();
+
+ assertThat(fields.getField("totalHomework")).isNotNull();
+ assertThat(fields.getField("totalQuiz")).isNotNull();
+ assertThat(fields.getField("does-not-exist")).isNull();
+ }
+
+ private static AggregationOperationContext contextFor(@Nullable Class> type) {
+
+ if (type == null) {
+ return Aggregation.DEFAULT_CONTEXT;
+ }
+
+ MappingMongoConverter mongoConverter = new MappingMongoConverter(NoOpDbRefResolver.INSTANCE,
+ new MongoMappingContext());
+ mongoConverter.afterPropertiesSet();
+
+ return new TypeBasedAggregationOperationContext(type, mongoConverter.getMappingContext(),
+ new QueryMapper(mongoConverter)).continueOnMissingFieldReference();
+ }
+
+ static class Scores {
+
+ String student;
+ List homework;
+ }
+
+ static class ScoresWithMappedField {
+
+ @Field("student_name") String student;
+ @Field("home_work") List homework;
+ }
+
+ static class ScoresWrapper {
+
+ Scores scores;
+ ScoresWithMappedField scoresWithMappedField;
+ }
+}
diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/aggregation/MergeOperationUnitTests.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/aggregation/MergeOperationUnitTests.java
new file mode 100644
index 000000000..509dfc48d
--- /dev/null
+++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/aggregation/MergeOperationUnitTests.java
@@ -0,0 +1,126 @@
+/*
+ * Copyright 2020 the original author or authors.
+ *
+ * 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
+ *
+ * https://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.springframework.data.mongodb.core.aggregation;
+
+import static org.assertj.core.api.Assertions.*;
+import static org.springframework.data.mongodb.core.aggregation.Aggregation.*;
+import static org.springframework.data.mongodb.core.aggregation.MergeOperation.*;
+import static org.springframework.data.mongodb.core.aggregation.MergeOperation.WhenDocumentsMatch.*;
+
+import java.util.Arrays;
+
+import org.bson.Document;
+import org.junit.jupiter.api.Test;
+import org.springframework.data.mongodb.core.aggregation.AccumulatorOperators.Sum;
+import org.springframework.data.mongodb.core.convert.MappingMongoConverter;
+import org.springframework.data.mongodb.core.convert.NoOpDbRefResolver;
+import org.springframework.data.mongodb.core.convert.QueryMapper;
+import org.springframework.data.mongodb.core.mapping.Field;
+import org.springframework.data.mongodb.core.mapping.MongoMappingContext;
+import org.springframework.lang.Nullable;
+
+/**
+ * @author Christoph Strobl
+ */
+class MergeOperationUnitTests {
+
+ private static final String OUT_COLLECTION = "target-collection";
+ private static final String OUT_DB = "target-db";
+
+ private static final Document OUT = new Document("db", OUT_DB).append("coll", OUT_COLLECTION);
+
+ @Test // DATAMONGO-2363
+ void justCollection() {
+
+ assertThat(mergeInto(OUT_COLLECTION).toDocument(DEFAULT_CONTEXT)).isEqualTo(new Document("$merge", OUT_COLLECTION));
+ }
+
+ @Test // DATAMONGO-2363
+ void collectionInDatabase() {
+
+ assertThat(merge().intoCollection(OUT_COLLECTION).inDatabase("target-db").build().toDocument(DEFAULT_CONTEXT))
+ .isEqualTo(new Document("$merge", new Document("into", OUT)));
+ }
+
+ @Test // DATAMONGO-2363
+ void singleOn() {
+
+ assertThat(merge().intoCollection(OUT_COLLECTION).on("id-field").build().toDocument(DEFAULT_CONTEXT))
+ .isEqualTo(new Document("$merge", new Document("into", OUT_COLLECTION).append("on", "id-field")));
+ }
+
+ @Test // DATAMONGO-2363
+ void multipleOn() {
+
+ assertThat(merge().intoCollection(OUT_COLLECTION).on("field-1", "field-2").build().toDocument(DEFAULT_CONTEXT))
+ .isEqualTo(new Document("$merge",
+ new Document("into", OUT_COLLECTION).append("on", Arrays.asList("field-1", "field-2"))));
+ }
+
+ @Test // DATAMONGO-2363
+ void collectionAndSimpleArgs() {
+
+ assertThat(merge().intoCollection(OUT_COLLECTION).on("_id").whenMatched(replaceDocument())
+ .whenNotMatched(WhenDocumentsDontMatch.insertNewDocument()).build().toDocument(DEFAULT_CONTEXT))
+ .isEqualTo(new Document("$merge", new Document("into", OUT_COLLECTION).append("on", "_id")
+ .append("whenMatched", "replace").append("whenNotMatched", "insert")));
+ }
+
+ @Test // DATAMONGO-2363
+ void whenMatchedWithAggregation() {
+
+ String expected = "{ \"$merge\" : {\"into\": \"" + OUT_COLLECTION + "\", \"whenMatched\": ["
+ + "{ \"$addFields\" : {" //
+ + "\"thumbsup\": { \"$sum\":[ \"$thumbsup\", \"$$new.thumbsup\" ] },"
+ + "\"thumbsdown\": { \"$sum\": [ \"$thumbsdown\", \"$$new.thumbsdown\" ] } } } ]" //
+ + "} }";
+
+ Aggregation update = Aggregation
+ .newAggregation(AddFieldsOperation.addField("thumbsup").withValueOf(Sum.sumOf("thumbsup").and("$$new.thumbsup"))
+ .addField("thumbsdown").withValueOf(Sum.sumOf("thumbsdown").and("$$new.thumbsdown")).build());
+
+ assertThat(
+ merge().intoCollection(OUT_COLLECTION).whenDocumentsMatchApply(update).build().toDocument(DEFAULT_CONTEXT))
+ .isEqualTo(Document.parse(expected));
+ }
+
+ @Test // DATAMONGO-2363
+ public void mapsFieldNames() {
+
+ assertThat(merge().intoCollection("newrestaurants").on("date", "postCode").build()
+ .toDocument(contextFor(Restaurant.class))).isEqualTo(
+ Document.parse("{ \"$merge\": { \"into\": \"newrestaurants\", \"on\": [ \"date\", \"post_code\" ] } }"));
+ }
+
+ private static AggregationOperationContext contextFor(@Nullable Class> type) {
+
+ if (type == null) {
+ return Aggregation.DEFAULT_CONTEXT;
+ }
+
+ MappingMongoConverter mongoConverter = new MappingMongoConverter(NoOpDbRefResolver.INSTANCE,
+ new MongoMappingContext());
+ mongoConverter.afterPropertiesSet();
+
+ return new TypeBasedAggregationOperationContext(type, mongoConverter.getMappingContext(),
+ new QueryMapper(mongoConverter)).continueOnMissingFieldReference();
+ }
+
+ static class Restaurant {
+
+ @Field("post_code") String postCode;
+ }
+}
diff --git a/src/main/asciidoc/new-features.adoc b/src/main/asciidoc/new-features.adoc
index dc84a6093..e6ab1684d 100644
--- a/src/main/asciidoc/new-features.adoc
+++ b/src/main/asciidoc/new-features.adoc
@@ -9,6 +9,7 @@
* Removal of `_id` flattening for composite Id's when using `MongoTemplate` aggregations.
* Apply pagination when using GridFS `find(Query)`.
* <> via `@Sharded`.
+* `$merge` and `$addFields` aggregation pipeline stages.
[[new-features.2-2-0]]
== What's New in Spring Data MongoDB 2.2