DATAMONGO-2363 - Add support for $merge and $addFields aggregation stage.

MergeOperation.builder().intoCollection("monthlytotals")
    .whenDocumentsMatchApply(
        newAggregation(
            AddFieldsOperation.builder()
                .addField("thumbsup").withValueOf(sumOf("thumbsup").and("$$new.thumbsup"))
                .addField("thumbsdown").withValueOf(sumOf("thumbsdown").and("$$new.thumbsdown"))
                .build()
        )
    )
    .whenDocumentsDontMatch(insertNewDocument())
    .build()

Original pull request: #801.
This commit is contained in:
Christoph Strobl
2019-10-28 15:04:30 +01:00
committed by Mark Paluch
parent 7e3f7bd861
commit a4e12a96c9
11 changed files with 1218 additions and 82 deletions

View File

@@ -0,0 +1,185 @@
/*
* 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.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
import org.springframework.data.mongodb.core.aggregation.AddFieldsOperation.AddFieldsOperationBuilder.ValueAppender;
import org.springframework.lang.Nullable;
/**
* Adds new fields to documents. {@code $addFields} outputs documents that contain all existing fields from the input
* documents and newly added fields.
*
* <pre class="code">
* AddFieldsOperation.addField("totalHomework").withValue("A+").and().addField("totalQuiz").withValue("B-")
* </pre>
*
* @author Christoph Strobl
* @since 3.0
* @see <a href="https://docs.mongodb.com/manual/reference/operator/aggregation/addFields/">MongoDB Aggregation
* Framework: $addFields</a>
*/
public class AddFieldsOperation extends DocumentEnhancingOperation {
/**
* Create new instance of {@link AddFieldsOperation} adding map keys as exposed fields.
*
* @param source must not be {@literal null}.
*/
private AddFieldsOperation(Map<Object, Object> source) {
super(source);
}
/**
* Create new instance of {@link AddFieldsOperation}
*
* @param field must not be {@literal null}.
* @param value can be {@literal null}.
*/
public AddFieldsOperation(Object field, @Nullable Object value) {
this(Collections.singletonMap(field, value));
}
/**
* Define the {@link AddFieldsOperation} via {@link AddFieldsOperationBuilder}.
*
* @return new instance of {@link AddFieldsOperationBuilder}.
*/
public static AddFieldsOperationBuilder builder() {
return new AddFieldsOperationBuilder();
}
/**
* Concatenate another field to add.
*
* @param field must not be {@literal null}.
* @return new instance of {@link AddFieldsOperationBuilder}.
*/
public static ValueAppender addField(String field) {
return new AddFieldsOperationBuilder().addField(field);
}
/**
* Append the value for a specific field to the operation.
*
* @param field the target field to add.
* @param value the value to assign.
* @return new instance of {@link AddFieldsOperation}.
*/
public AddFieldsOperation addField(Object field, Object value) {
LinkedHashMap<Object, Object> target = new LinkedHashMap<>(getValueMap());
target.put(field, value);
return new AddFieldsOperation(target);
}
/**
* Concatenate additional fields to add.
*
* @return new instance of {@link AddFieldsOperationBuilder}.
*/
public AddFieldsOperationBuilder and() {
return new AddFieldsOperationBuilder(getValueMap());
}
@Override
protected String mongoOperator() {
return "$addFields";
}
/**
* @author Christoph Strobl
* @since 3.0
*/
public static class AddFieldsOperationBuilder {
private final Map<Object, Object> valueMap;
private AddFieldsOperationBuilder() {
this.valueMap = new LinkedHashMap<>();
}
private AddFieldsOperationBuilder(Map<Object, Object> source) {
this.valueMap = new LinkedHashMap<>(source);
}
public AddFieldsOperationBuilder addFieldWithValue(String field, @Nullable Object value) {
return addField(field).withValue(value);
}
public AddFieldsOperationBuilder addFieldWithValueOf(String field, Object value) {
return addField(field).withValueOf(value);
}
/**
* Define the field to add.
*
* @param field must not be {@literal null}.
* @return new instance of {@link ValueAppender}.
*/
public ValueAppender addField(String field) {
return new ValueAppender() {
@Override
public AddFieldsOperationBuilder withValue(Object value) {
valueMap.put(field, value);
return AddFieldsOperationBuilder.this;
}
@Override
public AddFieldsOperationBuilder withValueOf(Object value) {
valueMap.put(field, value instanceof String ? Fields.fields((String) value) : value);
return AddFieldsOperationBuilder.this;
}
};
}
public AddFieldsOperation build() {
return new AddFieldsOperation(valueMap);
}
/**
* @author Christoph Strobl
* @since 3.0
*/
public interface ValueAppender {
/**
* Define the value to assign as is.
*
* @param value can be {@literal null}.
* @return new instance of {@link AddFieldsOperation}.
*/
AddFieldsOperationBuilder withValue(@Nullable Object value);
/**
* Define the value to assign. Plain {@link String} values are treated as {@link Field field references}.
*
* @param value must not be {@literal null}.
* @return new instance of {@link AddFieldsOperation}.
*/
AddFieldsOperationBuilder withValueOf(Object value);
}
}
}

View File

@@ -23,9 +23,11 @@ import java.util.List;
import org.bson.Document;
import org.springframework.data.domain.Sort;
import org.springframework.data.domain.Sort.Direction;
import org.springframework.data.mongodb.core.aggregation.AddFieldsOperation.AddFieldsOperationBuilder;
import org.springframework.data.mongodb.core.aggregation.CountOperation.CountOperationBuilder;
import org.springframework.data.mongodb.core.aggregation.FacetOperation.FacetOperationBuilder;
import org.springframework.data.mongodb.core.aggregation.GraphLookupOperation.StartWithBuilder;
import org.springframework.data.mongodb.core.aggregation.MergeOperation.MergeOperationBuilder;
import org.springframework.data.mongodb.core.aggregation.ReplaceRootOperation.ReplaceRootDocumentOperationBuilder;
import org.springframework.data.mongodb.core.aggregation.ReplaceRootOperation.ReplaceRootOperationBuilder;
import org.springframework.data.mongodb.core.query.Criteria;
@@ -647,6 +649,28 @@ public class Aggregation {
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

@@ -20,7 +20,6 @@ import java.lang.reflect.Method;
import java.util.Arrays;
import org.bson.Document;
import org.springframework.beans.BeanUtils;
import org.springframework.data.mongodb.core.aggregation.ExposedFields.FieldReference;
import org.springframework.lang.Nullable;
@@ -103,4 +102,16 @@ public interface AggregationOperationContext {
.map(PropertyDescriptor::getName) //
.toArray(String[]::new));
}
/**
* This toggle allows the {@link AggregationOperationContext context} to use any given field name without checking for
* its existence. Typically the {@link AggregationOperationContext} fails when referencing unknown fields, those that
* are not present in one of the previous stages or the input source, throughout the pipeline.
*
* @return a more relaxed {@link AggregationOperationContext}.
* @since 3.0
*/
default AggregationOperationContext continueOnMissingFieldReference() {
return this;
}
}

View File

@@ -0,0 +1,125 @@
/*
* 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.Collection;
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;
/**
* Base class for common taks required by {@link SetOperation} and {@link AddFieldsOperation}.
*
* @author Christoph Strobl
* @since 3.0
*/
abstract class DocumentEnhancingOperation implements InheritsFieldsAggregationOperation {
private Map<Object, Object> valueMap;
private ExposedFields exposedFields = ExposedFields.empty();
protected DocumentEnhancingOperation(Map<Object, Object> source) {
this.valueMap = new LinkedHashMap<>(source);
for (Object key : source.keySet()) {
this.exposedFields = add(key);
}
}
/*
* (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(mongoOperator(), 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(mongoOperator(), $set));
}
/**
* @return the String representation of the native MongoDB operator.
*/
protected abstract String mongoOperator();
/**
* @return the raw value map
*/
protected Map<Object, Object> getValueMap() {
return this.valueMap;
}
/*
* (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<Object, Object> 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;
}
}

View File

@@ -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.
* <p>
* 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
* @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<String> uniqueIdentifier;
private UniqueMergeId(Collection<String> 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<String> 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. <br />
* 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
*/
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<AggregationOperation> 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. <br />
* 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);
}
}
}

View File

@@ -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 <a href="https://docs.mongodb.com/manual/reference/operator/aggregation/set/">MongoDB Aggregation Framework:
* $set</a>
*/
public class SetOperation implements InheritsFieldsAggregationOperation {
private Map<Object, Object> 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<Object, Object> 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<Object, Object> target = new LinkedHashMap<>(this.valueMap);
LinkedHashMap<Object, Object> 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<Object, Object> 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";
}
/**

View File

@@ -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<MongoPersistentProperty> propertyPath = mappingContext

View File

@@ -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++) {

View File

@@ -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<Integer> homework;
}
static class ScoresWithMappedField {
@Field("student_name") String student;
@Field("home_work") List<Integer> homework;
}
static class ScoresWrapper {
Scores scores;
ScoresWithMappedField scoresWithMappedField;
}
}

View File

@@ -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;
}
}

View File

@@ -9,6 +9,7 @@
* Removal of `_id` flattening for composite Id's when using `MongoTemplate` aggregations.
* Apply pagination when using GridFS `find(Query)`.
* <<sharding,Sharding key derivation>> via `@Sharded`.
* `$merge` and `$addFields` aggregation pipeline stages.
[[new-features.2-2-0]]
== What's New in Spring Data MongoDB 2.2