Browse Source
The repository layer offers means interact with the aggregation framework via annotated repository finder methods. Similar to the JSON based queries a pipeline can be defined via the Aggregation annotation. The definition may contain simple placeholders like `?0` as well as SpEL expression markers `?#{ ... }`.
public interface PersonRepository extends CrudReppsitory<Person, String> {
@Aggregation("{ $group: { _id : $lastname, names : { $addToSet : $?0 } } }")
List<PersonAggregate> groupByLastnameAnd(String property);
@Aggregation("{ $group: { _id : $lastname, names : { $addToSet : $firstname } } }")
List<PersonAggregate> groupByLastnameAndFirstnames(Sort sort);
@Aggregation("{ $group: { _id : $lastname, names : { $addToSet : $?0 } } }")
List<PersonAggregate> groupByLastnameAnd(String property, Pageable page);
@Aggregation("{ $group : { _id : null, total : { $sum : $age } } }")
SumValue sumAgeUsingValueWrapper();
@Aggregation("{ $group : { _id : null, total : { $sum : $age } } }")
Long sumAge();
@Aggregation("{ $group : { _id : null, total : { $sum : $age } } }")
AggregationResults<SumValue> sumAgeRaw();
@Aggregation("{ '$project': { '_id' : '$lastname' } }")
List<String> findAllLastnames();
}
public interface ReactivePersonRepository extends ReactiveCrudReppsitory<Person, String> {
@Aggregation("{ $group: { _id : $lastname, names : { $addToSet : $?0 } } }")
Flux<PersonAggregate> groupByLastnameAnd(String property);
@Aggregation("{ $group : { _id : null, total : { $sum : $age } } }")
Mono<Long> sumAge();
@Aggregation("{ '$project': { '_id' : '$lastname' } }")
Flux<String> findAllLastnames();
}
Original pull request: #743.
pull/755/head
30 changed files with 1891 additions and 106 deletions
@ -0,0 +1,127 @@
@@ -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 decorate a {@link org.springframework.data.repository.Repository} |
||||
* query method so that it runs the {@link Aggregation#pipeline()} on invocation. <br /> |
||||
* The 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}. <br /> |
||||
* 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. |
||||
* |
||||
* <pre class="code"> |
||||
* |
||||
* // aggregation resulting in collection with single value
|
||||
* @Aggregation("{ '$project': { '_id' : '$lastname' } }") |
||||
* List<String> findAllLastnames(); |
||||
* |
||||
* // aggregation with parameter replacement
|
||||
* @Aggregation("{ '$group': { '_id' : '$lastname', names : { $addToSet : '$?0' } } }") |
||||
* List<PersonAggregate> groupByLastnameAnd(String property); |
||||
* |
||||
* // aggregation with sort in pipeline
|
||||
* @Aggregation(pipeline = {"{ '$group': { '_id' : '$lastname', names : { $addToSet : '$?0' } } }", "{ '$sort' : { 'lastname' : -1 } }"}) |
||||
* List<PersonAggregate> groupByLastnameAnd(String property); |
||||
* |
||||
* // Sort parameter is used for sorting results
|
||||
* @Aggregation("{ '$group': { '_id' : '$lastname', names : { $addToSet : '$?0' } } }") |
||||
* List<PersonAggregate> groupByLastnameAnd(String property, Sort sort); |
||||
* |
||||
* // Pageable parameter used for sort, skip and limit
|
||||
* @Aggregation("{ '$group': { '_id' : '$lastname', names : { $addToSet : '$?0' } } }") |
||||
* List<PersonAggregate> 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(); |
||||
* </pre> |
||||
* |
||||
* @return an empty array by default. |
||||
*/ |
||||
@AliasFor("value") |
||||
String[] pipeline() default {}; |
||||
|
||||
/** |
||||
* Defines the collation to apply when executing the aggregation. |
||||
* |
||||
* <pre class="code"> |
||||
* // Fixed value
|
||||
* @Aggregation(pipeline = "...", collation = "en_US") |
||||
* List<Entry> findAllByFixedCollation(); |
||||
* |
||||
* // Fixed value as Document
|
||||
* @Aggregation(pipeline = "...", collation = "{ 'locale' : 'en_US' }") |
||||
* List<Entry> findAllByFixedJsonCollation(); |
||||
* |
||||
* // Dynamic value as String
|
||||
* @Aggregation(pipeline = "...", collation = "?0") |
||||
* List<Entry> findAllByDynamicCollation(String collation); |
||||
* |
||||
* // Dynamic value as Document
|
||||
* @Aggregation(pipeline = "...", collation = "{ 'locale' : ?0 }") |
||||
* List<Entry> findAllByDynamicJsonCollation(String collation); |
||||
* |
||||
* // SpEL expression
|
||||
* @Aggregation(pipeline = "...", collation = "?#{[0]}") |
||||
* List<Entry> findAllByDynamicSpElCollation(String collation); |
||||
* </pre> |
||||
* |
||||
* @return an empty {@link String} by default. |
||||
* @since 2.2 |
||||
*/ |
||||
String collation() default ""; |
||||
} |
||||
@ -0,0 +1,208 @@
@@ -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) |
||||
* @since 2.2 |
||||
*/ |
||||
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<AggregationOperation> computePipeline(MongoQueryMethod method, ConvertingParameterAccessor accessor, |
||||
SpelExpressionParser expressionParser, QueryMethodEvaluationContextProvider evaluationContextProvider) { |
||||
|
||||
ParameterBindingContext bindingContext = new ParameterBindingContext((accessor::getBindableValue), expressionParser, |
||||
evaluationContextProvider.getEvaluationContext(method.getParameters(), accessor.getValues())); |
||||
|
||||
List<AggregationOperation> target = new ArrayList<>(method.getAnnotatedAggregation().length); |
||||
for (String source : method.getAnnotatedAggregation()) { |
||||
target.add(ctx -> ctx.getMappedObject(CODEC.decode(source, bindingContext), method.getRepositoryDomainType())); |
||||
} |
||||
return target; |
||||
} |
||||
|
||||
/** |
||||
* Append {@code $sort} aggregation stage if {@link ConvertingParameterAccessor#getSort()} is present. |
||||
* |
||||
* @param aggregationPipeline |
||||
* @param accessor |
||||
* @param targetType |
||||
*/ |
||||
static void appendSortIfPresent(List<AggregationOperation> 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<AggregationOperation> 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}. <br /> |
||||
* <ol> |
||||
* <li><strong>empty source:</strong> {@literal null}</li> |
||||
* <li><strong>single entry</strong> convert that one</li> |
||||
* <li><strong>single entry when ignoring {@literal _id} field</strong> convert that one</li> |
||||
* <li><strong>multiple entries</strong> first value assignable to the target type</li> |
||||
* <li><strong>no match</strong> IllegalArgumentException</li> |
||||
* </ol> |
||||
* |
||||
* @param <T> |
||||
* @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> T extractSimpleTypeResult(Document source, Class<T> targetType, MongoConverter converter) { |
||||
|
||||
if (source.isEmpty()) { |
||||
return null; |
||||
} |
||||
|
||||
if (source.size() == 1) { |
||||
return getPotentiallyConvertedSimpleTypeValue(converter, source.values().iterator().next(), targetType); |
||||
} |
||||
|
||||
Document tmp = new Document(source); |
||||
tmp.remove("_id"); |
||||
|
||||
if (tmp.size() == 1) { |
||||
return getPotentiallyConvertedSimpleTypeValue(converter, tmp.values().iterator().next(), targetType); |
||||
} |
||||
|
||||
for (Map.Entry<String, Object> entry : tmp.entrySet()) { |
||||
if (entry != null && ClassUtils.isAssignable(targetType, entry.getValue().getClass())) { |
||||
return (T) entry.getValue(); |
||||
} |
||||
} |
||||
|
||||
throw new IllegalArgumentException( |
||||
String.format("o_O no entry of type %s found in %s.", targetType.getSimpleName(), source.toJson())); |
||||
} |
||||
|
||||
@Nullable |
||||
private static <T> T getPotentiallyConvertedSimpleTypeValue(MongoConverter converter, Object value, |
||||
Class<T> targetType) { |
||||
|
||||
if (value == null) { |
||||
return (T) value; |
||||
} |
||||
|
||||
if (!converter.getConversionService().canConvert(value.getClass(), targetType)) { |
||||
return (T) value; |
||||
} |
||||
|
||||
return converter.getConversionService().convert(value, targetType); |
||||
} |
||||
} |
||||
@ -0,0 +1,110 @@
@@ -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); |
||||
} |
||||
} |
||||
@ -0,0 +1,163 @@
@@ -0,0 +1,163 @@
|
||||
/* |
||||
* 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.BasicQuery; |
||||
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 |
||||
* @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.getRepositoryDomainType(); |
||||
Class<?> targetType = typeToRead; |
||||
|
||||
List<AggregationOperation> pipeline = computePipeline(method, 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) { |
||||
return flux.map(it -> AggregationUtils.extractSimpleTypeResult((Document) it, typeToRead, mongoConverter)); |
||||
} |
||||
|
||||
return flux; |
||||
} |
||||
|
||||
private boolean isSimpleReturnType(Class<?> targetType) { |
||||
return MongoSimpleTypes.HOLDER.isSimpleType(targetType); |
||||
} |
||||
|
||||
List<AggregationOperation> computePipeline(MongoQueryMethod method, 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) { |
||||
return new BasicQuery("{}"); |
||||
} |
||||
|
||||
/* |
||||
* (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; |
||||
} |
||||
} |
||||
@ -0,0 +1,180 @@
@@ -0,0 +1,180 @@
|
||||
/* |
||||
* 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.BasicQuery; |
||||
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.getRepositoryDomainType(); |
||||
Class<?> targetType = typeToRead; |
||||
|
||||
List<AggregationOperation> 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().getActualType().getComponentType().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(); |
||||
} |
||||
|
||||
if (isSimpleReturnType) { |
||||
|
||||
return AggregationUtils.extractSimpleTypeResult((Document) result.getUniqueMappedResult(), typeToRead, |
||||
mongoConverter); |
||||
} |
||||
|
||||
return result.getUniqueMappedResult(); |
||||
} |
||||
|
||||
private boolean isSimpleReturnType(Class<?> targetType) { |
||||
return MongoSimpleTypes.HOLDER.isSimpleType(targetType); |
||||
} |
||||
|
||||
List<AggregationOperation> 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) { |
||||
return new BasicQuery("{}"); |
||||
} |
||||
|
||||
/* |
||||
* (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; |
||||
} |
||||
} |
||||
@ -0,0 +1,32 @@
@@ -0,0 +1,32 @@
|
||||
/* |
||||
* 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.List; |
||||
|
||||
import org.springframework.data.annotation.Id; |
||||
|
||||
/** |
||||
* @author Christoph Strobl |
||||
*/ |
||||
@Value |
||||
public class PersonAggregate { |
||||
|
||||
@Id private String lastname; |
||||
private List<String> names; |
||||
} |
||||
@ -0,0 +1,27 @@
@@ -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 |
||||
public class SumAge { |
||||
|
||||
private Long total; |
||||
} |
||||
@ -0,0 +1,229 @@
@@ -0,0 +1,229 @@
|
||||
/* |
||||
* 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.any; |
||||
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; |
||||
|
||||
/** |
||||
* @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<TypedAggregation> aggregationCaptor = ArgumentCaptor.forClass(TypedAggregation.class); |
||||
ArgumentCaptor<Class> 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) { |
||||
|
||||
try { |
||||
|
||||
Method method = SampleRepository.class.getMethod(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); |
||||
|
||||
} catch (Exception e) { |
||||
throw new IllegalArgumentException(e.getMessage(), e); |
||||
} |
||||
} |
||||
|
||||
private List<Document> 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<Person, Long> { |
||||
|
||||
@Aggregation({ RAW_GROUP_BY_LASTNAME_STRING, RAW_SORT_STRING }) |
||||
Mono<PersonAggregate> plainStringAggregation(); |
||||
|
||||
@Aggregation(RAW_GROUP_BY_LASTNAME_STRING) |
||||
Mono<PersonAggregate> plainStringAggregation(Sort sort); |
||||
|
||||
@Aggregation(GROUP_BY_LASTNAME_STRING_WITH_PARAMETER_PLACEHOLDER) |
||||
Mono<PersonAggregate> parameterReplacementAggregation(String attribute); |
||||
|
||||
@Aggregation(GROUP_BY_LASTNAME_STRING_WITH_SPEL_PARAMETER_PLACEHOLDER) |
||||
Mono<PersonAggregate> spelParameterReplacementAggregation(String arg0); |
||||
|
||||
@Aggregation(pipeline = RAW_GROUP_BY_LASTNAME_STRING, collation = "de_AT") |
||||
Mono<PersonAggregate> aggregateWithCollation(); |
||||
|
||||
@Aggregation(pipeline = RAW_GROUP_BY_LASTNAME_STRING, collation = "de_AT") |
||||
Mono<PersonAggregate> aggregateWithCollation(Collation collation); |
||||
} |
||||
|
||||
static class PersonAggregate { |
||||
|
||||
} |
||||
|
||||
@Value |
||||
static class AggregationInvocation { |
||||
|
||||
final TypedAggregation<?> aggregation; |
||||
final Class<?> targetType; |
||||
final Object result; |
||||
} |
||||
} |
||||
@ -0,0 +1,273 @@
@@ -0,0 +1,273 @@
|
||||
/* |
||||
* 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.any; |
||||
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 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; |
||||
|
||||
/** |
||||
* @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<PersonAggregate> 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(size -> new Class<?>[size]); |
||||
StringBasedAggregation aggregation = createAggregationForMethod(name, argTypes); |
||||
|
||||
ArgumentCaptor<TypedAggregation> aggregationCaptor = ArgumentCaptor.forClass(TypedAggregation.class); |
||||
ArgumentCaptor<Class> 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) { |
||||
|
||||
try { |
||||
|
||||
Method method = SampleRepository.class.getMethod(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); |
||||
|
||||
} catch (Exception e) { |
||||
throw new IllegalArgumentException(e.getMessage(), e); |
||||
} |
||||
} |
||||
|
||||
private List<Document> 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<Person, Long> { |
||||
|
||||
@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<PersonAggregate> returnCollection(); |
||||
|
||||
@Aggregation(RAW_GROUP_BY_LASTNAME_STRING) |
||||
AggregationResults<PersonAggregate> returnRawResultType(); |
||||
|
||||
@Aggregation(RAW_GROUP_BY_LASTNAME_STRING) |
||||
AggregationResults<PersonAggregate> 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 { |
||||
|
||||
final TypedAggregation<?> aggregation; |
||||
final Class<?> targetType; |
||||
final Object result; |
||||
} |
||||
} |
||||
@ -0,0 +1,90 @@
@@ -0,0 +1,90 @@
|
||||
[[mongodb.repositories.queries.aggregation]] |
||||
=== Aggregation Repository Methods |
||||
|
||||
The repository layer offers means interact with <<mongo.aggregation, the aggregation framework>> via annotated repository |
||||
finder methods. Similar to the <<mongodb.repositories.queries.json-based, JSON based queries>> a pipeline can be defined |
||||
via 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<Person, String> { |
||||
|
||||
@Aggregation("{ $group: { _id : $lastname, names : { $addToSet : $?0 } } }") |
||||
List<PersonAggregate> groupByLastnameAnd(String property); <1> |
||||
|
||||
@Aggregation("{ $group: { _id : $lastname, names : { $addToSet : $firstname } } }") |
||||
List<PersonAggregate> groupByLastnameAndFirstnames(Sort sort); <2> |
||||
|
||||
@Aggregation("{ $group: { _id : $lastname, names : { $addToSet : $?0 } } }") |
||||
List<PersonAggregate> groupByLastnameAnd(String property, Pageable page); <3> |
||||
|
||||
@Aggregation("{ $group : { _id : null, total : { $sum : $age } } }") |
||||
SumValue sumAgeUsingValueWrapper(); <4> |
||||
|
||||
@Aggregation("{ $group : { _id : null, total : { $sum : $age } } }") |
||||
Long sumAge(); <5> |
||||
|
||||
@Aggregation("{ $group : { _id : null, total : { $sum : $age } } }") |
||||
AggregationResults<SumValue> sumAgeRaw(); <6> |
||||
|
||||
@Aggregation("{ '$project': { '_id' : '$lastname' } }") |
||||
List<String> findAllLastnames(); <7> |
||||
} |
||||
---- |
||||
[source,java] |
||||
---- |
||||
public class PersonAggregate { |
||||
|
||||
private @Id String lastname; <2> |
||||
private List<String> names; |
||||
|
||||
public PersonAggregate(String lastname, List<String> names) { |
||||
// ... |
||||
} |
||||
|
||||
// Getter / Setter omitted |
||||
} |
||||
|
||||
public class SumValue { |
||||
|
||||
private final Long total; <4> <6> |
||||
|
||||
public SumValue(Long total) { |
||||
// ... |
||||
} |
||||
|
||||
// Getter omitted |
||||
} |
||||
---- |
||||
<1> Replace `?0` with the given value for `property`. |
||||
<2> If `Sort` argument is present, `$sort` is added at the pipelines tail 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> `$skip`, `$limit` and `$sort` can be passed on via a `Pageable` argument. Same as in 2., the operators are applied at |
||||
the pipelines tail. |
||||
<4> Map the result of an aggregation returning a single `Document` to an instance of a desired `SumValue` target type. |
||||
<5> 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 one might consider `AggregationResult` as the methods return type as shown in 4. or 6. |
||||
<6> Obtain the raw `AggregationResults` mapped to the generic target wrapper type `SumValue` or `org.bson.Document`. |
||||
<7> Like in (5) a single value can be directly obtained from mutliple result ``Document``s. |
||||
==== |
||||
|
||||
TIP: `@Aggregation` can also be used with <<mongo.reactive.repositories, Reactive Repositories>>. |
||||
|
||||
[NOTE] |
||||
==== |
||||
Obtaining simple type single results 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 execption if none of the above applied. |
||||
==== |
||||
|
||||
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. |
||||
Loading…
Reference in new issue