operations, Aggregat
* Simple {@link AggregationOperationContext} that just returns {@link FieldReference}s as is.
*
* @author Oliver Gierke
+ * @author Christoph Strobl
*/
private static class NoOpAggregationOperationContext implements AggregationOperationContext {
/*
* (non-Javadoc)
- * @see org.springframework.data.mongodb.core.aggregation.AggregationOperationContext#getMappedObject(org.bson.Document)
+ * @see org.springframework.data.mongodb.core.aggregation.AggregationOperationContext#getMappedObject(org.bson.Document, java.lang.Class)
*/
@Override
- public Document getMappedObject(Document document) {
+ public Document getMappedObject(Document document, @Nullable Class> type) {
return document;
}
diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/ExposedFieldsAggregationOperationContext.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/ExposedFieldsAggregationOperationContext.java
index 7c5778eacd..5bb01ffbb8 100644
--- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/ExposedFieldsAggregationOperationContext.java
+++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/ExposedFieldsAggregationOperationContext.java
@@ -56,11 +56,11 @@ public ExposedFieldsAggregationOperationContext(ExposedFields exposedFields,
/*
* (non-Javadoc)
- * @see org.springframework.data.mongodb.core.aggregation.AggregationOperationContext#getMappedObject(org.bson.Document)
+ * @see org.springframework.data.mongodb.core.aggregation.AggregationOperationContext#getMappedObject(org.bson.Document, java.lang.Class)
*/
@Override
- public Document getMappedObject(Document document) {
- return rootContext.getMappedObject(document);
+ public Document getMappedObject(Document document, @Nullable Class> type) {
+ return rootContext.getMappedObject(document, type);
}
/*
diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/NestedDelegatingExpressionAggregationOperationContext.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/NestedDelegatingExpressionAggregationOperationContext.java
index 59c4499306..056abaffea 100644
--- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/NestedDelegatingExpressionAggregationOperationContext.java
+++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/NestedDelegatingExpressionAggregationOperationContext.java
@@ -45,11 +45,11 @@ public NestedDelegatingExpressionAggregationOperationContext(AggregationOperatio
/*
* (non-Javadoc)
- * @see org.springframework.data.mongodb.core.aggregation.AggregationOperationContext#getMappedObject(org.bson.Document)
+ * @see org.springframework.data.mongodb.core.aggregation.AggregationOperationContext#getMappedObject(org.bson.Document, java.lang.Class)
*/
@Override
- public Document getMappedObject(Document document) {
- return delegate.getMappedObject(document);
+ public Document getMappedObject(Document document, Class> type) {
+ return delegate.getMappedObject(document, type);
}
/*
diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/PrefixingDelegatingAggregationOperationContext.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/PrefixingDelegatingAggregationOperationContext.java
index 299b7d51a5..d22d507d8b 100644
--- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/PrefixingDelegatingAggregationOperationContext.java
+++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/PrefixingDelegatingAggregationOperationContext.java
@@ -25,6 +25,7 @@
import org.bson.Document;
import org.springframework.data.mongodb.core.aggregation.ExposedFields.FieldReference;
+import org.springframework.lang.Nullable;
/**
* {@link AggregationOperationContext} implementation prefixing non-command keys on root level with the given prefix.
@@ -56,11 +57,11 @@ public PrefixingDelegatingAggregationOperationContext(AggregationOperationContex
/*
* (non-Javadoc)
- * @see org.springframework.data.mongodb.core.aggregation.AggregationOperationContext#getMappedObject(org.bson.Document)
+ * @see org.springframework.data.mongodb.core.aggregation.AggregationOperationContext#getMappedObject(org.bson.Document, java.lang.Class)
*/
@Override
- public Document getMappedObject(Document document) {
- return doPrefix(delegate.getMappedObject(document));
+ public Document getMappedObject(Document document, @Nullable Class> type) {
+ return doPrefix(delegate.getMappedObject(document, type));
}
/*
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 51b58dca29..b744795b1a 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
@@ -27,6 +27,7 @@
import org.springframework.data.mongodb.core.convert.QueryMapper;
import org.springframework.data.mongodb.core.mapping.MongoPersistentEntity;
import org.springframework.data.mongodb.core.mapping.MongoPersistentProperty;
+import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
/**
@@ -70,7 +71,16 @@ public TypeBasedAggregationOperationContext(Class> type,
*/
@Override
public Document getMappedObject(Document document) {
- return mapper.getMappedObject(document, mappingContext.getPersistentEntity(type));
+ return getMappedObject(document, type);
+ }
+
+ /*
+ * (non-Javadoc)
+ * @see org.springframework.data.mongodb.core.aggregation.AggregationOperationContext#getMappedObject(org.bson.Document, java.lang.Class)
+ */
+ @Override
+ public Document getMappedObject(Document document, @Nullable Class> type) {
+ return mapper.getMappedObject(document, type != null ? mappingContext.getPersistentEntity(type) : null);
}
/*
diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/Aggregation.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/Aggregation.java
new file mode 100644
index 0000000000..eea5b01188
--- /dev/null
+++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/Aggregation.java
@@ -0,0 +1,127 @@
+/*
+ * Copyright 2019 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.repository;
+
+import java.lang.annotation.Documented;
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+import org.springframework.core.annotation.AliasFor;
+import org.springframework.data.annotation.QueryAnnotation;
+
+/**
+ * The {@link Aggregation} annotation can be used to annotate a {@link org.springframework.data.repository.Repository}
+ * query method so that it runs the {@link Aggregation#pipeline()} on invocation.
+ *
+ * Pipeline stages are mapped against the {@link org.springframework.data.repository.Repository} domain type to consider
+ * {@link org.springframework.data.mongodb.core.mapping.Field field} mappings and may contain simple placeholders
+ * {@code ?0} as well as {@link org.springframework.expression.spel.standard.SpelExpression SpelExpressions}.
+ *
+ * Query method {@link org.springframework.data.domain.Sort} and {@link org.springframework.data.domain.Pageable}
+ * arguments are applied at the end of the pipeline or can be defined manually as part of it.
+ *
+ * @author Christoph Strobl
+ * @since 2.2
+ */
+@Retention(RetentionPolicy.RUNTIME)
+@Target({ ElementType.METHOD, ElementType.ANNOTATION_TYPE })
+@Documented
+@QueryAnnotation
+public @interface Aggregation {
+
+ /**
+ * Alias for {@link #pipeline()}. Defines the aggregation pipeline to apply.
+ *
+ * @return an empty array by default.
+ * @see #pipeline()
+ */
+ @AliasFor("pipeline")
+ String[] value() default {};
+
+ /**
+ * Defines the aggregation pipeline to apply.
+ *
+ *
+ *
+ * // aggregation resulting in collection with single value
+ * @Aggregation("{ '$project': { '_id' : '$lastname' } }")
+ * List findAllLastnames();
+ *
+ * // aggregation with parameter replacement
+ * @Aggregation("{ '$group': { '_id' : '$lastname', names : { $addToSet : '$?0' } } }")
+ * List groupByLastnameAnd(String property);
+ *
+ * // aggregation with sort in pipeline
+ * @Aggregation(pipeline = {"{ '$group': { '_id' : '$lastname', names : { $addToSet : '$?0' } } }", "{ '$sort' : { 'lastname' : -1 } }"})
+ * List groupByLastnameAnd(String property);
+ *
+ * // Sort parameter is used for sorting results
+ * @Aggregation("{ '$group': { '_id' : '$lastname', names : { $addToSet : '$?0' } } }")
+ * List groupByLastnameAnd(String property, Sort sort);
+ *
+ * // Pageable parameter used for sort, skip and limit
+ * @Aggregation("{ '$group': { '_id' : '$lastname', names : { $addToSet : '$?0' } } }")
+ * List groupByLastnameAnd(String property, Pageable page);
+ *
+ * // Single value result aggregation.
+ * @Aggregation("{ '$group' : { '_id' : null, 'total' : { $sum: '$age' } } }")
+ * Long sumAge();
+ *
+ * // Single value wrapped in container object
+ * @Aggregation("{ '$group' : { '_id' : null, 'total' : { $sum: '$age' } } })
+ * SumAge sumAgeAndReturnAggregationResultWrapperWithConcreteType();
+ *
+ * // Raw aggregation result
+ * @Aggregation("{ '$group' : { '_id' : null, 'total' : { $sum: '$age' } } })
+ * AggregationResults<org.bson.Document>> sumAgeAndReturnAggregationResultWrapper();
+ *
+ *
+ * @return an empty array by default.
+ */
+ @AliasFor("value")
+ String[] pipeline() default {};
+
+ /**
+ * Defines the collation to apply when executing the aggregation.
+ *
+ *
+ * // Fixed value
+ * @Aggregation(pipeline = "...", collation = "en_US")
+ * List findAllByFixedCollation();
+ *
+ * // Fixed value as Document
+ * @Aggregation(pipeline = "...", collation = "{ 'locale' : 'en_US' }")
+ * List findAllByFixedJsonCollation();
+ *
+ * // Dynamic value as String
+ * @Aggregation(pipeline = "...", collation = "?0")
+ * List findAllByDynamicCollation(String collation);
+ *
+ * // Dynamic value as Document
+ * @Aggregation(pipeline = "...", collation = "{ 'locale' : ?0 }")
+ * List findAllByDynamicJsonCollation(String collation);
+ *
+ * // SpEL expression
+ * @Aggregation(pipeline = "...", collation = "?#{[0]}")
+ * List findAllByDynamicSpElCollation(String collation);
+ *
+ *
+ * @return an empty {@link String} by default.
+ */
+ String collation() default "";
+}
diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/query/AbstractMongoQuery.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/query/AbstractMongoQuery.java
index 03dbc78ff9..b355c37572 100644
--- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/query/AbstractMongoQuery.java
+++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/query/AbstractMongoQuery.java
@@ -16,7 +16,6 @@
package org.springframework.data.mongodb.repository.query;
import org.bson.Document;
-
import org.springframework.data.mongodb.core.ExecutableFindOperation.ExecutableFind;
import org.springframework.data.mongodb.core.ExecutableFindOperation.FindWithQuery;
import org.springframework.data.mongodb.core.ExecutableFindOperation.TerminatingFind;
@@ -32,6 +31,7 @@
import org.springframework.data.repository.query.RepositoryQuery;
import org.springframework.data.repository.query.ResultProcessor;
import org.springframework.expression.spel.standard.SpelExpressionParser;
+import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
/**
@@ -94,22 +94,36 @@ public Object execute(Object[] parameters) {
ConvertingParameterAccessor accessor = new ConvertingParameterAccessor(operations.getConverter(),
new MongoParametersParameterAccessor(method, parameters));
+
+ ResultProcessor processor = method.getResultProcessor().withDynamicProjection(accessor);
+ Class> typeToRead = processor.getReturnedType().getTypeToRead();
+
+ return processor.processResult(doExecute(method, processor, accessor, typeToRead));
+ }
+
+ /**
+ * Execute the {@link RepositoryQuery} of the given method with the parameters provided by the
+ * {@link ConvertingParameterAccessor accessor}
+ *
+ * @param method the {@link MongoQueryMethod} invoked. Never {@literal null}.
+ * @param processor {@link ResultProcessor} for post procession. Never {@literal null}.
+ * @param accessor for providing invocation arguments. Never {@literal null}.
+ * @param typeToRead the desired component target type. Can be {@literal null}.
+ */
+ protected Object doExecute(MongoQueryMethod method, ResultProcessor processor, ConvertingParameterAccessor accessor,
+ @Nullable Class> typeToRead) {
+
Query query = createQuery(accessor);
applyQueryMetaAttributesWhenPresent(query);
query = applyAnnotatedDefaultSortIfPresent(query);
query = applyAnnotatedCollationIfPresent(query, accessor);
- ResultProcessor processor = method.getResultProcessor().withDynamicProjection(accessor);
- Class> typeToRead = processor.getReturnedType().getTypeToRead();
-
FindWithQuery> find = typeToRead == null //
? executableFind //
: executableFind.as(typeToRead);
- MongoQueryExecution execution = getExecution(accessor, find);
-
- return processor.processResult(execution.execute(query));
+ return getExecution(accessor, find).execute(query);
}
private MongoQueryExecution getExecution(ConvertingParameterAccessor accessor, FindWithQuery> operation) {
diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/query/AbstractReactiveMongoQuery.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/query/AbstractReactiveMongoQuery.java
index 343965d703..48df07217e 100644
--- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/query/AbstractReactiveMongoQuery.java
+++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/query/AbstractReactiveMongoQuery.java
@@ -20,7 +20,6 @@
import org.bson.Document;
import org.reactivestreams.Publisher;
-
import org.springframework.core.convert.converter.Converter;
import org.springframework.data.convert.EntityInstantiators;
import org.springframework.data.mongodb.core.MongoOperations;
@@ -38,6 +37,7 @@
import org.springframework.data.repository.query.RepositoryQuery;
import org.springframework.data.repository.query.ResultProcessor;
import org.springframework.expression.spel.standard.SpelExpressionParser;
+import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
/**
@@ -119,24 +119,39 @@ private Object execute(MongoParameterAccessor parameterAccessor) {
ConvertingParameterAccessor convertingParamterAccessor = new ConvertingParameterAccessor(operations.getConverter(),
parameterAccessor);
- Query query = createQuery(convertingParamterAccessor);
-
- applyQueryMetaAttributesWhenPresent(query);
- query = applyAnnotatedDefaultSortIfPresent(query);
- query = applyAnnotatedCollationIfPresent(query, convertingParamterAccessor);
ResultProcessor processor = method.getResultProcessor().withDynamicProjection(convertingParamterAccessor);
Class> typeToRead = processor.getReturnedType().getTypeToRead();
+ return doExecute(method, processor, convertingParamterAccessor, typeToRead);
+ }
+
+ /**
+ * Execute the {@link RepositoryQuery} of the given method with the parameters provided by the
+ * {@link ConvertingParameterAccessor accessor}
+ *
+ * @param method the {@link ReactiveMongoQueryMethod} invoked. Never {@literal null}.
+ * @param processor {@link ResultProcessor} for post procession. Never {@literal null}.
+ * @param accessor for providing invocation arguments. Never {@literal null}.
+ * @param typeToRead the desired component target type. Can be {@literal null}.
+ */
+ protected Object doExecute(ReactiveMongoQueryMethod method, ResultProcessor processor,
+ ConvertingParameterAccessor accessor, @Nullable Class> typeToRead) {
+
+ Query query = createQuery(accessor);
+
+ applyQueryMetaAttributesWhenPresent(query);
+ query = applyAnnotatedDefaultSortIfPresent(query);
+ query = applyAnnotatedCollationIfPresent(query, accessor);
+
FindWithQuery> find = typeToRead == null //
? findOperationWithProjection //
: findOperationWithProjection.as(typeToRead);
String collection = method.getEntityInformation().getCollectionName();
- ReactiveMongoQueryExecution execution = getExecution(convertingParamterAccessor,
+ ReactiveMongoQueryExecution execution = getExecution(accessor,
new ResultProcessingConverter(processor, operations, instantiators), find);
-
return execution.execute(query, processor.getReturnedType().getDomainType(), collection);
}
diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/query/AggregationUtils.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/query/AggregationUtils.java
new file mode 100644
index 0000000000..3c71a651ea
--- /dev/null
+++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/query/AggregationUtils.java
@@ -0,0 +1,208 @@
+/*
+ * Copyright 2019 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.repository.query;
+
+import lombok.experimental.UtilityClass;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+import org.bson.Document;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.domain.Sort.Order;
+import org.springframework.data.mongodb.core.aggregation.Aggregation;
+import org.springframework.data.mongodb.core.aggregation.AggregationOperation;
+import org.springframework.data.mongodb.core.aggregation.AggregationOperationContext;
+import org.springframework.data.mongodb.core.aggregation.AggregationOptions;
+import org.springframework.data.mongodb.core.convert.MongoConverter;
+import org.springframework.data.mongodb.core.query.Collation;
+import org.springframework.data.mongodb.core.query.Query;
+import org.springframework.data.mongodb.util.json.ParameterBindingContext;
+import org.springframework.data.mongodb.util.json.ParameterBindingDocumentCodec;
+import org.springframework.data.repository.query.QueryMethodEvaluationContextProvider;
+import org.springframework.expression.spel.standard.SpelExpressionParser;
+import org.springframework.lang.Nullable;
+import org.springframework.util.ClassUtils;
+
+/**
+ * Internal utility class to help avoid duplicate code required in both the reactive and the sync {@link Aggregation}
+ * support offered by repositories.
+ *
+ * @author Christoph Strobl
+ * @since 2.2
+ */
+@UtilityClass
+class AggregationUtils {
+
+ private static final ParameterBindingDocumentCodec CODEC = new ParameterBindingDocumentCodec();
+
+ /**
+ * Apply a collation extracted from the given {@literal collationExpression} to the given
+ * {@link org.springframework.data.mongodb.core.aggregation.AggregationOptions.Builder}. Potentially replace parameter
+ * placeholders with values from the {@link ConvertingParameterAccessor accessor}.
+ *
+ * @param builder must not be {@literal null}.
+ * @param collationExpression must not be {@literal null}.
+ * @param accessor must not be {@literal null}.
+ * @return the {@link Query} having proper {@link Collation}.
+ * @see AggregationOptions#getCollation()
+ * @see CollationUtils#computeCollation(String, ConvertingParameterAccessor, MongoParameters, SpelExpressionParser,
+ * QueryMethodEvaluationContextProvider)
+ */
+ static AggregationOptions.Builder applyCollation(AggregationOptions.Builder builder,
+ @Nullable String collationExpression, ConvertingParameterAccessor accessor, MongoParameters parameters,
+ SpelExpressionParser expressionParser, QueryMethodEvaluationContextProvider evaluationContextProvider) {
+
+ Collation collation = CollationUtils.computeCollation(collationExpression, accessor, parameters, expressionParser,
+ evaluationContextProvider);
+ return collation == null ? builder : builder.collation(collation);
+ }
+
+ /**
+ * Compute the {@link AggregationOperation aggregation} pipeline for the given {@link MongoQueryMethod}. The raw
+ * {@link org.springframework.data.mongodb.repository.Aggregation#pipeline()} is parsed with a
+ * {@link ParameterBindingDocumentCodec} to obtain the MongoDB native {@link Document} representation returned by
+ * {@link AggregationOperation#toDocument(AggregationOperationContext)} that is mapped against the domain type
+ * properties.
+ *
+ * @param method
+ * @param accessor
+ * @param expressionParser
+ * @param evaluationContextProvider
+ * @return
+ */
+ static List computePipeline(MongoQueryMethod method, ConvertingParameterAccessor accessor,
+ SpelExpressionParser expressionParser, QueryMethodEvaluationContextProvider evaluationContextProvider) {
+
+ ParameterBindingContext bindingContext = new ParameterBindingContext((accessor::getBindableValue), expressionParser,
+ evaluationContextProvider.getEvaluationContext(method.getParameters(), accessor.getValues()));
+
+ List target = new ArrayList<>(method.getAnnotatedAggregation().length);
+ for (String source : method.getAnnotatedAggregation()) {
+ target.add(ctx -> ctx.getMappedObject(CODEC.decode(source, bindingContext), method.getDomainClass()));
+ }
+ return target;
+ }
+
+ /**
+ * Append {@code $sort} aggregation stage if {@link ConvertingParameterAccessor#getSort()} is present.
+ *
+ * @param aggregationPipeline
+ * @param accessor
+ * @param targetType
+ */
+ static void appendSortIfPresent(List aggregationPipeline, ConvertingParameterAccessor accessor,
+ Class> targetType) {
+
+ if (accessor.getSort().isUnsorted()) {
+ return;
+ }
+
+ aggregationPipeline.add(ctx -> {
+
+ Document sort = new Document();
+ for (Order order : accessor.getSort()) {
+ sort.append(order.getProperty(), order.isAscending() ? 1 : -1);
+ }
+
+ return ctx.getMappedObject(new Document("$sort", sort), targetType);
+ });
+ }
+
+ /**
+ * Append {@code $skip} and {@code $limit} aggregation stage if {@link ConvertingParameterAccessor#getSort()} is
+ * present.
+ *
+ * @param aggregationPipeline
+ * @param accessor
+ */
+ static void appendLimitAndOffsetIfPresent(List aggregationPipeline,
+ ConvertingParameterAccessor accessor) {
+
+ Pageable pageable = accessor.getPageable();
+ if (pageable.isUnpaged()) {
+ return;
+ }
+
+ if (pageable.getOffset() > 0) {
+ aggregationPipeline.add(Aggregation.skip(pageable.getOffset()));
+ }
+
+ aggregationPipeline.add(Aggregation.limit(pageable.getPageSize()));
+ }
+
+ /**
+ * Extract a single entry from the given {@link Document}.
+ *
+ * - empty source: {@literal null}
+ * - single entry convert that one
+ * - single entry when ignoring {@literal _id} field convert that one
+ * - multiple entries first value assignable to the target type
+ * - no match IllegalArgumentException
+ *
+ *
+ * @param
+ * @param source
+ * @param targetType
+ * @param converter
+ * @return can be {@literal null} if source {@link Document#isEmpty() is empty}.
+ * @throws IllegalArgumentException when none of the above rules is met.
+ */
+ @Nullable
+ static T extractSimpleTypeResult(Document source, Class targetType, MongoConverter converter) {
+
+ if (source.isEmpty()) {
+ return null;
+ }
+
+ if (source.size() == 1) {
+ return getPotentiallyConvertedSimpleTypeValue(converter, source.values().iterator().next(), targetType);
+ }
+
+ Document intermediate = new Document(source);
+ intermediate.remove("_id");
+
+ if (intermediate.size() == 1) {
+ return getPotentiallyConvertedSimpleTypeValue(converter, intermediate.values().iterator().next(), targetType);
+ }
+
+ for (Map.Entry entry : intermediate.entrySet()) {
+ if (entry != null && ClassUtils.isAssignable(targetType, entry.getValue().getClass())) {
+ return targetType.cast(entry.getValue());
+ }
+ }
+
+ throw new IllegalArgumentException(
+ String.format("o_O no entry of type %s found in %s.", targetType.getSimpleName(), source.toJson()));
+ }
+
+ @Nullable
+ @SuppressWarnings("unchecked")
+ private static T getPotentiallyConvertedSimpleTypeValue(MongoConverter converter, @Nullable Object value,
+ Class targetType) {
+
+ if (value == null) {
+ return null;
+ }
+
+ if (ClassUtils.isAssignableValue(targetType, value)) {
+ return (T) value;
+ }
+
+ return converter.getConversionService().convert(value, targetType);
+ }
+}
diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/query/CollationUtils.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/query/CollationUtils.java
new file mode 100644
index 0000000000..c8ceac681a
--- /dev/null
+++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/query/CollationUtils.java
@@ -0,0 +1,110 @@
+/*
+ * Copyright 2019 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.repository.query;
+
+import lombok.experimental.UtilityClass;
+
+import java.util.Locale;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import org.bson.Document;
+import org.springframework.data.mongodb.core.query.Collation;
+import org.springframework.data.mongodb.util.json.ParameterBindingContext;
+import org.springframework.data.mongodb.util.json.ParameterBindingDocumentCodec;
+import org.springframework.data.repository.query.QueryMethodEvaluationContextProvider;
+import org.springframework.expression.spel.standard.SpelExpressionParser;
+import org.springframework.lang.Nullable;
+import org.springframework.util.NumberUtils;
+import org.springframework.util.ObjectUtils;
+import org.springframework.util.StringUtils;
+
+/**
+ * Internal utility class to help avoid duplicate code required in both the reactive and the sync {@link Collation}
+ * support offered by repositories.
+ *
+ * @author Christoph Strobl
+ * @since 2.2
+ */
+@UtilityClass
+class CollationUtils {
+
+ private static final ParameterBindingDocumentCodec CODEC = new ParameterBindingDocumentCodec();
+ private static final Pattern PARAMETER_BINDING_PATTERN = Pattern.compile("\\?(\\d+)");
+
+ /**
+ * Compute the {@link Collation} by inspecting the {@link ConvertingParameterAccessor#getCollation() parameter
+ * accessor} or parsing a potentially given {@literal collationExpression}.
+ *
+ * @param collationExpression
+ * @param accessor
+ * @param parameters
+ * @param expressionParser
+ * @param evaluationContextProvider
+ * @return can be {@literal null} if neither {@link ConvertingParameterAccessor#getCollation()} nor
+ * {@literal collationExpression} are present.
+ */
+ @Nullable
+ static Collation computeCollation(@Nullable String collationExpression, ConvertingParameterAccessor accessor,
+ MongoParameters parameters, SpelExpressionParser expressionParser,
+ QueryMethodEvaluationContextProvider evaluationContextProvider) {
+
+ if (accessor.getCollation() != null) {
+ return accessor.getCollation();
+ }
+
+ if (!StringUtils.hasText(collationExpression)) {
+ return null;
+ }
+
+ if (StringUtils.trimLeadingWhitespace(collationExpression).startsWith("{")) {
+
+ ParameterBindingContext bindingContext = new ParameterBindingContext((accessor::getBindableValue),
+ expressionParser, evaluationContextProvider.getEvaluationContext(parameters, accessor.getValues()));
+
+ return Collation.from(CODEC.decode(collationExpression, bindingContext));
+ }
+
+ Matcher matcher = PARAMETER_BINDING_PATTERN.matcher(collationExpression);
+ if (!matcher.find()) {
+ return Collation.parse(collationExpression);
+ }
+
+ String placeholder = matcher.group();
+ Object placeholderValue = accessor.getBindableValue(computeParameterIndex(placeholder));
+
+ if (collationExpression.startsWith("?")) {
+
+ if (placeholderValue instanceof String) {
+ return Collation.parse(placeholderValue.toString());
+ }
+ if (placeholderValue instanceof Locale) {
+ return Collation.of((Locale) placeholderValue);
+ }
+ if (placeholderValue instanceof Document) {
+ return Collation.from((Document) placeholderValue);
+ }
+ throw new IllegalArgumentException(String.format("Collation must be a String, Locale or Document but was %s",
+ ObjectUtils.nullSafeClassName(placeholderValue)));
+ }
+
+ return Collation.parse(collationExpression.replace(placeholder, placeholderValue.toString()));
+ }
+
+ private static int computeParameterIndex(String parameter) {
+ return NumberUtils.parseNumber(parameter.replace("?", ""), Integer.class);
+ }
+}
diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/query/MongoQueryMethod.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/query/MongoQueryMethod.java
index 79caf54ec5..a8d244424d 100644
--- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/query/MongoQueryMethod.java
+++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/query/MongoQueryMethod.java
@@ -30,6 +30,7 @@
import org.springframework.data.mapping.context.MappingContext;
import org.springframework.data.mongodb.core.mapping.MongoPersistentEntity;
import org.springframework.data.mongodb.core.mapping.MongoPersistentProperty;
+import org.springframework.data.mongodb.repository.Aggregation;
import org.springframework.data.mongodb.repository.Meta;
import org.springframework.data.mongodb.repository.Query;
import org.springframework.data.mongodb.repository.Tailable;
@@ -167,6 +168,14 @@ public MongoEntityMetadata> getEntityInformation() {
return this.metadata;
}
+ /*
+ * (non-Javadoc)
+ * @see org.springframework.data.repository.query.QueryMethod#getDomainClass()
+ */
+ protected Class> getDomainClass() {
+ return super.getDomainClass();
+ }
+
/*
* (non-Javadoc)
* @see org.springframework.data.repository.query.QueryMethod#getParameters()
@@ -305,7 +314,7 @@ public org.springframework.data.mongodb.core.query.Meta getQueryMetaAttributes()
* @since 2.1
*/
public boolean hasAnnotatedSort() {
- return lookupQueryAnnotation().map(it -> !it.sort().isEmpty()).orElse(false);
+ return lookupQueryAnnotation().map(Query::sort).filter(StringUtils::hasText).isPresent();
}
/**
@@ -323,27 +332,71 @@ public String getAnnotatedSort() {
}
/**
- * Check if the query method is decorated with an non empty {@link Query#collation()}.
+ * Check if the query method is decorated with an non empty {@link Query#collation()} or or
+ * {@link Aggregation#collation()}.
*
- * @return true if method annotated with {@link Query} having an non empty collation attribute.
+ * @return true if method annotated with {@link Query} or {@link Aggregation} having a non-empty collation attribute.
* @since 2.2
*/
public boolean hasAnnotatedCollation() {
- return lookupQueryAnnotation().map(it -> !it.collation().isEmpty()).orElse(false);
+
+ Optional optionalCollation = lookupQueryAnnotation().map(Query::collation);
+
+ if (!optionalCollation.isPresent()) {
+ optionalCollation = lookupAggregationAnnotation().map(Aggregation::collation);
+ }
+
+ return optionalCollation.filter(StringUtils::hasText).isPresent();
}
/**
- * Get the collation value extracted from the {@link Query} annotation.
+ * Get the collation value extracted from the {@link Query} or {@link Aggregation} annotation.
*
- * @return the {@link Query#collation()} value.
- * @throws IllegalStateException if method not annotated with {@link Query}. Make sure to check
+ * @return the {@link Query#collation()} or or {@link Aggregation#collation()} value.
+ * @throws IllegalStateException if method not annotated with {@link Query} or {@link Aggregation}. Make sure to check
* {@link #hasAnnotatedQuery()} first.
* @since 2.2
*/
public String getAnnotatedCollation() {
- return lookupQueryAnnotation().map(Query::collation).orElseThrow(() -> new IllegalStateException(
- "Expected to find @Query annotation but did not. Make sure to check hasAnnotatedCollation() before."));
+ return lookupQueryAnnotation().map(Query::collation)
+ .orElseGet(() -> lookupAggregationAnnotation().map(Aggregation::collation) //
+ .orElseThrow(() -> new IllegalStateException(
+ "Expected to find @Query annotation but did not. Make sure to check hasAnnotatedCollation() before.")));
+ }
+
+ /**
+ * Returns whether the method has an annotated query.
+ *
+ * @return true if {@link Aggregation} is present.
+ * @since 2.2
+ */
+ public boolean hasAnnotatedAggregation() {
+ return findAnnotatedAggregation().isPresent();
+ }
+
+ /**
+ * Returns the aggregation pipeline declared in a {@link Aggregation} annotation.
+ *
+ * @return the aggregation pipeline.
+ * @throws IllegalStateException if method not annotated with {@link Aggregation}. Make sure to check
+ * {@link #hasAnnotatedAggregation()} first.
+ * @since 2.2
+ */
+ public String[] getAnnotatedAggregation() {
+ return findAnnotatedAggregation().orElseThrow(() -> new IllegalStateException(
+ "Expected to find @Aggregation annotation but did not. Make sure to check hasAnnotatedAggregation() before."));
+ }
+
+ private Optional findAnnotatedAggregation() {
+
+ return lookupAggregationAnnotation() //
+ .map(Aggregation::pipeline) //
+ .filter(it -> !ObjectUtils.isEmpty(it));
+ }
+
+ Optional lookupAggregationAnnotation() {
+ return doFindAnnotation(Aggregation.class);
}
@SuppressWarnings("unchecked")
diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/query/QueryUtils.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/query/QueryUtils.java
index d73f0b4152..672afb9d26 100644
--- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/query/QueryUtils.java
+++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/query/QueryUtils.java
@@ -15,24 +15,14 @@
*/
package org.springframework.data.mongodb.repository.query;
-import java.util.Locale;
-import java.util.regex.Matcher;
-import java.util.regex.Pattern;
-
import org.aopalliance.intercept.MethodInterceptor;
import org.bson.Document;
-
import org.springframework.aop.framework.ProxyFactory;
import org.springframework.data.mongodb.core.query.Collation;
import org.springframework.data.mongodb.core.query.Query;
-import org.springframework.data.mongodb.util.json.ParameterBindingContext;
-import org.springframework.data.mongodb.util.json.ParameterBindingDocumentCodec;
import org.springframework.data.repository.query.QueryMethodEvaluationContextProvider;
import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.lang.Nullable;
-import org.springframework.util.NumberUtils;
-import org.springframework.util.ObjectUtils;
-import org.springframework.util.StringUtils;
/**
* Internal utility class to help avoid duplicate code required in both the reactive and the sync {@link Query} support
@@ -45,10 +35,6 @@
*/
class QueryUtils {
- private static final ParameterBindingDocumentCodec CODEC = new ParameterBindingDocumentCodec();
-
- private static final Pattern PARAMETER_BINDING_PATTERN = Pattern.compile("\\?(\\d+)");
-
/**
* Decorate {@link Query} and add a default sort expression to the given {@link Query}. Attributes of the given
* {@code sort} may be overwritten by the sort explicitly defined by the {@link Query} itself.
@@ -93,49 +79,8 @@ static Query applyCollation(Query query, @Nullable String collationExpression, C
MongoParameters parameters, SpelExpressionParser expressionParser,
QueryMethodEvaluationContextProvider evaluationContextProvider) {
- if (accessor.getCollation() != null) {
- return query.collation(accessor.getCollation());
- }
-
- if (collationExpression == null) {
- return query;
- }
-
- if (StringUtils.trimLeadingWhitespace(collationExpression).startsWith("{")) {
-
- ParameterBindingContext bindingContext = new ParameterBindingContext((accessor::getBindableValue),
- expressionParser, evaluationContextProvider.getEvaluationContext(parameters, accessor.getValues()));
-
- return query.collation(Collation.from(CODEC.decode(collationExpression, bindingContext)));
- }
-
- Matcher matcher = PARAMETER_BINDING_PATTERN.matcher(collationExpression);
- if (!matcher.find()) {
- return query.collation(Collation.parse(collationExpression));
- }
-
- String placeholder = matcher.group();
- Object placeholderValue = accessor.getBindableValue(computeParameterIndex(placeholder));
-
- if (collationExpression.startsWith("?")) {
-
- if (placeholderValue instanceof String) {
- return query.collation(Collation.parse(placeholderValue.toString()));
- }
- if (placeholderValue instanceof Locale) {
- return query.collation(Collation.of((Locale) placeholderValue));
- }
- if (placeholderValue instanceof Document) {
- return query.collation(Collation.from((Document) placeholderValue));
- }
- throw new IllegalArgumentException(String.format("Collation must be a String, Locale or Document but was %s",
- ObjectUtils.nullSafeClassName(placeholderValue)));
- }
-
- return query.collation(Collation.parse(collationExpression.replace(placeholder, placeholderValue.toString())));
- }
-
- private static int computeParameterIndex(String parameter) {
- return NumberUtils.parseNumber(parameter.replace("?", ""), Integer.class);
+ Collation collation = CollationUtils.computeCollation(collationExpression, accessor, parameters, expressionParser,
+ evaluationContextProvider);
+ return collation == null ? query : query.collation(collation);
}
}
diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/query/ReactiveStringBasedAggregation.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/query/ReactiveStringBasedAggregation.java
new file mode 100644
index 0000000000..c362366c13
--- /dev/null
+++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/query/ReactiveStringBasedAggregation.java
@@ -0,0 +1,168 @@
+/*
+ * Copyright 2019 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.repository.query;
+
+import reactor.core.publisher.Flux;
+
+import java.util.List;
+
+import org.bson.Document;
+
+import org.springframework.data.mongodb.core.ReactiveMongoOperations;
+import org.springframework.data.mongodb.core.aggregation.Aggregation;
+import org.springframework.data.mongodb.core.aggregation.AggregationOperation;
+import org.springframework.data.mongodb.core.aggregation.AggregationOptions;
+import org.springframework.data.mongodb.core.aggregation.TypedAggregation;
+import org.springframework.data.mongodb.core.convert.MongoConverter;
+import org.springframework.data.mongodb.core.mapping.MongoSimpleTypes;
+import org.springframework.data.mongodb.core.query.Query;
+import org.springframework.data.repository.query.QueryMethodEvaluationContextProvider;
+import org.springframework.data.repository.query.ResultProcessor;
+import org.springframework.expression.spel.standard.SpelExpressionParser;
+import org.springframework.util.ClassUtils;
+
+/**
+ * A reactive {@link org.springframework.data.repository.query.RepositoryQuery} to use a plain JSON String to create an
+ * {@link AggregationOperation aggregation} pipeline to actually execute.
+ *
+ * @author Christoph Strobl
+ * @author Mark Paluch
+ * @since 2.2
+ */
+public class ReactiveStringBasedAggregation extends AbstractReactiveMongoQuery {
+
+ private final SpelExpressionParser expressionParser;
+ private final QueryMethodEvaluationContextProvider evaluationContextProvider;
+ private final ReactiveMongoOperations reactiveMongoOperations;
+ private final MongoConverter mongoConverter;
+
+ /**
+ * @param method must not be {@literal null}.
+ * @param reactiveMongoOperations must not be {@literal null}.
+ * @param expressionParser must not be {@literal null}.
+ * @param evaluationContextProvider must not be {@literal null}.
+ */
+ public ReactiveStringBasedAggregation(ReactiveMongoQueryMethod method,
+ ReactiveMongoOperations reactiveMongoOperations, SpelExpressionParser expressionParser,
+ QueryMethodEvaluationContextProvider evaluationContextProvider) {
+
+ super(method, reactiveMongoOperations, expressionParser, evaluationContextProvider);
+
+ this.reactiveMongoOperations = reactiveMongoOperations;
+ this.mongoConverter = reactiveMongoOperations.getConverter();
+ this.expressionParser = expressionParser;
+ this.evaluationContextProvider = evaluationContextProvider;
+ }
+
+ /*
+ * (non-Javascript)
+ * @see org.springframework.data.mongodb.repository.query.AbstractReactiveMongoQuery#doExecute(org.springframework.data.mongodb.repository.query.ReactiveMongoQueryMethod, org.springframework.data.repository.query.ResultProcessor, org.springframework.data.mongodb.repository.query.ConvertingParameterAccessor, java.lang.Class)
+ */
+ @Override
+ protected Object doExecute(ReactiveMongoQueryMethod method, ResultProcessor processor,
+ ConvertingParameterAccessor accessor, Class> typeToRead) {
+
+ Class> sourceType = method.getDomainClass();
+ Class> targetType = typeToRead;
+
+ List pipeline = computePipeline(accessor);
+ AggregationUtils.appendSortIfPresent(pipeline, accessor, typeToRead);
+ AggregationUtils.appendLimitAndOffsetIfPresent(pipeline, accessor);
+
+ boolean isSimpleReturnType = isSimpleReturnType(typeToRead);
+ boolean isRawReturnType = ClassUtils.isAssignable(org.bson.Document.class, typeToRead);
+
+ if (isSimpleReturnType || isRawReturnType) {
+ targetType = Document.class;
+ }
+
+ AggregationOptions options = computeOptions(method, accessor);
+ TypedAggregation> aggregation = new TypedAggregation<>(sourceType, pipeline, options);
+
+ Flux> flux = reactiveMongoOperations.aggregate(aggregation, targetType);
+
+ if (isSimpleReturnType && !isRawReturnType) {
+ flux = flux.map(it -> AggregationUtils.extractSimpleTypeResult((Document) it, typeToRead, mongoConverter));
+ }
+
+ if (method.isCollectionQuery()) {
+ return flux;
+ } else {
+ return flux.next();
+ }
+ }
+
+ private boolean isSimpleReturnType(Class> targetType) {
+ return MongoSimpleTypes.HOLDER.isSimpleType(targetType);
+ }
+
+ List computePipeline(ConvertingParameterAccessor accessor) {
+ return AggregationUtils.computePipeline(getQueryMethod(), accessor, expressionParser, evaluationContextProvider);
+ }
+
+ private AggregationOptions computeOptions(MongoQueryMethod method, ConvertingParameterAccessor accessor) {
+
+ return AggregationUtils
+ .applyCollation(Aggregation.newAggregationOptions(), method.getAnnotatedCollation(), accessor,
+ method.getParameters(), expressionParser, evaluationContextProvider) //
+ .build();
+ }
+
+ /*
+ * (non-Javascript)
+ * @see org.springframework.data.mongodb.repository.query.AbstractReactiveMongoQuery#createQuery(org.springframework.data.mongodb.repository.query.ConvertingParameterAccessor)
+ */
+ @Override
+ protected Query createQuery(ConvertingParameterAccessor accessor) {
+ throw new UnsupportedOperationException("No query support for aggregation");
+ }
+
+ /*
+ * (non-Javascript)
+ * @see org.springframework.data.mongodb.repository.query.AbstractReactiveMongoQuery#isCountQuery()
+ */
+ @Override
+ protected boolean isCountQuery() {
+ return false;
+ }
+
+ /*
+ * (non-Javascript)
+ * @see org.springframework.data.mongodb.repository.query.AbstractReactiveMongoQuery#isExistsQuery()
+ */
+ @Override
+ protected boolean isExistsQuery() {
+ return false;
+ }
+
+ /*
+ * (non-Javascript)
+ * @see org.springframework.data.mongodb.repository.query.AbstractReactiveMongoQuery#isDeleteQuery()
+ */
+ @Override
+ protected boolean isDeleteQuery() {
+ return false;
+ }
+
+ /*
+ * (non-Javascript)
+ * @see org.springframework.data.mongodb.repository.query.AbstractReactiveMongoQuery#isLimiting()
+ */
+ @Override
+ protected boolean isLimiting() {
+ return false;
+ }
+}
diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/query/StringBasedAggregation.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/query/StringBasedAggregation.java
new file mode 100644
index 0000000000..ff6fa84b2a
--- /dev/null
+++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/query/StringBasedAggregation.java
@@ -0,0 +1,178 @@
+/*
+ * Copyright 2019 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.repository.query;
+
+import java.util.List;
+import java.util.stream.Collectors;
+
+import org.bson.Document;
+
+import org.springframework.data.mongodb.core.MongoOperations;
+import org.springframework.data.mongodb.core.aggregation.Aggregation;
+import org.springframework.data.mongodb.core.aggregation.AggregationOperation;
+import org.springframework.data.mongodb.core.aggregation.AggregationOptions;
+import org.springframework.data.mongodb.core.aggregation.AggregationResults;
+import org.springframework.data.mongodb.core.aggregation.TypedAggregation;
+import org.springframework.data.mongodb.core.convert.MongoConverter;
+import org.springframework.data.mongodb.core.mapping.MongoSimpleTypes;
+import org.springframework.data.mongodb.core.query.Query;
+import org.springframework.data.repository.query.QueryMethodEvaluationContextProvider;
+import org.springframework.data.repository.query.ResultProcessor;
+import org.springframework.expression.spel.standard.SpelExpressionParser;
+import org.springframework.util.ClassUtils;
+
+/**
+ * @author Christoph Strobl
+ * @since 2.2
+ */
+public class StringBasedAggregation extends AbstractMongoQuery {
+
+ private final MongoOperations mongoOperations;
+ private final MongoConverter mongoConverter;
+ private final SpelExpressionParser expressionParser;
+ private final QueryMethodEvaluationContextProvider evaluationContextProvider;
+
+ /**
+ * Creates a new {@link StringBasedAggregation} from the given {@link MongoQueryMethod} and {@link MongoOperations}.
+ *
+ * @param method must not be {@literal null}.
+ * @param mongoOperations must not be {@literal null}.
+ * @param expressionParser
+ * @param evaluationContextProvider
+ */
+ public StringBasedAggregation(MongoQueryMethod method, MongoOperations mongoOperations,
+ SpelExpressionParser expressionParser, QueryMethodEvaluationContextProvider evaluationContextProvider) {
+ super(method, mongoOperations, expressionParser, evaluationContextProvider);
+
+ this.mongoOperations = mongoOperations;
+ this.mongoConverter = mongoOperations.getConverter();
+ this.expressionParser = expressionParser;
+ this.evaluationContextProvider = evaluationContextProvider;
+ }
+
+ /*
+ * (non-Javascript)
+ * @see org.springframework.data.mongodb.repository.query.AbstractReactiveMongoQuery#doExecute(org.springframework.data.mongodb.repository.query.MongoQueryMethod, org.springframework.data.repository.query.ResultProcessor, org.springframework.data.mongodb.repository.query.ConvertingParameterAccessor, java.lang.Class)
+ */
+ @Override
+ protected Object doExecute(MongoQueryMethod method, ResultProcessor resultProcessor,
+ ConvertingParameterAccessor accessor, Class> typeToRead) {
+
+ Class> sourceType = method.getDomainClass();
+ Class> targetType = typeToRead;
+
+ List pipeline = computePipeline(method, accessor);
+ AggregationUtils.appendSortIfPresent(pipeline, accessor, typeToRead);
+ AggregationUtils.appendLimitAndOffsetIfPresent(pipeline, accessor);
+
+ boolean isSimpleReturnType = isSimpleReturnType(typeToRead);
+ boolean isRawAggregationResult = ClassUtils.isAssignable(AggregationResults.class, typeToRead);
+
+ if (isSimpleReturnType) {
+ targetType = Document.class;
+ } else if (isRawAggregationResult) {
+ targetType = method.getReturnType().getRequiredActualType().getRequiredComponentType().getType();
+ }
+
+ AggregationOptions options = computeOptions(method, accessor);
+ TypedAggregation> aggregation = new TypedAggregation<>(sourceType, pipeline, options);
+
+ AggregationResults> result = mongoOperations.aggregate(aggregation, targetType);
+
+ if (isRawAggregationResult) {
+ return result;
+ }
+
+ if (method.isCollectionQuery()) {
+
+ if (isSimpleReturnType) {
+
+ return result.getMappedResults().stream()
+ .map(it -> AggregationUtils.extractSimpleTypeResult((Document) it, typeToRead, mongoConverter))
+ .collect(Collectors.toList());
+ }
+
+ return result.getMappedResults();
+ }
+
+ Object uniqueResult = result.getUniqueMappedResult();
+
+ return isSimpleReturnType
+ ? AggregationUtils.extractSimpleTypeResult((Document) uniqueResult, typeToRead, mongoConverter)
+ : uniqueResult;
+ }
+
+ private boolean isSimpleReturnType(Class> targetType) {
+ return MongoSimpleTypes.HOLDER.isSimpleType(targetType);
+ }
+
+ List computePipeline(MongoQueryMethod method, ConvertingParameterAccessor accessor) {
+ return AggregationUtils.computePipeline(method, accessor, expressionParser, evaluationContextProvider);
+ }
+
+ private AggregationOptions computeOptions(MongoQueryMethod method, ConvertingParameterAccessor accessor) {
+
+ return AggregationUtils
+ .applyCollation(Aggregation.newAggregationOptions(), method.getAnnotatedCollation(), accessor,
+ method.getParameters(), expressionParser, evaluationContextProvider) //
+ .build();
+ }
+
+ /*
+ * (non-Javascript)
+ * @see org.springframework.data.mongodb.repository.query.AbstractMongoQuery#createQuery(org.springframework.data.mongodb.repository.query.ConvertingParameterAccessor)
+ */
+ @Override
+ protected Query createQuery(ConvertingParameterAccessor accessor) {
+ throw new UnsupportedOperationException("No query support for aggregation");
+ }
+
+ /*
+ * (non-Javascript)
+ * @see org.springframework.data.mongodb.repository.query.AbstractMongoQuery#isCountQuery()
+ */
+ @Override
+ protected boolean isCountQuery() {
+ return false;
+ }
+
+ /*
+ * (non-Javascript)
+ * @see org.springframework.data.mongodb.repository.query.AbstractMongoQuery#isExistsQuery()
+ */
+ @Override
+ protected boolean isExistsQuery() {
+ return false;
+ }
+
+ /*
+ * (non-Javascript)
+ * @see org.springframework.data.mongodb.repository.query.AbstractMongoQuery#isDeleteQuery()
+ */
+ @Override
+ protected boolean isDeleteQuery() {
+ return false;
+ }
+
+ /*
+ * (non-Javascript)
+ * @see org.springframework.data.mongodb.repository.query.AbstractMongoQuery#isLimiting()
+ */
+ @Override
+ protected boolean isLimiting() {
+ return false;
+ }
+}
diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/support/MongoRepositoryFactory.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/support/MongoRepositoryFactory.java
index eeff22eb47..c3d22bd8ae 100644
--- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/support/MongoRepositoryFactory.java
+++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/support/MongoRepositoryFactory.java
@@ -30,6 +30,7 @@
import org.springframework.data.mongodb.repository.query.MongoEntityInformation;
import org.springframework.data.mongodb.repository.query.MongoQueryMethod;
import org.springframework.data.mongodb.repository.query.PartTreeMongoQuery;
+import org.springframework.data.mongodb.repository.query.StringBasedAggregation;
import org.springframework.data.mongodb.repository.query.StringBasedMongoQuery;
import org.springframework.data.projection.ProjectionFactory;
import org.springframework.data.querydsl.QuerydslPredicateExecutor;
@@ -187,6 +188,8 @@ public RepositoryQuery resolveQuery(Method method, RepositoryMetadata metadata,
String namedQuery = namedQueries.getQuery(namedQueryName);
return new StringBasedMongoQuery(namedQuery, queryMethod, operations, EXPRESSION_PARSER,
evaluationContextProvider);
+ } else if (queryMethod.hasAnnotatedAggregation()) {
+ return new StringBasedAggregation(queryMethod, operations, EXPRESSION_PARSER, evaluationContextProvider);
} else if (queryMethod.hasAnnotatedQuery()) {
return new StringBasedMongoQuery(queryMethod, operations, EXPRESSION_PARSER, evaluationContextProvider);
} else {
diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/support/ReactiveMongoRepositoryFactory.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/support/ReactiveMongoRepositoryFactory.java
index c5001dcf23..6773e44882 100644
--- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/support/ReactiveMongoRepositoryFactory.java
+++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/repository/support/ReactiveMongoRepositoryFactory.java
@@ -32,6 +32,7 @@
import org.springframework.data.mongodb.repository.query.PartTreeMongoQuery;
import org.springframework.data.mongodb.repository.query.ReactiveMongoQueryMethod;
import org.springframework.data.mongodb.repository.query.ReactivePartTreeMongoQuery;
+import org.springframework.data.mongodb.repository.query.ReactiveStringBasedAggregation;
import org.springframework.data.mongodb.repository.query.ReactiveStringBasedMongoQuery;
import org.springframework.data.projection.ProjectionFactory;
import org.springframework.data.querydsl.ReactiveQuerydslPredicateExecutor;
@@ -154,6 +155,7 @@ private MongoEntityInformation getEntityInformation(Class doma
* {@link QueryLookupStrategy} to create {@link PartTreeMongoQuery} instances.
*
* @author Mark Paluch
+ * @author Christoph Strobl
*/
@RequiredArgsConstructor(access = AccessLevel.PACKAGE)
private static class MongoQueryLookupStrategy implements QueryLookupStrategy {
@@ -177,6 +179,9 @@ public RepositoryQuery resolveQuery(Method method, RepositoryMetadata metadata,
String namedQuery = namedQueries.getQuery(namedQueryName);
return new ReactiveStringBasedMongoQuery(namedQuery, queryMethod, operations, EXPRESSION_PARSER,
evaluationContextProvider);
+ } else if (queryMethod.hasAnnotatedAggregation()) {
+ return new ReactiveStringBasedAggregation(queryMethod, operations, EXPRESSION_PARSER,
+ evaluationContextProvider);
} else if (queryMethod.hasAnnotatedQuery()) {
return new ReactiveStringBasedMongoQuery(queryMethod, operations, EXPRESSION_PARSER, evaluationContextProvider);
} else {
diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/util/json/ParameterBindingJsonReader.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/util/json/ParameterBindingJsonReader.java
index 4c69b3d76c..ba61e487cc 100644
--- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/util/json/ParameterBindingJsonReader.java
+++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/util/json/ParameterBindingJsonReader.java
@@ -36,13 +36,13 @@
import org.bson.types.MaxKey;
import org.bson.types.MinKey;
import org.bson.types.ObjectId;
-
import org.springframework.data.spel.EvaluationContextProvider;
import org.springframework.expression.EvaluationContext;
import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.lang.Nullable;
import org.springframework.util.ClassUtils;
import org.springframework.util.NumberUtils;
+import org.springframework.util.ObjectUtils;
/**
* Reads a JSON and evaluates placehoders and SpEL expressions. Modified version of userList = IntStream.range(0, 10).mapToObj(it -> {
User user = new User();
@@ -1294,4 +1298,71 @@ public void annotatedQueryShouldAllowPositionalParameterInFieldsProjectionWithDb
assertThat(target).isNotNull();
assertThat(target.getFans()).hasSize(1);
}
+
+ @Test // DATAMONGO-2153
+ public void findListOfSingleValue() {
+
+ assertThat(repository.findAllLastnames()) //
+ .contains("Lessard") //
+ .contains("Keys") //
+ .contains("Tinsley") //
+ .contains("Beauford") //
+ .contains("Moore") //
+ .contains("Matthews"); //
+ }
+
+ @Test // DATAMONGO-2153
+ public void annotatedAggregationWithPlaceholderValue() {
+
+ assertThat(repository.groupByLastnameAnd("firstname"))
+ .contains(new PersonAggregate("Lessard", Collections.singletonList("Stefan"))) //
+ .contains(new PersonAggregate("Keys", Collections.singletonList("Alicia"))) //
+ .contains(new PersonAggregate("Tinsley", Collections.singletonList("Boyd"))) //
+ .contains(new PersonAggregate("Beauford", Collections.singletonList("Carter"))) //
+ .contains(new PersonAggregate("Moore", Collections.singletonList("Leroi"))) //
+ .contains(new PersonAggregate("Matthews", Arrays.asList("Dave", "Oliver August")));
+ }
+
+ @Test // DATAMONGO-2153
+ public void annotatedAggregationWithSort() {
+
+ assertThat(repository.groupByLastnameAnd("firstname", Sort.by("lastname"))) //
+ .containsSequence( //
+ new PersonAggregate("Beauford", Collections.singletonList("Carter")), //
+ new PersonAggregate("Keys", Collections.singletonList("Alicia")), //
+ new PersonAggregate("Lessard", Collections.singletonList("Stefan")), //
+ new PersonAggregate("Matthews", Arrays.asList("Dave", "Oliver August")), //
+ new PersonAggregate("Moore", Collections.singletonList("Leroi")), //
+ new PersonAggregate("Tinsley", Collections.singletonList("Boyd")));
+ }
+
+ @Test // DATAMONGO-2153
+ public void annotatedAggregationWithPageable() {
+
+ assertThat(repository.groupByLastnameAnd("firstname", PageRequest.of(1, 2, Sort.by("lastname")))) //
+ .containsExactly( //
+ new PersonAggregate("Lessard", Collections.singletonList("Stefan")), //
+ new PersonAggregate("Matthews", Arrays.asList("Dave", "Oliver August")));
+ }
+
+ @Test // DATAMONGO-2153
+ public void annotatedAggregationWithSingleSimpleResult() {
+ assertThat(repository.sumAge()).isEqualTo(245);
+ }
+
+ @Test // DATAMONGO-2153
+ public void annotatedAggregationWithAggregationResultAsReturnType() {
+
+ assertThat(repository.sumAgeAndReturnAggregationResultWrapper()) //
+ .isInstanceOf(AggregationResults.class) //
+ .containsExactly(new Document("_id", null).append("total", 245));
+ }
+
+ @Test // DATAMONGO-2153
+ public void annotatedAggregationWithAggregationResultAsReturnTypeAndProjection() {
+
+ assertThat(repository.sumAgeAndReturnAggregationResultWrapperWithConcreteType()) //
+ .isInstanceOf(AggregationResults.class) //
+ .containsExactly(new SumAge(245L));
+ }
}
diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/repository/PersonAggregate.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/repository/PersonAggregate.java
new file mode 100644
index 0000000000..7e4ec3337e
--- /dev/null
+++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/repository/PersonAggregate.java
@@ -0,0 +1,45 @@
+/*
+ * Copyright 2019 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.repository;
+
+import lombok.Value;
+
+import java.util.Collections;
+import java.util.List;
+
+import org.springframework.data.annotation.Id;
+import org.springframework.data.annotation.PersistenceConstructor;
+
+/**
+ * @author Christoph Strobl
+ * @author Mark Paluch
+ */
+@Value
+class PersonAggregate {
+
+ @Id private String lastname;
+ private List names;
+
+ @PersistenceConstructor
+ public PersonAggregate(String lastname, List names) {
+ this.lastname = lastname;
+ this.names = names;
+ }
+
+ public PersonAggregate(String lastname, String name) {
+ this(lastname, Collections.singletonList(name));
+ }
+}
diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/repository/PersonRepository.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/repository/PersonRepository.java
index 41d8b65f0a..19b445ec52 100644
--- a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/repository/PersonRepository.java
+++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/repository/PersonRepository.java
@@ -35,6 +35,8 @@
import org.springframework.data.geo.GeoResults;
import org.springframework.data.geo.Point;
import org.springframework.data.geo.Polygon;
+import org.springframework.data.mongodb.core.aggregation.AggregationResults;
+import org.springframework.data.mongodb.core.mapping.Document;
import org.springframework.data.mongodb.repository.Person.Sex;
import org.springframework.data.querydsl.QuerydslPredicateExecutor;
import org.springframework.data.repository.query.Param;
@@ -363,6 +365,27 @@ Page findByCustomQueryLastnameAndAddressStreetInList(String lastname, Li
@Query(value = "{ 'shippingAddresses' : { '$elemMatch' : { 'city' : { '$eq' : 'lnz' } } } }", fields = "{ 'shippingAddresses.$': ?0 }")
Person findWithArrayPositionInProjection(int position);
- @Query(value = "{ 'fans' : { '$elemMatch' : { '$ref' : 'user' } } }", fields = "{ 'fans.$': ?0 }")
- Person findWithArrayPositionInProjectionWithDbRef(int position);
+ @Query(value = "{ 'fans' : { '$elemMatch' : { '$ref' : 'user' } } }", fields = "{ 'fans.$': ?0 }")
+ Person findWithArrayPositionInProjectionWithDbRef(int position);
+
+ @Aggregation("{ '$project': { '_id' : '$lastname' } }")
+ List findAllLastnames();
+
+ @Aggregation("{ '$group': { '_id' : '$lastname', names : { $addToSet : '$?0' } } }")
+ List groupByLastnameAnd(String property);
+
+ @Aggregation("{ '$group': { '_id' : '$lastname', names : { $addToSet : '$?0' } } }")
+ List groupByLastnameAnd(String property, Sort sort);
+
+ @Aggregation("{ '$group': { '_id' : '$lastname', names : { $addToSet : '$?0' } } }")
+ List groupByLastnameAnd(String property, Pageable page);
+
+ @Aggregation(pipeline = "{ '$group' : { '_id' : null, 'total' : { $sum: '$age' } } }")
+ int sumAge();
+
+ @Aggregation(pipeline = "{ '$group' : { '_id' : null, 'total' : { $sum: '$age' } } }")
+ AggregationResults sumAgeAndReturnAggregationResultWrapper();
+
+ @Aggregation(pipeline = "{ '$group' : { '_id' : null, 'total' : { $sum: '$age' } } }")
+ AggregationResults sumAgeAndReturnAggregationResultWrapperWithConcreteType();
}
diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/repository/ReactiveMongoRepositoryTests.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/repository/ReactiveMongoRepositoryTests.java
index c12d581c11..a036cd627f 100644
--- a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/repository/ReactiveMongoRepositoryTests.java
+++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/repository/ReactiveMongoRepositoryTests.java
@@ -15,7 +15,7 @@
*/
package org.springframework.data.mongodb.repository;
-import static org.assertj.core.api.Assertions.offset;
+import static org.assertj.core.api.Assertions.*;
import static org.springframework.data.domain.Sort.Direction.*;
import static org.springframework.data.mongodb.test.util.Assertions.assertThat;
@@ -36,6 +36,7 @@
import org.junit.Test;
import org.junit.runner.RunWith;
import org.reactivestreams.Publisher;
+
import org.springframework.beans.factory.BeanFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
@@ -156,7 +157,7 @@ public void setUp() throws Exception {
alicia = new Person("Alicia", "Keys", 30, Sex.FEMALE);
- StepVerifier.create(repository.saveAll(Arrays.asList(oliver, dave, carter, boyd, stefan, leroi, alicia))) //
+ StepVerifier.create(repository.saveAll(Arrays.asList(oliver, carter, boyd, stefan, leroi, alicia, dave))) //
.expectNextCount(7) //
.verifyComplete();
}
@@ -423,6 +424,101 @@ public void shouldFindPersonsWhenUsingQueryDslPerdicatedOnIdProperty() {
}).verifyComplete();
}
+ @Test // DATAMONGO-2153
+ public void findListOfSingleValue() {
+
+ repository.findAllLastnames() //
+ .collectList() //
+ .as(StepVerifier::create) //
+ .assertNext(actual -> {
+ assertThat(actual) //
+ .contains("Lessard") //
+ .contains("Keys") //
+ .contains("Tinsley") //
+ .contains("Beauford") //
+ .contains("Moore") //
+ .contains("Matthews");
+ }).verifyComplete();
+ }
+
+ @Test // DATAMONGO-2153
+ public void annotatedAggregationWithPlaceholderValue() {
+
+ repository.groupByLastnameAnd("firstname") //
+ .collectList() //
+ .as(StepVerifier::create) //
+ .assertNext(actual -> {
+ assertThat(actual) //
+ .contains(new PersonAggregate("Lessard", "Stefan")) //
+ .contains(new PersonAggregate("Keys", "Alicia")) //
+ .contains(new PersonAggregate("Tinsley", "Boyd")) //
+ .contains(new PersonAggregate("Beauford", "Carter")) //
+ .contains(new PersonAggregate("Moore", "Leroi")) //
+ .contains(new PersonAggregate("Matthews", Arrays.asList("Dave", "Oliver August")));
+ }).verifyComplete();
+ }
+
+ @Test // DATAMONGO-2153
+ public void annotatedAggregationWithSort() {
+
+ repository.groupByLastnameAnd("firstname", Sort.by("lastname")) //
+ .collectList() //
+ .as(StepVerifier::create) //
+ .assertNext(actual -> {
+ assertThat(actual) //
+ .containsSequence( //
+ new PersonAggregate("Beauford", "Carter"), //
+ new PersonAggregate("Keys", "Alicia"), //
+ new PersonAggregate("Lessard", "Stefan"), //
+ new PersonAggregate("Matthews", Arrays.asList("Dave", "Oliver August")), //
+ new PersonAggregate("Moore", "Leroi"), //
+ new PersonAggregate("Tinsley", "Boyd"));
+ }) //
+ .verifyComplete();
+ }
+
+ @Test // DATAMONGO-2153
+ public void annotatedAggregationWithPageable() {
+
+ repository.groupByLastnameAnd("firstname", PageRequest.of(1, 2, Sort.by("lastname"))) //
+ .collectList() //
+ .as(StepVerifier::create) //
+ .assertNext(actual -> {
+ assertThat(actual) //
+ .containsExactly( //
+ new PersonAggregate("Lessard", "Stefan"), //
+ new PersonAggregate("Matthews", Arrays.asList("Dave", "Oliver August")));
+ }) //
+ .verifyComplete();
+ }
+
+ @Test // DATAMONGO-2153
+ public void annotatedAggregationWithSingleSimpleResult() {
+
+ repository.sumAge() //
+ .as(StepVerifier::create) //
+ .expectNext(245L) //
+ .verifyComplete();
+ }
+
+ @Test // DATAMONGO-2153
+ public void annotatedAggregationWithAggregationResultAsReturnType() {
+
+ repository.sumAgeAndReturnRawResult() //
+ .as(StepVerifier::create) //
+ .expectNext(new org.bson.Document("_id", null).append("total", 245)) //
+ .verifyComplete();
+ }
+
+ @Test // DATAMONGO-2153
+ public void annotatedAggregationWithAggregationResultAsReturnTypeAndProjection() {
+
+ repository.sumAgeAndReturnSumWrapper() //
+ .as(StepVerifier::create) //
+ .expectNext(new SumAge(245L)) //
+ .verifyComplete();
+ }
+
interface ReactivePersonRepository
extends ReactiveMongoRepository, ReactiveQuerydslPredicateExecutor {
@@ -462,6 +558,27 @@ interface ReactivePersonRepository
@Query(sort = "{ age : -1 }")
Flux findByAgeGreaterThan(int age, Sort sort);
+
+ @Aggregation("{ '$project': { '_id' : '$lastname' } }")
+ Flux findAllLastnames();
+
+ @Aggregation("{ '$group': { '_id' : '$lastname', names : { $addToSet : '$?0' } } }")
+ Flux groupByLastnameAnd(String property);
+
+ @Aggregation("{ '$group': { '_id' : '$lastname', names : { $addToSet : '$?0' } } }")
+ Flux groupByLastnameAnd(String property, Sort sort);
+
+ @Aggregation("{ '$group': { '_id' : '$lastname', names : { $addToSet : '$?0' } } }")
+ Flux groupByLastnameAnd(String property, Pageable page);
+
+ @Aggregation(pipeline = "{ '$group' : { '_id' : null, 'total' : { $sum: '$age' } } }")
+ Mono sumAge();
+
+ @Aggregation(pipeline = "{ '$group' : { '_id' : null, 'total' : { $sum: '$age' } } }")
+ Mono sumAgeAndReturnRawResult();
+
+ @Aggregation(pipeline = "{ '$group' : { '_id' : null, 'total' : { $sum: '$age' } } }")
+ Mono sumAgeAndReturnSumWrapper();
}
interface ReactiveContactRepository extends ReactiveMongoRepository {}
diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/repository/SumAge.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/repository/SumAge.java
new file mode 100644
index 0000000000..ce4b41d81e
--- /dev/null
+++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/repository/SumAge.java
@@ -0,0 +1,27 @@
+/*
+ * Copyright 2019 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.repository;
+
+import lombok.Value;
+
+/**
+ * @author Christoph Strobl
+ */
+@Value
+class SumAge {
+
+ private Long total;
+}
diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/repository/query/MongoQueryMethodUnitTests.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/repository/query/MongoQueryMethodUnitTests.java
index 45466a9aba..b7fa605df2 100644
--- a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/repository/query/MongoQueryMethodUnitTests.java
+++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/repository/query/MongoQueryMethodUnitTests.java
@@ -22,6 +22,7 @@
import java.util.Collection;
import java.util.List;
+import org.assertj.core.api.Assertions;
import org.junit.Before;
import org.junit.Test;
import org.springframework.data.domain.Pageable;
@@ -33,6 +34,7 @@
import org.springframework.data.mongodb.core.User;
import org.springframework.data.mongodb.core.mapping.MongoMappingContext;
import org.springframework.data.mongodb.repository.Address;
+import org.springframework.data.mongodb.repository.Aggregation;
import org.springframework.data.mongodb.repository.Contact;
import org.springframework.data.mongodb.repository.Meta;
import org.springframework.data.mongodb.repository.Person;
@@ -217,7 +219,8 @@ public void createsMongoQueryMethodWithMultipleFlagsCorrectly() throws Exception
assertThat(method.hasQueryMetaAttributes(), is(true));
assertThat(method.getQueryMetaAttributes().getFlags(),
- containsInAnyOrder(org.springframework.data.mongodb.core.query.Meta.CursorOption.NO_TIMEOUT, org.springframework.data.mongodb.core.query.Meta.CursorOption.SLAVE_OK));
+ containsInAnyOrder(org.springframework.data.mongodb.core.query.Meta.CursorOption.NO_TIMEOUT,
+ org.springframework.data.mongodb.core.query.Meta.CursorOption.SLAVE_OK));
}
@Test // DATAMONGO-1266
@@ -228,6 +231,24 @@ public void fallsBackToRepositoryDomainTypeIfMethodDoesNotReturnADomainType() th
assertThat(method.getEntityInformation().getJavaType(), is(typeCompatibleWith(User.class)));
}
+ @Test // DATAMONGO-2153
+ public void findsAnnotatedAggregation() throws Exception {
+
+ MongoQueryMethod method = queryMethod(PersonRepository.class, "findByAggregation");
+
+ Assertions.assertThat(method.hasAnnotatedAggregation()).isTrue();
+ Assertions.assertThat(method.getAnnotatedAggregation()).hasSize(1);
+ }
+
+ @Test // DATAMONGO-2153
+ public void detectsCollationForAggregation() throws Exception {
+
+ MongoQueryMethod method = queryMethod(PersonRepository.class, "findByAggregationWithCollation");
+
+ Assertions.assertThat(method.hasAnnotatedCollation()).isTrue();
+ Assertions.assertThat(method.getAnnotatedCollation()).isEqualTo("de_AT");
+ }
+
private MongoQueryMethod queryMethod(Class> repository, String name, Class>... parameters) throws Exception {
Method method = repository.getMethod(name, parameters);
@@ -275,11 +296,19 @@ interface PersonRepository extends Repository {
@Meta(flags = { org.springframework.data.mongodb.core.query.Meta.CursorOption.NO_TIMEOUT })
List metaWithNoCursorTimeout();
- @Meta(flags = { org.springframework.data.mongodb.core.query.Meta.CursorOption.NO_TIMEOUT, org.springframework.data.mongodb.core.query.Meta.CursorOption.SLAVE_OK })
+ @Meta(flags = { org.springframework.data.mongodb.core.query.Meta.CursorOption.NO_TIMEOUT,
+ org.springframework.data.mongodb.core.query.Meta.CursorOption.SLAVE_OK })
List metaWithMultipleFlags();
// DATAMONGO-1266
void deleteByUserName(String userName);
+
+ @Aggregation("{'$group': { _id: '$templateId', maxVersion : { $max : '$version'} } }")
+ List findByAggregation();
+
+ @Aggregation(pipeline = "{'$group': { _id: '$templateId', maxVersion : { $max : '$version'} } }",
+ collation = "de_AT")
+ List findByAggregationWithCollation();
}
interface SampleRepository extends Repository {
diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/repository/query/ReactiveMongoQueryMethodUnitTests.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/repository/query/ReactiveMongoQueryMethodUnitTests.java
index 9b0ee2d8ac..1230f77486 100644
--- a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/repository/query/ReactiveMongoQueryMethodUnitTests.java
+++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/repository/query/ReactiveMongoQueryMethodUnitTests.java
@@ -18,9 +18,13 @@
import static org.hamcrest.Matchers.*;
import static org.junit.Assert.*;
+import reactor.core.publisher.Flux;
+import reactor.core.publisher.Mono;
+
import java.lang.reflect.Method;
import java.util.List;
+import org.assertj.core.api.Assertions;
import org.junit.Before;
import org.junit.Test;
import org.springframework.dao.InvalidDataAccessApiUsageException;
@@ -33,6 +37,7 @@
import org.springframework.data.mongodb.core.User;
import org.springframework.data.mongodb.core.mapping.MongoMappingContext;
import org.springframework.data.mongodb.repository.Address;
+import org.springframework.data.mongodb.repository.Aggregation;
import org.springframework.data.mongodb.repository.Contact;
import org.springframework.data.mongodb.repository.Meta;
import org.springframework.data.mongodb.repository.Person;
@@ -41,9 +46,6 @@
import org.springframework.data.repository.Repository;
import org.springframework.data.repository.core.support.DefaultRepositoryMetadata;
-import reactor.core.publisher.Flux;
-import reactor.core.publisher.Mono;
-
/**
* Unit test for {@link ReactiveMongoQueryMethod}.
*
@@ -149,11 +151,29 @@ public void throwsExceptionOnWrappedSlice() throws Exception {
@Test // DATAMONGO-1444
public void fallsBackToRepositoryDomainTypeIfMethodDoesNotReturnADomainType() throws Exception {
- MongoQueryMethod method = queryMethod(PersonRepository.class, "deleteByUserName", String.class);
+ ReactiveMongoQueryMethod method = queryMethod(PersonRepository.class, "deleteByUserName", String.class);
assertThat(method.getEntityInformation().getJavaType(), is(typeCompatibleWith(User.class)));
}
+ @Test // DATAMONGO-2153
+ public void findsAnnotatedAggregation() throws Exception {
+
+ ReactiveMongoQueryMethod method = queryMethod(PersonRepository.class, "findByAggregation");
+
+ Assertions.assertThat(method.hasAnnotatedAggregation()).isTrue();
+ Assertions.assertThat(method.getAnnotatedAggregation()).hasSize(1);
+ }
+
+ @Test // DATAMONGO-2153
+ public void detectsCollationForAggregation() throws Exception {
+
+ ReactiveMongoQueryMethod method = queryMethod(PersonRepository.class, "findByAggregationWithCollation");
+
+ Assertions.assertThat(method.hasAnnotatedCollation()).isTrue();
+ Assertions.assertThat(method.getAnnotatedCollation()).isEqualTo("de_AT");
+ }
+
private ReactiveMongoQueryMethod queryMethod(Class> repository, String name, Class>... parameters)
throws Exception {
@@ -188,6 +208,13 @@ interface PersonRepository extends Repository {
Flux metaWithMaxExecutionTime();
void deleteByUserName(String userName);
+
+ @Aggregation("{'$group': { _id: '$templateId', maxVersion : { $max : '$version'} } }")
+ Flux findByAggregation();
+
+ @Aggregation(pipeline = "{'$group': { _id: '$templateId', maxVersion : { $max : '$version'} } }",
+ collation = "de_AT")
+ Flux findByAggregationWithCollation();
}
interface SampleRepository extends Repository {
diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/repository/query/ReactiveStringBasedAggregationUnitTests.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/repository/query/ReactiveStringBasedAggregationUnitTests.java
new file mode 100644
index 0000000000..23735660ea
--- /dev/null
+++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/repository/query/ReactiveStringBasedAggregationUnitTests.java
@@ -0,0 +1,227 @@
+/*
+ * Copyright 2019 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.repository.query;
+
+import static org.assertj.core.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+import lombok.Value;
+import reactor.core.publisher.Flux;
+import reactor.core.publisher.Mono;
+
+import java.lang.reflect.Method;
+import java.util.Arrays;
+import java.util.List;
+
+import org.bson.Document;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnitRunner;
+
+import org.springframework.data.domain.Sort;
+import org.springframework.data.domain.Sort.Direction;
+import org.springframework.data.mongodb.core.ReactiveMongoOperations;
+import org.springframework.data.mongodb.core.aggregation.AggregationOperationContext;
+import org.springframework.data.mongodb.core.aggregation.TypeBasedAggregationOperationContext;
+import org.springframework.data.mongodb.core.aggregation.TypedAggregation;
+import org.springframework.data.mongodb.core.convert.DbRefResolver;
+import org.springframework.data.mongodb.core.convert.MappingMongoConverter;
+import org.springframework.data.mongodb.core.convert.MongoConverter;
+import org.springframework.data.mongodb.core.convert.QueryMapper;
+import org.springframework.data.mongodb.core.mapping.MongoMappingContext;
+import org.springframework.data.mongodb.core.query.Collation;
+import org.springframework.data.mongodb.repository.Aggregation;
+import org.springframework.data.mongodb.repository.Person;
+import org.springframework.data.projection.ProjectionFactory;
+import org.springframework.data.projection.SpelAwareProxyProjectionFactory;
+import org.springframework.data.repository.core.support.DefaultRepositoryMetadata;
+import org.springframework.data.repository.query.QueryMethodEvaluationContextProvider;
+import org.springframework.data.repository.reactive.ReactiveCrudRepository;
+import org.springframework.expression.spel.standard.SpelExpressionParser;
+import org.springframework.lang.Nullable;
+import org.springframework.util.ClassUtils;
+
+/**
+ * Unit tests for {@link ReactiveStringBasedAggregation}.
+ *
+ * @author Christoph Strobl
+ */
+@RunWith(MockitoJUnitRunner.class)
+public class ReactiveStringBasedAggregationUnitTests {
+
+ SpelExpressionParser PARSER = new SpelExpressionParser();
+
+ @Mock ReactiveMongoOperations operations;
+ @Mock DbRefResolver dbRefResolver;
+ MongoConverter converter;
+
+ private static final String RAW_SORT_STRING = "{ '$sort' : { 'lastname' : -1 } }";
+ private static final String RAW_GROUP_BY_LASTNAME_STRING = "{ '$group': { '_id' : '$lastname', 'names' : { '$addToSet' : '$firstname' } } }";
+ private static final String GROUP_BY_LASTNAME_STRING_WITH_PARAMETER_PLACEHOLDER = "{ '$group': { '_id' : '$lastname', names : { '$addToSet' : '$?0' } } }";
+ private static final String GROUP_BY_LASTNAME_STRING_WITH_SPEL_PARAMETER_PLACEHOLDER = "{ '$group': { '_id' : '$lastname', 'names' : { '$addToSet' : '$?#{[0]}' } } }";
+
+ private static final Document SORT = Document.parse(RAW_SORT_STRING);
+ private static final Document GROUP_BY_LASTNAME = Document.parse(RAW_GROUP_BY_LASTNAME_STRING);
+
+ @Before
+ public void setUp() {
+
+ converter = new MappingMongoConverter(dbRefResolver, new MongoMappingContext());
+ when(operations.getConverter()).thenReturn(converter);
+ when(operations.aggregate(any(TypedAggregation.class), any())).thenReturn(Flux.empty());
+ }
+
+ @Test // DATAMONGO-2153
+ public void plainStringAggregation() {
+
+ AggregationInvocation invocation = executeAggregation("plainStringAggregation");
+
+ assertThat(inputTypeOf(invocation)).isEqualTo(Person.class);
+ assertThat(targetTypeOf(invocation)).isEqualTo(PersonAggregate.class);
+ assertThat(pipelineOf(invocation)).containsExactly(GROUP_BY_LASTNAME, SORT);
+ }
+
+ @Test // DATAMONGO-2153
+ public void plainStringAggregationWithSortParameter() {
+
+ AggregationInvocation invocation = executeAggregation("plainStringAggregation",
+ Sort.by(Direction.DESC, "lastname"));
+
+ assertThat(inputTypeOf(invocation)).isEqualTo(Person.class);
+ assertThat(targetTypeOf(invocation)).isEqualTo(PersonAggregate.class);
+ assertThat(pipelineOf(invocation)).containsExactly(GROUP_BY_LASTNAME, SORT);
+ }
+
+ @Test // DATAMONGO-2153
+ public void replaceParameter() {
+
+ AggregationInvocation invocation = executeAggregation("parameterReplacementAggregation", "firstname");
+
+ assertThat(inputTypeOf(invocation)).isEqualTo(Person.class);
+ assertThat(targetTypeOf(invocation)).isEqualTo(PersonAggregate.class);
+ assertThat(pipelineOf(invocation)).containsExactly(GROUP_BY_LASTNAME);
+ }
+
+ @Test // DATAMONGO-2153
+ public void replaceSpElParameter() {
+
+ AggregationInvocation invocation = executeAggregation("spelParameterReplacementAggregation", "firstname");
+
+ assertThat(inputTypeOf(invocation)).isEqualTo(Person.class);
+ assertThat(targetTypeOf(invocation)).isEqualTo(PersonAggregate.class);
+ assertThat(pipelineOf(invocation)).containsExactly(GROUP_BY_LASTNAME);
+ }
+
+ @Test // DATAMONGO-2153
+ public void aggregateWithCollation() {
+
+ AggregationInvocation invocation = executeAggregation("aggregateWithCollation");
+
+ assertThat(collationOf(invocation)).isEqualTo(Collation.of("de_AT"));
+ }
+
+ @Test // DATAMONGO-2153
+ public void aggregateWithCollationParameter() {
+
+ AggregationInvocation invocation = executeAggregation("aggregateWithCollation", Collation.of("en_US"));
+
+ assertThat(collationOf(invocation)).isEqualTo(Collation.of("en_US"));
+ }
+
+ private AggregationInvocation executeAggregation(String name, Object... args) {
+
+ Class>[] argTypes = Arrays.stream(args).map(Object::getClass).toArray(size -> new Class>[size]);
+ ReactiveStringBasedAggregation aggregation = createAggregationForMethod(name, argTypes);
+
+ ArgumentCaptor aggregationCaptor = ArgumentCaptor.forClass(TypedAggregation.class);
+ ArgumentCaptor targetTypeCaptor = ArgumentCaptor.forClass(Class.class);
+
+ Object result = aggregation.execute(args);
+
+ verify(operations).aggregate(aggregationCaptor.capture(), targetTypeCaptor.capture());
+
+ return new AggregationInvocation(aggregationCaptor.getValue(), targetTypeCaptor.getValue(), result);
+ }
+
+ private ReactiveStringBasedAggregation createAggregationForMethod(String name, Class>... parameters) {
+
+ Method method = ClassUtils.getMethod(SampleRepository.class, name, parameters);
+ ProjectionFactory factory = new SpelAwareProxyProjectionFactory();
+ ReactiveMongoQueryMethod queryMethod = new ReactiveMongoQueryMethod(method,
+ new DefaultRepositoryMetadata(SampleRepository.class), factory, converter.getMappingContext());
+ return new ReactiveStringBasedAggregation(queryMethod, operations, PARSER,
+ QueryMethodEvaluationContextProvider.DEFAULT);
+ }
+
+ private List pipelineOf(AggregationInvocation invocation) {
+
+ AggregationOperationContext context = new TypeBasedAggregationOperationContext(
+ invocation.aggregation.getInputType(), converter.getMappingContext(), new QueryMapper(converter));
+
+ return invocation.aggregation.toPipeline(context);
+ }
+
+ private Class> inputTypeOf(AggregationInvocation invocation) {
+ return invocation.aggregation.getInputType();
+ }
+
+ @Nullable
+ private Collation collationOf(AggregationInvocation invocation) {
+ return invocation.aggregation.getOptions() != null ? invocation.aggregation.getOptions().getCollation().orElse(null)
+ : null;
+ }
+
+ private Class> targetTypeOf(AggregationInvocation invocation) {
+ return invocation.getTargetType();
+ }
+
+ private interface SampleRepository extends ReactiveCrudRepository {
+
+ @Aggregation({ RAW_GROUP_BY_LASTNAME_STRING, RAW_SORT_STRING })
+ Mono plainStringAggregation();
+
+ @Aggregation(RAW_GROUP_BY_LASTNAME_STRING)
+ Mono plainStringAggregation(Sort sort);
+
+ @Aggregation(GROUP_BY_LASTNAME_STRING_WITH_PARAMETER_PLACEHOLDER)
+ Mono parameterReplacementAggregation(String attribute);
+
+ @Aggregation(GROUP_BY_LASTNAME_STRING_WITH_SPEL_PARAMETER_PLACEHOLDER)
+ Mono spelParameterReplacementAggregation(String arg0);
+
+ @Aggregation(pipeline = RAW_GROUP_BY_LASTNAME_STRING, collation = "de_AT")
+ Mono aggregateWithCollation();
+
+ @Aggregation(pipeline = RAW_GROUP_BY_LASTNAME_STRING, collation = "de_AT")
+ Mono aggregateWithCollation(Collation collation);
+ }
+
+ static class PersonAggregate {
+
+ }
+
+ @Value
+ static class AggregationInvocation {
+
+ TypedAggregation> aggregation;
+ Class> targetType;
+ Object result;
+ }
+}
diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/repository/query/StringBasedAggregationUnitTests.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/repository/query/StringBasedAggregationUnitTests.java
new file mode 100644
index 0000000000..44f7186482
--- /dev/null
+++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/repository/query/StringBasedAggregationUnitTests.java
@@ -0,0 +1,272 @@
+/*
+ * Copyright 2019 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.repository.query;
+
+import static org.assertj.core.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+import lombok.Value;
+
+import java.lang.reflect.Method;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.function.IntFunction;
+
+import org.bson.Document;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnitRunner;
+
+import org.springframework.data.domain.Sort;
+import org.springframework.data.domain.Sort.Direction;
+import org.springframework.data.mongodb.core.MongoOperations;
+import org.springframework.data.mongodb.core.aggregation.AggregationOperationContext;
+import org.springframework.data.mongodb.core.aggregation.AggregationResults;
+import org.springframework.data.mongodb.core.aggregation.TypeBasedAggregationOperationContext;
+import org.springframework.data.mongodb.core.aggregation.TypedAggregation;
+import org.springframework.data.mongodb.core.convert.DbRefResolver;
+import org.springframework.data.mongodb.core.convert.MappingMongoConverter;
+import org.springframework.data.mongodb.core.convert.MongoConverter;
+import org.springframework.data.mongodb.core.convert.QueryMapper;
+import org.springframework.data.mongodb.core.mapping.MongoMappingContext;
+import org.springframework.data.mongodb.core.query.Collation;
+import org.springframework.data.mongodb.repository.Aggregation;
+import org.springframework.data.mongodb.repository.Person;
+import org.springframework.data.projection.ProjectionFactory;
+import org.springframework.data.projection.SpelAwareProxyProjectionFactory;
+import org.springframework.data.repository.Repository;
+import org.springframework.data.repository.core.support.DefaultRepositoryMetadata;
+import org.springframework.data.repository.query.QueryMethodEvaluationContextProvider;
+import org.springframework.expression.spel.standard.SpelExpressionParser;
+import org.springframework.lang.Nullable;
+import org.springframework.util.ClassUtils;
+
+/**
+ * Unit tests for {@link StringBasedAggregation}.
+ *
+ * @author Christoph Strobl
+ */
+@RunWith(MockitoJUnitRunner.class)
+public class StringBasedAggregationUnitTests {
+
+ SpelExpressionParser PARSER = new SpelExpressionParser();
+
+ @Mock MongoOperations operations;
+ @Mock DbRefResolver dbRefResolver;
+ @Mock AggregationResults aggregationResults;
+ MongoConverter converter;
+
+ private static final String RAW_SORT_STRING = "{ '$sort' : { 'lastname' : -1 } }";
+ private static final String RAW_GROUP_BY_LASTNAME_STRING = "{ '$group': { '_id' : '$lastname', 'names' : { '$addToSet' : '$firstname' } } }";
+ private static final String GROUP_BY_LASTNAME_STRING_WITH_PARAMETER_PLACEHOLDER = "{ '$group': { '_id' : '$lastname', names : { '$addToSet' : '$?0' } } }";
+ private static final String GROUP_BY_LASTNAME_STRING_WITH_SPEL_PARAMETER_PLACEHOLDER = "{ '$group': { '_id' : '$lastname', 'names' : { '$addToSet' : '$?#{[0]}' } } }";
+
+ private static final Document SORT = Document.parse(RAW_SORT_STRING);
+ private static final Document GROUP_BY_LASTNAME = Document.parse(RAW_GROUP_BY_LASTNAME_STRING);
+
+ @Before
+ public void setUp() {
+
+ converter = new MappingMongoConverter(dbRefResolver, new MongoMappingContext());
+ when(operations.getConverter()).thenReturn(converter);
+ when(operations.aggregate(any(TypedAggregation.class), any())).thenReturn(aggregationResults);
+ }
+
+ @Test // DATAMONGO-2153
+ public void plainStringAggregation() {
+
+ AggregationInvocation invocation = executeAggregation("plainStringAggregation");
+
+ assertThat(inputTypeOf(invocation)).isEqualTo(Person.class);
+ assertThat(targetTypeOf(invocation)).isEqualTo(PersonAggregate.class);
+ assertThat(pipelineOf(invocation)).containsExactly(GROUP_BY_LASTNAME, SORT);
+ }
+
+ @Test // DATAMONGO-2153
+ public void returnSingleObject() {
+
+ PersonAggregate expected = new PersonAggregate();
+ when(aggregationResults.getUniqueMappedResult()).thenReturn(Collections.singletonList(expected));
+
+ assertThat(executeAggregation("returnSingleEntity").result).isEqualTo(expected);
+ }
+
+ @Test // DATAMONGO-2153
+ public void returnSingleObjectThrowsError() {
+
+ when(aggregationResults.getUniqueMappedResult()).thenThrow(new IllegalArgumentException("o_O"));
+
+ assertThatExceptionOfType(IllegalArgumentException.class)
+ .isThrownBy(() -> executeAggregation("returnSingleEntity"));
+ }
+
+ @Test // DATAMONGO-2153
+ public void returnCollection() {
+
+ List expected = Collections.singletonList(new PersonAggregate());
+ when(aggregationResults.getMappedResults()).thenReturn(expected);
+
+ assertThat(executeAggregation("returnCollection").result).isEqualTo(expected);
+ }
+
+ @Test // DATAMONGO-2153
+ public void returnRawResultType() {
+ assertThat(executeAggregation("returnRawResultType").result).isEqualTo(aggregationResults);
+ }
+
+ @Test // DATAMONGO-2153
+ public void plainStringAggregationWithSortParameter() {
+
+ AggregationInvocation invocation = executeAggregation("plainStringAggregation",
+ Sort.by(Direction.DESC, "lastname"));
+
+ assertThat(inputTypeOf(invocation)).isEqualTo(Person.class);
+ assertThat(targetTypeOf(invocation)).isEqualTo(PersonAggregate.class);
+ assertThat(pipelineOf(invocation)).containsExactly(GROUP_BY_LASTNAME, SORT);
+ }
+
+ @Test // DATAMONGO-2153
+ public void replaceParameter() {
+
+ AggregationInvocation invocation = executeAggregation("parameterReplacementAggregation", "firstname");
+
+ assertThat(inputTypeOf(invocation)).isEqualTo(Person.class);
+ assertThat(targetTypeOf(invocation)).isEqualTo(PersonAggregate.class);
+ assertThat(pipelineOf(invocation)).containsExactly(GROUP_BY_LASTNAME);
+ }
+
+ @Test // DATAMONGO-2153
+ public void replaceSpElParameter() {
+
+ AggregationInvocation invocation = executeAggregation("spelParameterReplacementAggregation", "firstname");
+
+ assertThat(inputTypeOf(invocation)).isEqualTo(Person.class);
+ assertThat(targetTypeOf(invocation)).isEqualTo(PersonAggregate.class);
+ assertThat(pipelineOf(invocation)).containsExactly(GROUP_BY_LASTNAME);
+ }
+
+ @Test // DATAMONGO-2153
+ public void aggregateWithCollation() {
+
+ AggregationInvocation invocation = executeAggregation("aggregateWithCollation");
+
+ assertThat(collationOf(invocation)).isEqualTo(Collation.of("de_AT"));
+ }
+
+ @Test // DATAMONGO-2153
+ public void aggregateWithCollationParameter() {
+
+ AggregationInvocation invocation = executeAggregation("aggregateWithCollation", Collation.of("en_US"));
+
+ assertThat(collationOf(invocation)).isEqualTo(Collation.of("en_US"));
+ }
+
+ private AggregationInvocation executeAggregation(String name, Object... args) {
+
+ Class>[] argTypes = Arrays.stream(args).map(Object::getClass).toArray(Class[]::new);
+ StringBasedAggregation aggregation = createAggregationForMethod(name, argTypes);
+
+ ArgumentCaptor aggregationCaptor = ArgumentCaptor.forClass(TypedAggregation.class);
+ ArgumentCaptor targetTypeCaptor = ArgumentCaptor.forClass(Class.class);
+
+ Object result = aggregation.execute(args);
+
+ verify(operations).aggregate(aggregationCaptor.capture(), targetTypeCaptor.capture());
+
+ return new AggregationInvocation(aggregationCaptor.getValue(), targetTypeCaptor.getValue(), result);
+ }
+
+ private StringBasedAggregation createAggregationForMethod(String name, Class>... parameters) {
+
+ Method method = ClassUtils.getMethod(SampleRepository.class, name, parameters);
+ ProjectionFactory factory = new SpelAwareProxyProjectionFactory();
+ MongoQueryMethod queryMethod = new MongoQueryMethod(method, new DefaultRepositoryMetadata(SampleRepository.class),
+ factory, converter.getMappingContext());
+ return new StringBasedAggregation(queryMethod, operations, PARSER, QueryMethodEvaluationContextProvider.DEFAULT);
+ }
+
+ private List pipelineOf(AggregationInvocation invocation) {
+
+ AggregationOperationContext context = new TypeBasedAggregationOperationContext(
+ invocation.aggregation.getInputType(), converter.getMappingContext(), new QueryMapper(converter));
+
+ return invocation.aggregation.toPipeline(context);
+ }
+
+ private Class> inputTypeOf(AggregationInvocation invocation) {
+ return invocation.aggregation.getInputType();
+ }
+
+ @Nullable
+ private Collation collationOf(AggregationInvocation invocation) {
+ return invocation.aggregation.getOptions() != null ? invocation.aggregation.getOptions().getCollation().orElse(null)
+ : null;
+ }
+
+ private Class> targetTypeOf(AggregationInvocation invocation) {
+ return invocation.getTargetType();
+ }
+
+ private interface SampleRepository extends Repository {
+
+ @Aggregation({ RAW_GROUP_BY_LASTNAME_STRING, RAW_SORT_STRING })
+ PersonAggregate plainStringAggregation();
+
+ @Aggregation(RAW_GROUP_BY_LASTNAME_STRING)
+ PersonAggregate plainStringAggregation(Sort sort);
+
+ @Aggregation(RAW_GROUP_BY_LASTNAME_STRING)
+ PersonAggregate returnSingleEntity();
+
+ @Aggregation(RAW_GROUP_BY_LASTNAME_STRING)
+ List returnCollection();
+
+ @Aggregation(RAW_GROUP_BY_LASTNAME_STRING)
+ AggregationResults returnRawResultType();
+
+ @Aggregation(RAW_GROUP_BY_LASTNAME_STRING)
+ AggregationResults returnRawResults();
+
+ @Aggregation(GROUP_BY_LASTNAME_STRING_WITH_PARAMETER_PLACEHOLDER)
+ PersonAggregate parameterReplacementAggregation(String attribute);
+
+ @Aggregation(GROUP_BY_LASTNAME_STRING_WITH_SPEL_PARAMETER_PLACEHOLDER)
+ PersonAggregate spelParameterReplacementAggregation(String arg0);
+
+ @Aggregation(pipeline = RAW_GROUP_BY_LASTNAME_STRING, collation = "de_AT")
+ PersonAggregate aggregateWithCollation();
+
+ @Aggregation(pipeline = RAW_GROUP_BY_LASTNAME_STRING, collation = "de_AT")
+ PersonAggregate aggregateWithCollation(Collation collation);
+ }
+
+ static class PersonAggregate {
+
+ }
+
+ @Value
+ static class AggregationInvocation {
+
+ TypedAggregation> aggregation;
+ Class> targetType;
+ Object result;
+ }
+}
diff --git a/src/main/asciidoc/new-features.adoc b/src/main/asciidoc/new-features.adoc
index 8050d212f4..c7b7aa35e8 100644
--- a/src/main/asciidoc/new-features.adoc
+++ b/src/main/asciidoc/new-features.adoc
@@ -16,6 +16,7 @@
* <> from domain types.
* SpEL support in for expressions in `@Indexed`.
* Annotation-based Collation support through `@Document` and `@Query`.
+* <> support via repository query methods.
[[new-features.2-1-0]]
== What's New in Spring Data MongoDB 2.1
diff --git a/src/main/asciidoc/reference/mongo-repositories-aggregation.adoc b/src/main/asciidoc/reference/mongo-repositories-aggregation.adoc
new file mode 100644
index 0000000000..feba576ad2
--- /dev/null
+++ b/src/main/asciidoc/reference/mongo-repositories-aggregation.adoc
@@ -0,0 +1,90 @@
+[[mongodb.repositories.queries.aggregation]]
+=== Aggregation Repository Methods
+
+The repository layer offers means to interact with <> via annotated repository query methods.
+Similar to the <>, you can define a pipeline using the `org.springframework.data.mongodb.repository.Aggregation` annotation.
+The definition may contain simple placeholders like `?0` as well as https://docs.spring.io/spring/docs/{springVersion}/spring-framework-reference/core.html#expressions[SpEL expressions] `?#{ … }`.
+
+.Aggregating Repository Method
+====
+[source,java]
+----
+public interface PersonRepository extends CrudReppsitory {
+
+
+ @Aggregation("{ $group: { _id : $lastname, names : { $addToSet : $firstname } } }")
+ List groupByLastnameAndFirstnames(); <1>
+
+ @Aggregation("{ $group: { _id : $lastname, names : { $addToSet : $firstname } } }")
+ List groupByLastnameAndFirstnames(Sort sort); <2>
+
+ @Aggregation("{ $group: { _id : $lastname, names : { $addToSet : $?0 } } }")
+ List groupByLastnameAnd(String property); <3>
+
+ @Aggregation("{ $group: { _id : $lastname, names : { $addToSet : $?0 } } }")
+ List groupByLastnameAnd(String property, Pageable page); <4>
+
+ @Aggregation("{ $group : { _id : null, total : { $sum : $age } } }")
+ SumValue sumAgeUsingValueWrapper(); <5>
+
+ @Aggregation("{ $group : { _id : null, total : { $sum : $age } } }")
+ Long sumAge(); <6>
+
+ @Aggregation("{ $group : { _id : null, total : { $sum : $age } } }")
+ AggregationResults sumAgeRaw(); <7>
+
+ @Aggregation("{ '$project': { '_id' : '$lastname' } }")
+ List findAllLastnames(); <8>
+}
+----
+[source,java]
+----
+public class PersonAggregate {
+
+ private @Id String lastname; <2>
+ private List names;
+
+ public PersonAggregate(String lastname, List names) {
+ // ...
+ }
+
+ // Getter / Setter omitted
+}
+
+public class SumValue {
+
+ private final Long total; <5> <7>
+
+ public SumValue(Long total) {
+ // ...
+ }
+
+ // Getter omitted
+}
+----
+<1> Aggregation pipeline to group first names by `lastname` in the `Person` collection returning these as `PersonAggregate`.
+<2> If `Sort` argument is present, `$sort` is appended after the declared pipeline stages so that it only affects the order of the final results after having passed all other aggregation stages.
+Therefore, the `Sort` properties are mapped against the methods return type `PersonAggregate` which turns `Sort.by("lastname")` into `{ $sort : { '_id', 1 } }` because `PersonAggregate.lastname` is annotated with `@Id`.
+<3> Replaces `?0` with the given value for `property` for a dynamic aggregation pipeline.
+<4> `$skip`, `$limit` and `$sort` can be passed on via a `Pageable` argument. Same as in <2>, the operators are appended to the pipeline definition.
+<5> Map the result of an aggregation returning a single `Document` to an instance of a desired `SumValue` target type.
+<6> Aggregations resulting in single document holding just an accumulation result like eg. `$sum` can be extracted directly from the result `Document`.
+To gain more control, you might consider `AggregationResult` as method return type as shown in <7>.
+<7> Obtain the raw `AggregationResults` mapped to the generic target wrapper type `SumValue` or `org.bson.Document`.
+<8> Like in <6>, a single value can be directly obtained from multiple result ``Document``s.
+====
+
+TIP: You can use `@Aggregation` also with <>.
+
+[NOTE]
+====
+Simple-type single-result inspects the returned `Document` and checks for the following:
+
+. Only one entry in the document, return it.
+. Two entries, one is the `_id` value. Return the other.
+. Return for the first value assignable to the return type.
+. Throw an exception if none of the above is applicable.
+====
+
+WARNING: The `Page` return type is not supported for repository methods using `@Aggregation`. However you can use a
+`Pageable` argument to add `$skip`, `$limit` and `$sort` to the pipeline.
diff --git a/src/main/asciidoc/reference/mongo-repositories.adoc b/src/main/asciidoc/reference/mongo-repositories.adoc
index 8b9c260480..c6c2341fbb 100644
--- a/src/main/asciidoc/reference/mongo-repositories.adoc
+++ b/src/main/asciidoc/reference/mongo-repositories.adoc
@@ -579,6 +579,8 @@ List result = repository.findByTitleOrderByScoreDesc("mongodb"
include::../{spring-data-commons-docs}/repository-projections.adoc[leveloffset=+2]
+include::./mongo-repositories-aggregation.adoc[]
+
[[mongodb.repositories.misc.cdi-integration]]
== CDI Integration