diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/AddFieldsOperation.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/AddFieldsOperation.java new file mode 100644 index 000000000..984cfdc8c --- /dev/null +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/AddFieldsOperation.java @@ -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. + * + *
+ * AddFieldsOperation.addField("totalHomework").withValue("A+").and().addField("totalQuiz").withValue("B-")
+ * 
+ * + * @author Christoph Strobl + * @since 3.0 + * @see MongoDB Aggregation + * Framework: $addFields + */ +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 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 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 valueMap; + + private AddFieldsOperationBuilder() { + this.valueMap = new LinkedHashMap<>(); + } + + private AddFieldsOperationBuilder(Map 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); + } + + } +} diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/Aggregation.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/Aggregation.java index c7e8c1b2c..44376d6e8 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/Aggregation.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/Aggregation.java @@ -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}. * diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/AggregationOperationContext.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/AggregationOperationContext.java index 56940180a..6862cf515 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/AggregationOperationContext.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/AggregationOperationContext.java @@ -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; + } } diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/DocumentEnhancingOperation.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/DocumentEnhancingOperation.java new file mode 100644 index 000000000..9c078de59 --- /dev/null +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/DocumentEnhancingOperation.java @@ -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 valueMap; + private ExposedFields exposedFields = ExposedFields.empty(); + + protected DocumentEnhancingOperation(Map 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 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 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