diff --git a/pom.xml b/pom.xml index 10c1adf1bf..7d1121c693 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ org.springframework.data spring-data-mongodb-parent - 3.2.0-SNAPSHOT + 3.2.0-GH-3542-SNAPSHOT pom Spring Data MongoDB diff --git a/spring-data-mongodb-benchmarks/pom.xml b/spring-data-mongodb-benchmarks/pom.xml index f0fbb601c8..ecb2010181 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 - 3.2.0-SNAPSHOT + 3.2.0-GH-3542-SNAPSHOT ../pom.xml diff --git a/spring-data-mongodb-distribution/pom.xml b/spring-data-mongodb-distribution/pom.xml index 1a17321782..c983ef7976 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 - 3.2.0-SNAPSHOT + 3.2.0-GH-3542-SNAPSHOT ../pom.xml diff --git a/spring-data-mongodb/pom.xml b/spring-data-mongodb/pom.xml index 0248517caf..14cdbcb318 100644 --- a/spring-data-mongodb/pom.xml +++ b/spring-data-mongodb/pom.xml @@ -11,7 +11,7 @@ org.springframework.data spring-data-mongodb-parent - 3.2.0-SNAPSHOT + 3.2.0-GH-3542-SNAPSHOT ../pom.xml diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/AggregationUtil.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/AggregationUtil.java index adb2e1d8ba..fb721c3db3 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/AggregationUtil.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/AggregationUtil.java @@ -27,6 +27,7 @@ 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.aggregation.AggregationOptions.DomainTypeMapping; import org.springframework.data.mongodb.core.aggregation.CountOperation; import org.springframework.data.mongodb.core.aggregation.RelaxedTypeBasedAggregationOperationContext; import org.springframework.data.mongodb.core.aggregation.TypeBasedAggregationOperationContext; @@ -36,6 +37,7 @@ import org.springframework.data.mongodb.core.mapping.MongoPersistentProperty; import org.springframework.data.mongodb.core.query.CriteriaDefinition; import org.springframework.data.mongodb.core.query.Query; +import org.springframework.data.util.Lazy; import org.springframework.lang.Nullable; import org.springframework.util.Assert; import org.springframework.util.ObjectUtils; @@ -52,41 +54,44 @@ class AggregationUtil { QueryMapper queryMapper; MappingContext, MongoPersistentProperty> mappingContext; + Lazy untypedMappingContext; AggregationUtil(QueryMapper queryMapper, MappingContext, MongoPersistentProperty> mappingContext) { this.queryMapper = queryMapper; this.mappingContext = mappingContext; + this.untypedMappingContext = Lazy + .of(() -> new RelaxedTypeBasedAggregationOperationContext(Object.class, mappingContext, queryMapper)); } - /** - * Prepare the {@link AggregationOperationContext} for a given aggregation by either returning the context itself it - * is not {@literal null}, create a {@link TypeBasedAggregationOperationContext} if the aggregation contains type - * information (is a {@link TypedAggregation}) or use the {@link Aggregation#DEFAULT_CONTEXT}. - * - * @param aggregation must not be {@literal null}. - * @param context can be {@literal null}. - * @return the root {@link AggregationOperationContext} to use. - */ - AggregationOperationContext prepareAggregationContext(Aggregation aggregation, - @Nullable AggregationOperationContext context) { + AggregationOperationContext createAggregationContext(Aggregation aggregation, @Nullable Class inputType) { - if (context != null) { - return context; + if (aggregation.getOptions().getDomainTypeMapping() == DomainTypeMapping.NONE) { + return Aggregation.DEFAULT_CONTEXT; } if (!(aggregation instanceof TypedAggregation)) { - return new RelaxedTypeBasedAggregationOperationContext(Object.class, mappingContext, queryMapper); - } - Class inputType = ((TypedAggregation) aggregation).getInputType(); + if(inputType == null) { + return untypedMappingContext.get(); + } + + if (aggregation.getOptions().getDomainTypeMapping() == DomainTypeMapping.STRICT + && !aggregation.getPipeline().containsUnionWith()) { + return new TypeBasedAggregationOperationContext(inputType, mappingContext, queryMapper); + } - if (aggregation.getPipeline().containsUnionWith()) { return new RelaxedTypeBasedAggregationOperationContext(inputType, mappingContext, queryMapper); } - return new TypeBasedAggregationOperationContext(inputType, mappingContext, queryMapper); + inputType = ((TypedAggregation) aggregation).getInputType(); + if (aggregation.getOptions().getDomainTypeMapping() == DomainTypeMapping.STRICT + && !aggregation.getPipeline().containsUnionWith()) { + return new TypeBasedAggregationOperationContext(inputType, mappingContext, queryMapper); + } + + return new RelaxedTypeBasedAggregationOperationContext(inputType, mappingContext, queryMapper); } /** diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/MongoTemplate.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/MongoTemplate.java index 3955d860d8..16a8b19989 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/MongoTemplate.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/MongoTemplate.java @@ -55,6 +55,7 @@ import org.springframework.data.mongodb.core.BulkOperations.BulkMode; import org.springframework.data.mongodb.core.DefaultBulkOperations.BulkOperationContext; import org.springframework.data.mongodb.core.EntityOperations.AdaptibleEntity; +import org.springframework.data.mongodb.core.QueryOperations.AggregateContext; import org.springframework.data.mongodb.core.QueryOperations.CountContext; import org.springframework.data.mongodb.core.QueryOperations.DeleteContext; import org.springframework.data.mongodb.core.QueryOperations.DistinctQueryContext; @@ -1988,7 +1989,7 @@ public AggregationResults aggregate(TypedAggregation aggregation, Stri public AggregationResults aggregate(Aggregation aggregation, Class inputType, Class outputType) { return aggregate(aggregation, getCollectionName(inputType), outputType, - new TypeBasedAggregationOperationContext(inputType, mappingContext, queryMapper)); + queryOperations.createAggregationContext(aggregation, inputType).getAggregationOperationContext()); } /* (non-Javadoc) @@ -2095,9 +2096,12 @@ protected AggregationResults aggregate(Aggregation aggregation, String co Assert.notNull(aggregation, "Aggregation pipeline must not be null!"); Assert.notNull(outputType, "Output type must not be null!"); - AggregationOperationContext contextToUse = new AggregationUtil(queryMapper, mappingContext) - .prepareAggregationContext(aggregation, context); - return doAggregate(aggregation, collectionName, outputType, contextToUse); + return doAggregate(aggregation, collectionName, outputType, queryOperations.createAggregationContext(aggregation, context)); + } + + private AggregationResults doAggregate(Aggregation aggregation, String collectionName, Class outputType, + AggregateContext context) { + return doAggregate(aggregation, collectionName, outputType, context.getAggregationOperationContext()); } @SuppressWarnings("ConstantConditions") @@ -2185,11 +2189,10 @@ protected CloseableIterator aggregateStream(Aggregation aggregation, Stri Assert.notNull(outputType, "Output type must not be null!"); Assert.isTrue(!aggregation.getOptions().isExplain(), "Can't use explain option with streaming!"); - AggregationUtil aggregationUtil = new AggregationUtil(queryMapper, mappingContext); - AggregationOperationContext rootContext = aggregationUtil.prepareAggregationContext(aggregation, context); + AggregateContext aggregateContext = queryOperations.createAggregationContext(aggregation, context); AggregationOptions options = aggregation.getOptions(); - List pipeline = aggregationUtil.createPipeline(aggregation, rootContext); + List pipeline = aggregateContext.getAggregationPipeline(); if (LOGGER.isDebugEnabled()) { LOGGER.debug("Streaming aggregation: {} in collection {}", serializeToJsonSafely(pipeline), collectionName); diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/QueryOperations.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/QueryOperations.java index 67ac4fede6..ec120047e2 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/QueryOperations.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/QueryOperations.java @@ -34,8 +34,12 @@ import org.springframework.data.mongodb.core.MappedDocument.MappedUpdate; import org.springframework.data.mongodb.core.aggregation.Aggregation; import org.springframework.data.mongodb.core.aggregation.AggregationOperationContext; +import org.springframework.data.mongodb.core.aggregation.AggregationOptions; +import org.springframework.data.mongodb.core.aggregation.AggregationPipeline; import org.springframework.data.mongodb.core.aggregation.AggregationUpdate; import org.springframework.data.mongodb.core.aggregation.RelaxedTypeBasedAggregationOperationContext; +import org.springframework.data.mongodb.core.aggregation.TypeBasedAggregationOperationContext; +import org.springframework.data.mongodb.core.aggregation.TypedAggregation; import org.springframework.data.mongodb.core.convert.QueryMapper; import org.springframework.data.mongodb.core.convert.UpdateMapper; import org.springframework.data.mongodb.core.mapping.MongoPersistentEntity; @@ -48,6 +52,7 @@ import org.springframework.data.mongodb.core.query.UpdateDefinition.ArrayFilter; import org.springframework.data.mongodb.util.BsonUtils; import org.springframework.data.projection.ProjectionFactory; +import org.springframework.data.util.Lazy; import org.springframework.lang.Nullable; import org.springframework.util.ClassUtils; import org.springframework.util.ObjectUtils; @@ -194,6 +199,31 @@ DeleteContext deleteSingleContext(Query query) { return new DeleteContext(query, false); } + /** + * Create a new {@link AggregateContext} for the given {@link Aggregation}. + * + * @param aggregation must not be {@literal null}. + * @param inputType fallback mapping type in case of untyped aggregation. Can be {@literal null}. + * @return new instance of {@link AggregateContext}. + * @since 3.2 + */ + AggregateContext createAggregationContext(Aggregation aggregation, @Nullable Class inputType) { + return new AggregateContext(aggregation, inputType); + } + + /** + * Create a new {@link AggregateContext} for the given {@link Aggregation}. + * + * @param aggregation must not be {@literal null}. + * @param aggregationOperationContext the {@link AggregationOperationContext} to use. Can be {@literal null}. + * @return new instance of {@link AggregateContext}. + * @since 3.2 + */ + AggregateContext createAggregationContext(Aggregation aggregation, + @Nullable AggregationOperationContext aggregationOperationContext) { + return new AggregateContext(aggregation, aggregationOperationContext); + } + /** * {@link QueryContext} encapsulates common tasks required to convert a {@link Query} into its MongoDB document * representation, mapping fieldnames, as well as determinging and applying {@link Collation collations}. @@ -341,7 +371,8 @@ private DistinctQueryContext(@Nullable Object query, String fieldName) { } @Override - Document getMappedFields(@Nullable MongoPersistentEntity entity, Class targetType, ProjectionFactory projectionFactory) { + Document getMappedFields(@Nullable MongoPersistentEntity entity, Class targetType, + ProjectionFactory projectionFactory) { return getMappedFields(entity); } @@ -709,7 +740,8 @@ List getUpdatePipeline(@Nullable Class domainType) { Class type = domainType != null ? domainType : Object.class; - AggregationOperationContext context = new RelaxedTypeBasedAggregationOperationContext(type, mappingContext, queryMapper); + AggregationOperationContext context = new RelaxedTypeBasedAggregationOperationContext(type, mappingContext, + queryMapper); return aggregationUtil.createPipeline((AggregationUpdate) update, context); } @@ -759,4 +791,105 @@ boolean isMulti() { return multi; } } + + /** + * A context class that encapsulates common tasks required when running {@literal aggregations}. + * + * @since 3.2 + */ + class AggregateContext { + + private Aggregation aggregation; + private Lazy aggregationOperationContext; + private Lazy> pipeline; + private @Nullable Class inputType; + + /** + * Creates new instance of {@link AggregateContext} extracting the input type from either the + * {@link org.springframework.data.mongodb.core.aggregation.Aggregation} in case of a {@link TypedAggregation} or + * the given {@literal aggregationOperationContext} if present.
+ * Creates a new {@link AggregationOperationContext} if none given, based on the {@link Aggregation} input type and + * the desired {@link AggregationOptions#getDomainTypeMapping() domain type mapping}.
+ * Pipelines are mapped on first access of {@link #getAggregationPipeline()} and cached for reuse. + * + * @param aggregation the source aggregation. + * @param aggregationOperationContext can be {@literal null}. + */ + AggregateContext(Aggregation aggregation, @Nullable AggregationOperationContext aggregationOperationContext) { + + this.aggregation = aggregation; + if (aggregation instanceof TypedAggregation) { + this.inputType = ((TypedAggregation) aggregation).getInputType(); + } else if (aggregationOperationContext instanceof TypeBasedAggregationOperationContext) { + this.inputType = ((TypeBasedAggregationOperationContext) aggregationOperationContext).getType(); + } + this.aggregationOperationContext = Lazy.of(() -> aggregationOperationContext != null ? aggregationOperationContext + : aggregationUtil.createAggregationContext(aggregation, getInputType())); + this.pipeline = Lazy.of(() -> aggregationUtil.createPipeline(this.aggregation, getAggregationOperationContext())); + } + + /** + * Creates new instance of {@link AggregateContext} extracting the input type from either the + * {@link org.springframework.data.mongodb.core.aggregation.Aggregation} in case of a {@link TypedAggregation} or + * the given {@literal aggregationOperationContext} if present.
+ * Creates a new {@link AggregationOperationContext} based on the {@link Aggregation} input type and the desired + * {@link AggregationOptions#getDomainTypeMapping() domain type mapping}.
+ * Pipelines are mapped on first access of {@link #getAggregationPipeline()} and cached for reuse. + * + * @param aggregation the source aggregation. + * @param inputType can be {@literal null}. + */ + AggregateContext(Aggregation aggregation, @Nullable Class inputType) { + + this.aggregation = aggregation; + + if (aggregation instanceof TypedAggregation) { + this.inputType = ((TypedAggregation) aggregation).getInputType(); + } else { + this.inputType = inputType; + } + + this.aggregationOperationContext = Lazy + .of(() -> aggregationUtil.createAggregationContext(aggregation, getInputType())); + this.pipeline = Lazy.of(() -> aggregationUtil.createPipeline(this.aggregation, getAggregationOperationContext())); + } + + /** + * Obtain the already mapped pipeline. + * + * @return never {@literal null}. + */ + List getAggregationPipeline() { + return pipeline.get(); + } + + /** + * @return {@literal true} if the last aggregation stage is either {@literal $out} or {@literal $merge}. + * @see AggregationPipeline#isOutOrMerge() + */ + boolean isOutOrMerge() { + return aggregation.getPipeline().isOutOrMerge(); + } + + /** + * Obtain the {@link AggregationOperationContext} used for mapping the pipeline. + * + * @return never {@literal null}. + */ + AggregationOperationContext getAggregationOperationContext() { + return aggregationOperationContext.get(); + } + + /** + * @return the input type to map the pipeline against. Can be {@literal null}. + */ + @Nullable + Class getInputType() { + return inputType; + } + + Document getAggregationCommand(String collectionName) { + return aggregationUtil.createCommand(collectionName, aggregation, getAggregationOperationContext()); + } + } } diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/ReactiveMongoTemplate.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/ReactiveMongoTemplate.java index 76afa855a1..62673dbbfc 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/ReactiveMongoTemplate.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/ReactiveMongoTemplate.java @@ -17,6 +17,7 @@ import static org.springframework.data.mongodb.core.query.SerializationUtils.*; +import org.springframework.data.mongodb.core.QueryOperations.AggregateContext; import org.springframework.data.mongodb.core.aggregation.RelaxedTypeBasedAggregationOperationContext; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; @@ -946,9 +947,7 @@ public Flux aggregate(TypedAggregation aggregation, String inputCollec Assert.notNull(aggregation, "Aggregation pipeline must not be null!"); - AggregationOperationContext context = new TypeBasedAggregationOperationContext(aggregation.getInputType(), - mappingContext, queryMapper); - return aggregate(aggregation, inputCollectionName, outputType, context); + return doAggregate(aggregation, inputCollectionName, aggregation.getInputType(), outputType); } /* @@ -966,9 +965,7 @@ public Flux aggregate(TypedAggregation aggregation, Class outputTyp */ @Override public Flux aggregate(Aggregation aggregation, Class inputType, Class outputType) { - - return aggregate(aggregation, getCollectionName(inputType), outputType, - new TypeBasedAggregationOperationContext(inputType, mappingContext, queryMapper)); + return doAggregate(aggregation, getCollectionName(inputType), inputType, outputType); } /* @@ -977,40 +974,29 @@ public Flux aggregate(Aggregation aggregation, Class inputType, Class< */ @Override public Flux aggregate(Aggregation aggregation, String collectionName, Class outputType) { - return aggregate(aggregation, collectionName, outputType, null); + return doAggregate(aggregation, collectionName, null, outputType); } - /** - * @param aggregation must not be {@literal null}. - * @param collectionName must not be {@literal null}. - * @param outputType must not be {@literal null}. - * @param context can be {@literal null} and will be defaulted to {@link Aggregation#DEFAULT_CONTEXT}. - * @return never {@literal null}. - */ - protected Flux aggregate(Aggregation aggregation, String collectionName, Class outputType, - @Nullable AggregationOperationContext context) { + protected Flux doAggregate(Aggregation aggregation, String collectionName, @Nullable Class inputType, Class outputType) { Assert.notNull(aggregation, "Aggregation pipeline must not be null!"); Assert.hasText(collectionName, "Collection name must not be null or empty!"); Assert.notNull(outputType, "Output type must not be null!"); - AggregationUtil aggregationUtil = new AggregationUtil(queryMapper, mappingContext); - AggregationOperationContext rootContext = aggregationUtil.prepareAggregationContext(aggregation, context); - AggregationOptions options = aggregation.getOptions(); - List pipeline = aggregationUtil.createPipeline(aggregation, rootContext); - Assert.isTrue(!options.isExplain(), "Cannot use explain option with streaming!"); + AggregateContext ctx = queryOperations.createAggregationContext(aggregation, inputType); + if (LOGGER.isDebugEnabled()) { - LOGGER.debug("Streaming aggregation: {} in collection {}", serializeToJsonSafely(pipeline), collectionName); + LOGGER.debug("Streaming aggregation: {} in collection {}", serializeToJsonSafely(ctx.getAggregationPipeline()), collectionName); } ReadDocumentCallback readCallback = new ReadDocumentCallback<>(mongoConverter, outputType, collectionName); return execute(collectionName, - collection -> aggregateAndMap(collection, pipeline, aggregation.getPipeline().isOutOrMerge(), options, + collection -> aggregateAndMap(collection, ctx.getAggregationPipeline(), ctx.isOutOrMerge(), options, readCallback, - aggregation instanceof TypedAggregation ? ((TypedAggregation) aggregation).getInputType() : null)); + ctx.getInputType())); } private Flux aggregateAndMap(MongoCollection collection, List pipeline, diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/AggregationOptions.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/AggregationOptions.java index c69e75e3ec..4ef7335068 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/AggregationOptions.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/AggregationOptions.java @@ -56,6 +56,7 @@ public class AggregationOptions { private final Optional hint; private Duration maxTime = Duration.ZERO; private ResultOptions resultOptions = ResultOptions.READ; + private DomainTypeMapping domainTypeMapping = DomainTypeMapping.RELAXED; /** * Creates a new {@link AggregationOptions}. @@ -261,6 +262,14 @@ public boolean isSkipResults() { return ResultOptions.SKIP.equals(resultOptions); } + /** + * @return the domain type mapping strategy do apply. Never {@literal null}. + * @since 3.2 + */ + public DomainTypeMapping getDomainTypeMapping() { + return domainTypeMapping; + } + /** * Returns a new potentially adjusted copy for the given {@code aggregationCommandObject} with the configuration * applied. @@ -358,6 +367,7 @@ public static class Builder { private @Nullable Document hint; private @Nullable Duration maxTime; private @Nullable ResultOptions resultOptions; + private @Nullable DomainTypeMapping domainTypeMapping; /** * Defines whether to off-load intensive sort-operations to disk. @@ -475,6 +485,44 @@ public Builder skipOutput() { return this; } + /** + * Apply a strict domain type mapping considering {@link org.springframework.data.mongodb.core.mapping.Field} + * annotations throwing errors for non existing, but referenced fields. + * + * @return this. + * @since 3.2 + */ + public Builder strictMapping() { + + this.domainTypeMapping = DomainTypeMapping.STRICT; + return this; + } + + /** + * Apply a relaxed domain type mapping considering {@link org.springframework.data.mongodb.core.mapping.Field} + * annotations using the user provided name if a referenced field does not exist. + * + * @return this. + * @since 3.2 + */ + public Builder relaxedMapping() { + + this.domainTypeMapping = DomainTypeMapping.RELAXED; + return this; + } + + /** + * Apply no domain type mapping at all taking the pipeline as is. + * + * @return this. + * @since 3.2 + */ + public Builder noMapping() { + + this.domainTypeMapping = DomainTypeMapping.NONE; + return this; + } + /** * Returns a new {@link AggregationOptions} instance with the given configuration. * @@ -489,6 +537,9 @@ public AggregationOptions build() { if (resultOptions != null) { options.resultOptions = resultOptions; } + if (domainTypeMapping != null) { + options.domainTypeMapping = domainTypeMapping; + } return options; } @@ -508,4 +559,25 @@ private enum ResultOptions { */ READ; } + + /** + * Aggregation pipeline Domain type mappings supported by the mapping layer. + * + * @since 3.2 + */ + public enum DomainTypeMapping { + + /** + * Mapping throws errors for non existing, but referenced fields. + */ + STRICT, + /** + * Fields that do not exist in the model are treated as is. + */ + RELAXED, + /** + * Do not attempt to map fields against the model and treat the entire pipeline as is. + */ + NONE + } } diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/RelaxedTypeBasedAggregationOperationContext.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/RelaxedTypeBasedAggregationOperationContext.java index fae573e822..89cb126973 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/RelaxedTypeBasedAggregationOperationContext.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/RelaxedTypeBasedAggregationOperationContext.java @@ -15,6 +15,7 @@ */ package org.springframework.data.mongodb.core.aggregation; +import org.springframework.data.mapping.MappingException; import org.springframework.data.mapping.context.InvalidPersistentPropertyPath; import org.springframework.data.mapping.context.MappingContext; import org.springframework.data.mongodb.core.aggregation.ExposedFields.DirectFieldReference; @@ -55,7 +56,7 @@ protected FieldReference getReferenceFor(Field field) { try { return super.getReferenceFor(field); - } catch (InvalidPersistentPropertyPath e) { + } catch (MappingException e) { return new DirectFieldReference(new ExposedField(field, true)); } } 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 5cb633740e..ef9bfe73bb 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 @@ -166,4 +166,8 @@ protected FieldReference getReferenceFor(Field field) { return new DirectFieldReference(new ExposedField(mappedField, true)); } + + public Class getType() { + return type; + } } diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/MongoTemplateUnitTests.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/MongoTemplateUnitTests.java index e6f68a8c9a..11d25bb19f 100644 --- a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/MongoTemplateUnitTests.java +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/MongoTemplateUnitTests.java @@ -50,7 +50,6 @@ import org.mockito.Mockito; import org.mockito.junit.jupiter.MockitoSettings; import org.mockito.quality.Strictness; - import org.springframework.beans.factory.annotation.Value; import org.springframework.context.ApplicationContext; import org.springframework.context.ApplicationListener; @@ -66,16 +65,11 @@ import org.springframework.data.domain.Sort; import org.springframework.data.geo.Point; import org.springframework.data.mapping.callback.EntityCallbacks; +import org.springframework.data.mapping.context.InvalidPersistentPropertyPath; import org.springframework.data.mongodb.MongoDatabaseFactory; -import org.springframework.data.mongodb.core.aggregation.Aggregation; -import org.springframework.data.mongodb.core.aggregation.AggregationOptions; -import org.springframework.data.mongodb.core.aggregation.AggregationUpdate; -import org.springframework.data.mongodb.core.aggregation.ArithmeticOperators; +import org.springframework.data.mongodb.core.aggregation.*; import org.springframework.data.mongodb.core.aggregation.ComparisonOperators.Gte; -import org.springframework.data.mongodb.core.aggregation.ConditionalOperators; import org.springframework.data.mongodb.core.aggregation.ConditionalOperators.Switch.CaseOperator; -import org.springframework.data.mongodb.core.aggregation.Fields; -import org.springframework.data.mongodb.core.aggregation.SetOperation; import org.springframework.data.mongodb.core.convert.DefaultDbRefResolver; import org.springframework.data.mongodb.core.convert.MappingMongoConverter; import org.springframework.data.mongodb.core.convert.MongoCustomConversions; @@ -480,6 +474,44 @@ void aggregateShouldHonorOptionsHint() { verify(aggregateIterable).hint(hint); } + @Test // GH-3542 + void aggregateShouldUseRelaxedMappingByDefault() { + + MongoTemplate template = new MongoTemplate(factory, converter) { + + @Override + protected AggregationResults doAggregate(Aggregation aggregation, String collectionName, + Class outputType, AggregationOperationContext context) { + + assertThat(context).isInstanceOf(RelaxedTypeBasedAggregationOperationContext.class); + return super.doAggregate(aggregation, collectionName, outputType, context); + } + }; + + template.aggregate( + newAggregation(Jedi.class, Aggregation.unwind("foo")).withOptions(AggregationOptions.builder().build()), + Jedi.class); + } + + @Test // GH-3542 + void aggregateShouldUseStrictMappingIfOptionsIndicate() { + + MongoTemplate template = new MongoTemplate(factory, converter) { + + @Override + protected AggregationResults doAggregate(Aggregation aggregation, String collectionName, + Class outputType, AggregationOperationContext context) { + + assertThat(context).isInstanceOf(TypeBasedAggregationOperationContext.class); + return super.doAggregate(aggregation, collectionName, outputType, context); + } + }; + + assertThatExceptionOfType(InvalidPersistentPropertyPath.class) + .isThrownBy(() -> template.aggregate(newAggregation(Jedi.class, Aggregation.unwind("foo")) + .withOptions(AggregationOptions.builder().strictMapping().build()), Jedi.class)); + } + @Test // DATAMONGO-1166, DATAMONGO-2264 void geoNearShouldHonorReadPreferenceWhenSet() { diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/QueryOperationsUnitTests.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/QueryOperationsUnitTests.java new file mode 100644 index 0000000000..6a49adf265 --- /dev/null +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/QueryOperationsUnitTests.java @@ -0,0 +1,114 @@ +/* + * Copyright 2021 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.mongodb.core; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.mapping.context.MappingContext; +import org.springframework.data.mongodb.MongoDatabaseFactory; +import org.springframework.data.mongodb.core.QueryOperations.AggregateContext; +import org.springframework.data.mongodb.core.aggregation.Aggregation; +import org.springframework.data.mongodb.core.aggregation.AggregationOptions; +import org.springframework.data.mongodb.core.aggregation.RelaxedTypeBasedAggregationOperationContext; +import org.springframework.data.mongodb.core.aggregation.TypeBasedAggregationOperationContext; +import org.springframework.data.mongodb.core.convert.QueryMapper; +import org.springframework.data.mongodb.core.convert.UpdateMapper; +import org.springframework.data.mongodb.core.mapping.MongoMappingContext; + +/** + * @author Christoph Strobl + * @since 2021/01 + */ +@ExtendWith(MockitoExtension.class) +class QueryOperationsUnitTests { + + static final AggregationOptions NO_MAPPING = AggregationOptions.builder().noMapping().build(); + static final AggregationOptions STRICT_MAPPING = AggregationOptions.builder().strictMapping().build(); + + @Mock QueryMapper queryMapper; + @Mock UpdateMapper updateMapper; + @Mock EntityOperations entityOperations; + @Mock PropertyOperations propertyOperations; + @Mock MongoDatabaseFactory mongoDbFactory; + @Mock MongoMappingContext mappingContext; + + QueryOperations queryOperations; + + @BeforeEach + void beforeEach() { + + when(queryMapper.getMappingContext()).thenReturn((MappingContext) mappingContext); + + queryOperations = new QueryOperations(queryMapper, updateMapper, entityOperations, propertyOperations, + mongoDbFactory); + } + + @Test // GH-3542 + void createAggregationContextUsesRelaxedOneForUntypedAggregationsWhenNoInputTypeProvided() { + + Aggregation aggregation = Aggregation.newAggregation(Aggregation.project("name")); + AggregateContext ctx = queryOperations.createAggregationContext(aggregation, (Class) null); + + assertThat(ctx.getAggregationOperationContext()).isInstanceOf(RelaxedTypeBasedAggregationOperationContext.class); + } + + @Test // GH-3542 + void createAggregationContextUsesRelaxedOneForTypedAggregationsWhenNoInputTypeProvided() { + + Aggregation aggregation = Aggregation.newAggregation(Person.class, Aggregation.project("name")); + AggregateContext ctx = queryOperations.createAggregationContext(aggregation, (Class) null); + + assertThat(ctx.getAggregationOperationContext()).isInstanceOf(RelaxedTypeBasedAggregationOperationContext.class); + } + + @Test // GH-3542 + void createAggregationContextUsesRelaxedOneForUntypedAggregationsWhenInputTypeProvided() { + + Aggregation aggregation = Aggregation.newAggregation(Aggregation.project("name")); + AggregateContext ctx = queryOperations.createAggregationContext(aggregation, Person.class); + + assertThat(ctx.getAggregationOperationContext()).isInstanceOf(RelaxedTypeBasedAggregationOperationContext.class); + } + + @Test // GH-3542 + void createAggregationContextUsesDefaultIfNoMappingDesired() { + + Aggregation aggregation = Aggregation.newAggregation(Aggregation.project("name")).withOptions(NO_MAPPING); + AggregateContext ctx = queryOperations.createAggregationContext(aggregation, Person.class); + + assertThat(ctx.getAggregationOperationContext()).isEqualTo(Aggregation.DEFAULT_CONTEXT); + } + + @Test // GH-3542 + void createAggregationContextUsesStrictlyTypedContextForTypedAggregationsWhenRequested() { + + Aggregation aggregation = Aggregation.newAggregation(Person.class, Aggregation.project("name")) + .withOptions(STRICT_MAPPING); + AggregateContext ctx = queryOperations.createAggregationContext(aggregation, (Class) null); + + assertThat(ctx.getAggregationOperationContext()).isInstanceOf(TypeBasedAggregationOperationContext.class); + } + + static class Person { + + } +} diff --git a/src/main/asciidoc/reference/mongodb.adoc b/src/main/asciidoc/reference/mongodb.adoc index 99e2e0ec70..7c25cebd43 100644 --- a/src/main/asciidoc/reference/mongodb.adoc +++ b/src/main/asciidoc/reference/mongodb.adoc @@ -2514,7 +2514,11 @@ The actual aggregate operation is run by the `aggregate` method of the `MongoTem + A `TypedAggregation`, just like an `Aggregation`, holds the instructions of the aggregation pipeline and a reference to the input type, that is used for mapping domain properties to actual document fields. + -At runtime, field references get checked against the given input type, considering potential `@Field` annotations and raising errors when referencing nonexistent properties. +At runtime, field references get checked against the given input type, considering potential `@Field` annotations. +[NOTE] +==== +Changed in 3.2 referencing nonexistent properties does no longer raise errors. To restore the previous behaviour use the `strictMapping` option of `AggregationOptions`. +==== + * `AggregationOperation` +