From 4700b4dda24ab1f786005234e3934f124a1ff104 Mon Sep 17 00:00:00 2001 From: Christoph Strobl Date: Mon, 25 Jan 2021 11:43:08 +0100 Subject: [PATCH] Use relaxed type mapping for aggregations by default. This commit switches from a strict to a relaxed type mapping for aggregation executions. This allows users to add fields to the aggregation that might be part of the stored document but not necessarily of its java model representation. Instead of throwing an exception in those cases the relaxed type check will go on with the user provided field names. To restore the original behaviour use the strictMapping() option on AggregationOptions. Closes #3542 Original pull request: #3545. --- .../data/mongodb/core/AggregationUtil.java | 41 +++--- .../data/mongodb/core/MongoTemplate.java | 17 ++- .../data/mongodb/core/QueryOperations.java | 137 +++++++++++++++++- .../mongodb/core/ReactiveMongoTemplate.java | 34 ++--- .../core/aggregation/AggregationOptions.java | 72 +++++++++ ...dTypeBasedAggregationOperationContext.java | 3 +- .../TypeBasedAggregationOperationContext.java | 4 + .../mongodb/core/MongoTemplateUnitTests.java | 48 +++++- .../core/QueryOperationsUnitTests.java | 114 +++++++++++++++ src/main/asciidoc/reference/mongodb.adoc | 6 +- 10 files changed, 415 insertions(+), 61 deletions(-) create mode 100644 spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/QueryOperationsUnitTests.java 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 adb2e1d8b..fb721c3db 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.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.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.MongoPersistentEntity; 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 3955d860d..16a8b1998 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.SessionSynchronization; 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 class MongoTemplate implements MongoOperations, ApplicationContextAware, 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 @@ public class MongoTemplate implements MongoOperations, ApplicationContextAware, 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 @@ public class MongoTemplate implements MongoOperations, ApplicationContextAware, 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 67ac4fede..ec120047e 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.CodecRegistryProvider; 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; 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 @@ class QueryOperations { 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 @@ class QueryOperations { } @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 @@ class QueryOperations { 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 @@ class QueryOperations { 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 76afa855a..62673dbbf 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 @@ package org.springframework.data.mongodb.core; 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 class ReactiveMongoTemplate implements ReactiveMongoOperations, Applicati 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 class ReactiveMongoTemplate implements ReactiveMongoOperations, Applicati */ @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 class ReactiveMongoTemplate implements ReactiveMongoOperations, Applicati */ @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 c69e75e3e..4ef733506 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 class AggregationOptions { 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 class AggregationOptions { 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 class AggregationOptions { 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 class AggregationOptions { if (resultOptions != null) { options.resultOptions = resultOptions; } + if (domainTypeMapping != null) { + options.domainTypeMapping = domainTypeMapping; + } return options; } @@ -508,4 +559,25 @@ public class AggregationOptions { */ 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 fae573e82..89cb12697 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 @@ public class RelaxedTypeBasedAggregationOperationContext extends TypeBasedAggreg 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 5cb633740..ef9bfe73b 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 @@ public class TypeBasedAggregationOperationContext implements AggregationOperatio 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 e6f68a8c9..11d25bb19 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.Mock; 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.convert.CustomConversions; 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 @@ public class MongoTemplateUnitTests extends MongoOperationsUnitTests { 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 000000000..6a49adf26 --- /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 99e2e0ec7..7c25cebd4 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` +