diff --git a/pom.xml b/pom.xml index a1cf69a0f6..9a03b20040 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ org.springframework.data spring-data-mongodb-parent - 2.2.0.BUILD-SNAPSHOT + 2.2.0.DATAMONGO-2153-SNAPSHOT pom Spring Data MongoDB diff --git a/spring-data-mongodb-benchmarks/pom.xml b/spring-data-mongodb-benchmarks/pom.xml index bb7d9f03cc..8c004dce72 100644 --- a/spring-data-mongodb-benchmarks/pom.xml +++ b/spring-data-mongodb-benchmarks/pom.xml @@ -7,7 +7,7 @@ org.springframework.data spring-data-mongodb-parent - 2.2.0.BUILD-SNAPSHOT + 2.2.0.DATAMONGO-2153-SNAPSHOT ../pom.xml diff --git a/spring-data-mongodb-distribution/pom.xml b/spring-data-mongodb-distribution/pom.xml index b32dcba387..344cda5a9a 100644 --- a/spring-data-mongodb-distribution/pom.xml +++ b/spring-data-mongodb-distribution/pom.xml @@ -14,7 +14,7 @@ org.springframework.data spring-data-mongodb-parent - 2.2.0.BUILD-SNAPSHOT + 2.2.0.DATAMONGO-2153-SNAPSHOT ../pom.xml diff --git a/spring-data-mongodb/pom.xml b/spring-data-mongodb/pom.xml index b611cf01a8..484f316166 100644 --- a/spring-data-mongodb/pom.xml +++ b/spring-data-mongodb/pom.xml @@ -11,7 +11,7 @@ org.springframework.data spring-data-mongodb-parent - 2.2.0.BUILD-SNAPSHOT + 2.2.0.DATAMONGO-2153-SNAPSHOT ../pom.xml diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/AggregationOperationContext.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/AggregationOperationContext.java index ece1067665..a0e9bcde63 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/AggregationOperationContext.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/AggregationOperationContext.java @@ -17,6 +17,7 @@ import org.bson.Document; import org.springframework.data.mongodb.core.aggregation.ExposedFields.FieldReference; +import org.springframework.lang.Nullable; /** * The context for an {@link AggregationOperation}. @@ -33,7 +34,20 @@ public interface AggregationOperationContext { * @param document will never be {@literal null}. * @return must not be {@literal null}. */ - Document getMappedObject(Document document); + default Document getMappedObject(Document document) { + return getMappedObject(document, null); + } + + /** + * Returns the mapped {@link Document}, potentially converting the source considering mapping metadata for the given + * type. + * + * @param document will never be {@literal null}. + * @param type can be {@literal null}. + * @return must not be {@literal null}. + * @since 2.2 + */ + Document getMappedObject(Document document, @Nullable Class type); /** * Returns a {@link FieldReference} for the given field or {@literal null} if the context does not expose the given diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/AggregationOperationRenderer.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/AggregationOperationRenderer.java index 827496aa31..de19727b16 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/AggregationOperationRenderer.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/AggregationOperationRenderer.java @@ -24,6 +24,7 @@ import org.springframework.data.mongodb.core.aggregation.ExposedFields.FieldReference; import org.springframework.data.mongodb.core.aggregation.Fields.AggregationField; import org.springframework.data.mongodb.core.aggregation.FieldsExposingAggregationOperation.InheritsFieldsAggregationOperation; +import org.springframework.lang.Nullable; /** * Rendering support for {@link AggregationOperation} into a {@link List} of {@link org.bson.Document}. @@ -75,15 +76,16 @@ static List toDocument(List 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}.
+ *
    + *
  1. empty source: {@literal null}
  2. + *
  3. single entry convert that one
  4. + *
  5. single entry when ignoring {@literal _id} field convert that one
  6. + *
  7. multiple entries first value assignable to the target type
  8. + *
  9. no match IllegalArgumentException
  10. + *
+ * + * @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