From 6a5202ed7a2e3fb3534fcb29d6e2b7c71aac6444 Mon Sep 17 00:00:00 2001 From: Christoph Strobl Date: Tue, 9 Mar 2021 07:55:52 +0100 Subject: [PATCH 1/3] Prepare issue branch. --- pom.xml | 2 +- spring-data-mongodb-benchmarks/pom.xml | 2 +- spring-data-mongodb-distribution/pom.xml | 2 +- spring-data-mongodb/pom.xml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/pom.xml b/pom.xml index 828f56af2e..6221fcd774 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-3583-SNAPSHOT pom Spring Data MongoDB diff --git a/spring-data-mongodb-benchmarks/pom.xml b/spring-data-mongodb-benchmarks/pom.xml index f0fbb601c8..0e3cea0f2b 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-3583-SNAPSHOT ../pom.xml diff --git a/spring-data-mongodb-distribution/pom.xml b/spring-data-mongodb-distribution/pom.xml index 1a17321782..53b33c2a04 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-3583-SNAPSHOT ../pom.xml diff --git a/spring-data-mongodb/pom.xml b/spring-data-mongodb/pom.xml index 0248517caf..5177610a61 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-3583-SNAPSHOT ../pom.xml From 9369bd6c2bcb4ba3e5b34c5f510191cff7df17a1 Mon Sep 17 00:00:00 2001 From: Christoph Strobl Date: Wed, 10 Mar 2021 13:27:43 +0100 Subject: [PATCH 2/3] Support expressions in query field projections. // explicit via dedicated AggregationExpression query.fields() .project(StringOperators.valueOf("name").toUpper()) .as("name"); // with a user provided expression parsed from a String query.fields().project(MongoExpression.from("'$toUpper' : '$name'")) .as("name") // using SpEL support query.fields().project(AggregationSpELExpression.expressionOf("toUpper(name)")) .as("name"); // with parameter binding query.fields().project( MongoExpression.from("'$toUpper' : '?0'").bind("$name") ).as("name") // via the @Query annotation on repositories @Query(value = "{ 'id' : ?0 }", fields = "{ 'name': { '$toUpper': '$name' } }") --- .../data/mongodb/BindableMongoExpression.java | 141 +++++++++++++ .../data/mongodb/MongoExpression.java | 86 ++++++++ .../data/mongodb/core/QueryOperations.java | 20 +- .../aggregation/AggregationExpression.java | 32 ++- .../mongodb/core/convert/QueryMapper.java | 17 +- .../data/mongodb/core/query/Field.java | 80 +++++++- .../MongoTemplateFieldProjectionTests.java | 188 ++++++++++++++++++ ...tractPersonRepositoryIntegrationTests.java | 7 + .../mongodb/repository/PersonRepository.java | 3 + src/main/asciidoc/reference/mongodb.adoc | 63 ++++++ 10 files changed, 630 insertions(+), 7 deletions(-) create mode 100644 spring-data-mongodb/src/main/java/org/springframework/data/mongodb/BindableMongoExpression.java create mode 100644 spring-data-mongodb/src/main/java/org/springframework/data/mongodb/MongoExpression.java create mode 100644 spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/MongoTemplateFieldProjectionTests.java diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/BindableMongoExpression.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/BindableMongoExpression.java new file mode 100644 index 0000000000..c55d69aef6 --- /dev/null +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/BindableMongoExpression.java @@ -0,0 +1,141 @@ +/* + * 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; + +import org.bson.Document; +import org.bson.codecs.DocumentCodec; +import org.bson.codecs.configuration.CodecRegistry; +import org.springframework.data.mongodb.util.json.ParameterBindingDocumentCodec; +import org.springframework.data.util.Lazy; +import org.springframework.lang.Nullable; +import org.springframework.util.ObjectUtils; +import org.springframework.util.StringUtils; + +/** + * A {@link MongoExpression} using the {@link ParameterBindingDocumentCodec} for parsing the {@literal json} expression. + * The expression will be wrapped within { } if necessary. Placeholders like {@code ?0} are resolved when + * first obtaining the target {@link Document} via {@link #toDocument()}. + *

+ * + *

+ * $toUpper : $name                -> { '$toUpper' : '$name' }
+ * 
+ * { '$toUpper' : '$name' }        -> { '$toUpper' : '$name' }
+ * 
+ * { '$toUpper' : '?0' }, "$name"  -> { '$toUpper' : '$name' }
+ * 
+ * + * Some types (like {@link java.util.UUID}) cannot be used directly but require a special {@link org.bson.codecs.Codec}. + * Make sure to provide a {@link CodecRegistry} containing the required {@link org.bson.codecs.Codec codecs} via + * {@link #withCodecRegistry(CodecRegistry)}. + * + * @author Christoph Strobl + * @since 3.2 + */ +public class BindableMongoExpression implements MongoExpression { + + private final String json; + + @Nullable // + private final CodecRegistryProvider codecRegistryProvider; + + @Nullable // + private final Object[] args; + + private final Lazy target; + + /** + * Create a new instance of {@link BindableMongoExpression}. + * + * @param json must not be {@literal null}. + * @param args can be {@literal null}. + */ + public BindableMongoExpression(String json, @Nullable Object[] args) { + this(json, null, args); + } + + /** + * Create a new instance of {@link BindableMongoExpression}. + * + * @param json must not be {@literal null}. + * @param codecRegistryProvider can be {@literal null}. + * @param args can be {@literal null}. + */ + public BindableMongoExpression(String json, @Nullable CodecRegistryProvider codecRegistryProvider, + @Nullable Object[] args) { + + this.json = wrapJsonIfNecessary(json); + this.codecRegistryProvider = codecRegistryProvider; + this.args = args; + this.target = Lazy.of(this::parse); + } + + /** + * Provide the {@link CodecRegistry} used to convert expressions. + * + * @param codecRegistry must not be {@literal null}. + * @return new instance of {@link BindableMongoExpression}. + */ + public BindableMongoExpression withCodecRegistry(CodecRegistry codecRegistry) { + return new BindableMongoExpression(json, () -> codecRegistry, args); + } + + /** + * Provide the arguments to bind to the placeholders via their index. + * + * @param args must not be {@literal null}. + * @return new instance of {@link BindableMongoExpression}. + */ + public BindableMongoExpression bind(Object... args) { + return new BindableMongoExpression(json, codecRegistryProvider, args); + } + + /* + * (non-Javadoc) + * + * @see org.springframework.data.mongodb.MongoExpression#toDocument() + */ + @Override + public Document toDocument() { + return target.get(); + } + + private String wrapJsonIfNecessary(String json) { + + if (StringUtils.hasText(json) && (json.startsWith("{") && json.endsWith("}"))) { + return json; + } + + return "{" + json + "}"; + } + + private Document parse() { + + if (ObjectUtils.isEmpty(args)) { + + if (codecRegistryProvider == null) { + return Document.parse(json); + } + + return Document.parse(json, codecRegistryProvider.getCodecFor(Document.class) + .orElseGet(() -> new DocumentCodec(codecRegistryProvider.getCodecRegistry()))); + } + + ParameterBindingDocumentCodec codec = codecRegistryProvider == null ? new ParameterBindingDocumentCodec() + : new ParameterBindingDocumentCodec(codecRegistryProvider.getCodecRegistry()); + return codec.decode(json, args); + } +} diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/MongoExpression.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/MongoExpression.java new file mode 100644 index 0000000000..0bc4f1e0b6 --- /dev/null +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/MongoExpression.java @@ -0,0 +1,86 @@ +/* + * 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; + +import java.util.function.Function; + +/** + * Wrapper object for MongoDB expressions like {@code $toUpper : $name} that manifest as {@link org.bson.Document} when + * passed on to the driver. + *

+ * A set of predefined {@link MongoExpression expressions}, including a + * {@link org.springframework.data.mongodb.core.aggregation.AggregationSpELExpression SpEL based variant} for method + * like expressions (eg. {@code toUpper(name)}) are available via the + * {@link org.springframework.data.mongodb.core.aggregation Aggregation API}. + * + * @author Christoph Strobl + * @since 3.2 + * @see org.springframework.data.mongodb.core.aggregation.ArithmeticOperators + * @see org.springframework.data.mongodb.core.aggregation.ArrayOperators + * @see org.springframework.data.mongodb.core.aggregation.ComparisonOperators + * @see org.springframework.data.mongodb.core.aggregation.ConditionalOperators + * @see org.springframework.data.mongodb.core.aggregation.ConvertOperators + * @see org.springframework.data.mongodb.core.aggregation.DateOperators + * @see org.springframework.data.mongodb.core.aggregation.ObjectOperators + * @see org.springframework.data.mongodb.core.aggregation.SetOperators + * @see org.springframework.data.mongodb.core.aggregation.StringOperators @ + */ +@FunctionalInterface +public interface MongoExpression { + + /** + * Obtain the native {@link org.bson.Document} representation. + * + * @return never {@literal null}. + */ + org.bson.Document toDocument(); + + /** + * Convert this instance to another expression applying the given conversion {@link Function}. + * + * @param function must not be {@literal null}. + * @param + * @return never {@literal null}. + */ + default T as(Function function) { + return function.apply(this); + } + + /** + * Create a new {@link MongoExpression} from plain String (eg. {@code $toUpper : $name}).
+ * The given source value will be wrapped with { } to match an actual MongoDB {@link org.bson.Document} + * if necessary. + * + * @param json must not be {@literal null}. + * @return new instance of {@link MongoExpression}. + */ + static MongoExpression expressionFromString(String json) { + return new BindableMongoExpression(json, null); + } + + /** + * Create a new {@link MongoExpression} from plain String containing placeholders (eg. {@code $toUpper : ?0}) that + * will be resolved on {@link #toDocument()}.
+ * The given source value will be wrapped with { } to match an actual MongoDB {@link org.bson.Document} + * if necessary. + * + * @param json must not be {@literal null}. + * @return new instance of {@link MongoExpression}. + */ + static MongoExpression expressionFromString(String json, Object... args) { + return new BindableMongoExpression(json, args); + } +} 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 327d282193..0eab203177 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 @@ -17,6 +17,7 @@ import java.util.List; import java.util.Map; +import java.util.Map.Entry; import java.util.Optional; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; @@ -31,8 +32,10 @@ import org.springframework.data.mapping.PropertyReferenceException; import org.springframework.data.mapping.context.MappingContext; import org.springframework.data.mongodb.CodecRegistryProvider; +import org.springframework.data.mongodb.MongoExpression; import org.springframework.data.mongodb.core.MappedDocument.MappedUpdate; import org.springframework.data.mongodb.core.aggregation.Aggregation; +import org.springframework.data.mongodb.core.aggregation.AggregationExpression; import org.springframework.data.mongodb.core.aggregation.AggregationOperationContext; import org.springframework.data.mongodb.core.aggregation.AggregationOptions; import org.springframework.data.mongodb.core.aggregation.AggregationPipeline; @@ -288,7 +291,22 @@ Document getMappedQuery(@Nullable MongoPersistentEntity entity) { Document getMappedFields(@Nullable MongoPersistentEntity entity, Class targetType, ProjectionFactory projectionFactory) { - Document fields = query.getFieldsObject(); + Document fields = new Document(); + + for (Entry entry : query.getFieldsObject().entrySet()) { + + if (entry.getValue() instanceof MongoExpression) { + + AggregationOperationContext ctx = entity == null ? Aggregation.DEFAULT_CONTEXT + : new RelaxedTypeBasedAggregationOperationContext(entity.getType(), mappingContext, queryMapper); + + fields.put(entry.getKey(), + ((MongoExpression) entry.getValue()).as(AggregationExpression::create).toDocument(ctx)); + } else { + fields.put(entry.getKey(), entry.getValue()); + } + } + Document mappedFields = fields; if (entity == null) { diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/AggregationExpression.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/AggregationExpression.java index 2a0148cc5d..f49e0792cb 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/AggregationExpression.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/AggregationExpression.java @@ -16,6 +16,7 @@ package org.springframework.data.mongodb.core.aggregation; import org.bson.Document; +import org.springframework.data.mongodb.MongoExpression; /** * An {@link AggregationExpression} can be used with field expressions in aggregation pipeline stages like @@ -25,7 +26,36 @@ * @author Oliver Gierke * @author Christoph Strobl */ -public interface AggregationExpression { +public interface AggregationExpression extends MongoExpression { + + /** + * Obtain the as is (unmapped) representation of the {@link AggregationExpression}. Use + * {@link #toDocument(AggregationOperationContext)} with a matching {@link AggregationOperationContext context} to + * engage domain type mapping including field name resolution. + * + * @see org.springframework.data.mongodb.MongoExpression#toDocument() + */ + @Override + default Document toDocument() { + return toDocument(Aggregation.DEFAULT_CONTEXT); + } + + /** + * Create an {@link AggregationExpression} out of a given {@link MongoExpression}.
+ * If the given expression is already an {@link AggregationExpression} return the very same instance. + * + * @param expression must not be {@literal null}. + * @return never {@literal null}. + * @since 3.2 + */ + static AggregationExpression create(MongoExpression expression) { + + if (expression instanceof AggregationExpression) { + return AggregationExpression.class.cast(expression); + } + + return (context) -> context.getMappedObject(expression.toDocument()); + } /** * Turns the {@link AggregationExpression} into a {@link Document} within the given diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/QueryMapper.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/QueryMapper.java index 3d125f6bf2..00371f945e 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/QueryMapper.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/convert/QueryMapper.java @@ -24,7 +24,6 @@ import org.bson.Document; import org.bson.conversions.Bson; import org.bson.types.ObjectId; - import org.springframework.core.convert.ConversionService; import org.springframework.core.convert.converter.Converter; import org.springframework.data.domain.Example; @@ -37,6 +36,7 @@ import org.springframework.data.mapping.PropertyReferenceException; import org.springframework.data.mapping.context.InvalidPersistentPropertyPath; import org.springframework.data.mapping.context.MappingContext; +import org.springframework.data.mongodb.MongoExpression; import org.springframework.data.mongodb.core.convert.MappingMongoConverter.NestedDocument; import org.springframework.data.mongodb.core.mapping.MongoPersistentEntity; import org.springframework.data.mongodb.core.mapping.MongoPersistentProperty; @@ -295,6 +295,10 @@ protected Entry getMappedObjectForField(Field field, Object rawV String key = field.getMappedKey(); Object value; + if (rawValue instanceof MongoExpression) { + return createMapEntry(key, getMappedObject(((MongoExpression) rawValue).toDocument(), field.getEntity())); + } + if (isNestedKeyword(rawValue) && !field.isIdField()) { Keyword keyword = new Keyword((Document) rawValue); value = getMappedKeyword(field, keyword); @@ -934,6 +938,11 @@ public MongoPersistentEntity getPropertyEntity() { return null; } + @Nullable + MongoPersistentEntity getEntity() { + return null; + } + /** * Returns whether the field represents an association. * @@ -1086,6 +1095,12 @@ public MongoPersistentEntity getPropertyEntity() { return property == null ? null : mappingContext.getPersistentEntity(property); } + @Nullable + @Override + public MongoPersistentEntity getEntity() { + return entity; + } + /* * (non-Javadoc) * @see org.springframework.data.mongodb.core.convert.QueryMapper.Field#isAssociation() diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/query/Field.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/query/Field.java index 32ab4e4339..711e5d81db 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/query/Field.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/query/Field.java @@ -20,7 +20,7 @@ import java.util.Map.Entry; import org.bson.Document; - +import org.springframework.data.mongodb.MongoExpression; import org.springframework.lang.Nullable; import org.springframework.util.Assert; import org.springframework.util.ObjectUtils; @@ -37,7 +37,7 @@ */ public class Field { - private final Map criteria = new HashMap<>(); + private final Map criteria = new HashMap<>(); private final Map slices = new HashMap<>(); private final Map elemMatchs = new HashMap<>(); private @Nullable String positionKey; @@ -58,6 +58,62 @@ public Field include(String field) { return this; } + /** + * Project a given {@link MongoExpression} to a {@link FieldProjectionExpression#as(String) field} included in the + * result. + * + *

+	 * 
+	 * // { 'name' : { '$toUpper' : '$name' } }
+	 * 
+	 * // native MongoDB expression
+	 * .project(MongoExpression.expressionFromString("'$toUpper' : '$name'")).as("name");
+	 * 
+	 * // Aggregation Framework expression
+	 * .project(StringOperators.valueOf("name").toUpper()).as("name");
+	 * 
+	 * // Aggregation Framework SpEL expression
+	 * .project(AggregationSpELExpression.expressionOf("toUpper(name)")).as("name");
+	 * 
+ * + * @param expression must not be {@literal null}. + * @return new instance of {@link FieldProjectionExpression} - you still need to define the target field name via + * {@link FieldProjectionExpression#as(String) as(String)}. + * @since 3.2 + */ + public FieldProjectionExpression project(MongoExpression expression) { + return field -> Field.this.projectAs(expression, field); + } + + /** + * Project a given {@link MongoExpression} to a {@link FieldProjectionExpression#as(String) field} included in the + * result. + * + *
+	 *
+	 * // { 'name' : { '$toUpper' : '$name' } }
+	 *
+	 * // native MongoDB expression
+	 * .projectAs(MongoExpression.expressionFromString("'$toUpper' : '$name'"), "name");
+	 *
+	 * // Aggregation Framework expression
+	 * .projectAs(StringOperators.valueOf("name").toUpper(), "name");
+	 *
+	 * // Aggregation Framework SpEL expression
+	 * .projectAs(AggregationSpELExpression.expressionOf("toUpper(name)"), "name");
+	 * 
+ * + * @param expression must not be {@literal null}. + * @param field the field name used in the result. + * @return new instance of {@link FieldProjectionExpression}. + * @since 3.2 + */ + public Field projectAs(MongoExpression expression, String field) { + + criteria.put(field, expression); + return this; + } + /** * Include one or more {@code fields} to be returned by the query operation. * @@ -166,8 +222,7 @@ public Field position(String field, int value) { public Document getFieldsObject() { - @SuppressWarnings({ "unchecked", "rawtypes" }) - Document document = new Document((Map) criteria); + Document document = new Document(criteria); for (Entry entry : slices.entrySet()) { document.put(entry.getKey(), new Document("$slice", entry.getValue())); @@ -219,4 +274,21 @@ public int hashCode() { result = 31 * result + positionValue; return result; } + + /** + * Intermediate builder part for projecting a {@link MongoExpression} to a result field. + * + * @since 3.2 + * @author Christoph Strobl + */ + public interface FieldProjectionExpression { + + /** + * Set the name to be used in the result. + * + * @param name must not be {@literal null}. + * @return the calling instance {@link Field}. + */ + Field as(String name); + } } diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/MongoTemplateFieldProjectionTests.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/MongoTemplateFieldProjectionTests.java new file mode 100644 index 0000000000..351f6671d2 --- /dev/null +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/MongoTemplateFieldProjectionTests.java @@ -0,0 +1,188 @@ +/* + * 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 lombok.EqualsAndHashCode; +import lombok.ToString; + +import java.util.function.Consumer; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.MongoExpression; +import org.springframework.data.mongodb.core.aggregation.AggregationSpELExpression; +import org.springframework.data.mongodb.core.aggregation.StringOperators; +import org.springframework.data.mongodb.core.mapping.Embedded; +import org.springframework.data.mongodb.core.mapping.Field; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.data.mongodb.test.util.MongoTemplateExtension; +import org.springframework.data.mongodb.test.util.MongoTestTemplate; +import org.springframework.data.mongodb.test.util.Template; + +/** + * @author Christoph Strobl + */ +@ExtendWith(MongoTemplateExtension.class) +class MongoTemplateFieldProjectionTests { + + private static @Template MongoTestTemplate template; + + private Person luke; + + @BeforeEach + void beforeEach() { + + luke = new Person(); + luke.id = "luke"; + luke.firstname = "luke"; + luke.lastname = "skywalker"; + + template.save(luke); + } + + @AfterEach + void afterEach() { + template.flush(Person.class, Wrapper.class); + } + + @Test // GH-3583 + void usesMongoExpressionAsIs() { + + Person result = findLuke(fields -> { + fields.include("firstname").project(MongoExpression.expressionFromString("'$toUpper' : '$last_name'")) + .as("last_name"); + }); + + assertThat(result).isEqualTo(luke.upperCaseLastnameClone()); + } + + @Test // GH-3583 + void usesMongoExpressionWithPlaceholdersAsIs() { + + Person result = findLuke(fields -> { + fields.include("firstname").project(MongoExpression.expressionFromString("'$toUpper' : '$?0'", "last_name")) + .as("last_name"); + }); + + assertThat(result).isEqualTo(luke.upperCaseLastnameClone()); + } + + @Test // GH-3583 + void mapsAggregationExpressionToDomainType() { + + Person result = findLuke(fields -> { + fields.include("firstname").project(StringOperators.valueOf("lastname").toUpper()).as("last_name"); + }); + + assertThat(result).isEqualTo(luke.upperCaseLastnameClone()); + } + + @Test // GH-3583 + void mapsAggregationSpELExpressionToDomainType() { + + Person result = findLuke(fields -> { + fields.include("firstname").project(AggregationSpELExpression.expressionOf("toUpper(lastname)")).as("last_name"); + }); + + assertThat(result).isEqualTo(luke.upperCaseLastnameClone()); + } + + @Test // GH-3583 + void mapsNestedPathAggregationExpressionToDomainType() { + + Wrapper wrapper = new Wrapper(); + wrapper.id = "wrapper"; + wrapper.person = luke; + + template.save(wrapper); + + Query query = Query.query(Criteria.where("id").is(wrapper.id)); + query.fields().include("person.firstname", "person.id") + .project(StringOperators.valueOf("person.lastname").toUpper()).as("person.last_name"); + + Wrapper result = template.findOne(query, Wrapper.class); + assertThat(result.person).isEqualTo(luke.upperCaseLastnameClone()); + } + + @Test // GH-3583 + void mapsProjectionOnEmbedded() { + + luke.address = new Address(); + luke.address.planet = "tatoine"; + + template.save(luke); + + Person result = findLuke(fields -> { + fields.project(StringOperators.valueOf("address.planet").toUpper()).as("planet"); + }); + + assertThat(result.address.planet).isEqualTo("TATOINE"); + } + + private Person findLuke(Consumer projection) { + + Query query = Query.query(Criteria.where("id").is(luke.id)); + projection.accept(query.fields()); + return template.findOne(query, Person.class); + } + + @EqualsAndHashCode + @ToString + static class Wrapper { + @Id String id; + Person person; + } + + @EqualsAndHashCode + @ToString + static class Person { + + @Id String id; + String firstname; + + @Field("last_name") // + String lastname; + + @Embedded.Nullable Address address; + + Person toUpperCaseLastnameClone(Person source) { + + Person target = new Person(); + target.id = source.id; + target.firstname = source.firstname; + target.lastname = source.lastname.toUpperCase(); + target.address = source.address; + + return target; + } + + Person upperCaseLastnameClone() { + return toUpperCaseLastnameClone(this); + } + } + + @EqualsAndHashCode + @ToString + static class Address { + String planet; + } +} diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/repository/AbstractPersonRepositoryIntegrationTests.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/repository/AbstractPersonRepositoryIntegrationTests.java index f2cb95a0cc..8ca46bdf41 100644 --- a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/repository/AbstractPersonRepositoryIntegrationTests.java +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/repository/AbstractPersonRepositoryIntegrationTests.java @@ -1413,4 +1413,11 @@ void caseInSensitiveInClauseQuotesExpressions() { void caseSensitiveInClauseIgnoresExpressions() { assertThat(repository.findByFirstnameIn(".*")).isEmpty(); } + + @Test // GH-23583 + void annotatedQueryShouldAllowAggregationInProjection() { + + Person target = repository.findWithAggregationInProjection(alicia.getId()); + assertThat(target.getFirstname()).isEqualTo(alicia.getFirstname().toUpperCase()); + } } diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/repository/PersonRepository.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/repository/PersonRepository.java index c39fc39de5..97a6a7c251 100644 --- a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/repository/PersonRepository.java +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/repository/PersonRepository.java @@ -366,6 +366,9 @@ Page findByCustomQueryLastnameAndAddressStreetInList(String lastname, Li @Query(value = "{ 'id' : ?0 }", fields = "{ 'fans': { '$slice': [ ?1, ?2 ] } }") Person findWithSliceInProjection(String id, int skip, int limit); + @Query(value = "{ 'id' : ?0 }", fields = "{ 'firstname': { '$toUpper': '$firstname' } }") + Person findWithAggregationInProjection(String id); + @Query(value = "{ 'shippingAddresses' : { '$elemMatch' : { 'city' : { '$eq' : 'lnz' } } } }", fields = "{ 'shippingAddresses.$': ?0 }") Person findWithArrayPositionInProjection(int position); diff --git a/src/main/asciidoc/reference/mongodb.adoc b/src/main/asciidoc/reference/mongodb.adoc index 1f08d4452e..30e843d80e 100644 --- a/src/main/asciidoc/reference/mongodb.adoc +++ b/src/main/asciidoc/reference/mongodb.adoc @@ -1247,6 +1247,69 @@ The `Query` class has some additional methods that provide options for the query * `Query` *skip* `(int skip)` used to skip the provided number of documents in the results (used for paging) * `Query` *with* `(Sort sort)` used to provide sort definition for the results +[[mongo-template.querying.field-selection]] +==== Selecting fields + +MongoDB supports https://docs.mongodb.com/manual/tutorial/project-fields-from-query-results/[projecting fields] returned by a query. +A projection can in- & exclude fields (the `_id` field is always included unless explicitly excluded) based on their name. + +.Selecting result fields +==== +[source,java] +---- +public class Person { + + @Id String id; + String firstname; + + @Field("last_name") + String lastname; + + Address address; +} + +query.fields().include("lastname"); <1> + +query.fields().exclude("id").include("lastname") <2> + +query.fields().include("address") <3> + +query.fields().include("address.city") <4> + + +---- +<1> Result will contain both `_id` and `last_name` via `{ "last_name" : 1 }`. +<2> Result will only contain the `last_name` via `{ "_id" : 0, "last_name" : 1 }`. +<3> Result will contain the `_id` and entire `address` object via `{ "address" : 1 }`. +<4> Result will contain the `_id` and and `address` object that only contains the `city` field via `{ "address.city" : 1 }`. +==== + +Starting with MongoDB 4.4 it is possible to use the aggregation expressions syntax for field projections as shown below. + +.Computing result fields with expressions +==== +[source,java] +---- +query.fields() + .project(MongoExpression.expressionFromString("'$toUpper' : '$last_name'")) <1> + .as("last_name"); <2> + +query.fields() + .project(StringOperators.valueOf("lastname").toUpper()) <3> + .as("last_name"); + +query.fields() + .project(AggregationSpELExpression.expressionOf("toUpper(lastname)")) <4> + .as("last_name"); +---- +<1> Use a native expression. The used field names must refer to the ones of the document within the database. +<2> Assign the field name that shall hold the expression result in the target document. The resulting field name will never be mapped against the domain model. +<3> Use an `AggregationExpression`. Other than native `MongoExpression`, field names are mapped to the ones used in the domain model. +<4> Use SpEL along with an `AggregationExpression` to invoke expression functions. Field names are mapped to the ones used in the domain model. +==== + +`@Query(fields='...')` allows usage of expression field projections at `Repository` level as described in <>. + [[mongo-template.querying]] === Methods for Querying for Documents From ff9d5a918612147a837d86330ff17098d96a0939 Mon Sep 17 00:00:00 2001 From: Christoph Strobl Date: Wed, 17 Mar 2021 11:30:07 +0100 Subject: [PATCH 3/3] Update method names and wording. - MongoExpresssion#expressionFromString(String) -> MongoExpresssion#create - AggregationExpression#create(MongoExpression) ->A ggregationExpression#from - MongoExpression#as -> removed from public API --- .../data/mongodb/BindableMongoExpression.java | 48 +++++++++++-------- .../data/mongodb/MongoExpression.java | 37 +++++--------- .../data/mongodb/core/QueryOperations.java | 3 +- .../aggregation/AggregationExpression.java | 7 +-- .../MongoTemplateFieldProjectionTests.java | 4 +- src/main/asciidoc/reference/mongodb.adoc | 2 +- 6 files changed, 49 insertions(+), 52 deletions(-) diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/BindableMongoExpression.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/BindableMongoExpression.java index c55d69aef6..4ecc52ce5a 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/BindableMongoExpression.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/BindableMongoExpression.java @@ -25,9 +25,10 @@ import org.springframework.util.StringUtils; /** - * A {@link MongoExpression} using the {@link ParameterBindingDocumentCodec} for parsing the {@literal json} expression. - * The expression will be wrapped within { } if necessary. Placeholders like {@code ?0} are resolved when - * first obtaining the target {@link Document} via {@link #toDocument()}. + * A {@link MongoExpression} using the {@link ParameterBindingDocumentCodec} for parsing a raw ({@literal json}) + * expression. The expression will be wrapped within { ... } if necessary. The actual parsing and parameter + * binding of placeholders like {@code ?0} is delayed upon first call on the the target {@link Document} via + * {@link #toDocument()}. *

* *

@@ -38,16 +39,15 @@
  * { '$toUpper' : '?0' }, "$name"  -> { '$toUpper' : '$name' }
  * 
* - * Some types (like {@link java.util.UUID}) cannot be used directly but require a special {@link org.bson.codecs.Codec}. - * Make sure to provide a {@link CodecRegistry} containing the required {@link org.bson.codecs.Codec codecs} via - * {@link #withCodecRegistry(CodecRegistry)}. + * Some types might require a special {@link org.bson.codecs.Codec}. If so, make sure to provide a {@link CodecRegistry} + * containing the required {@link org.bson.codecs.Codec codec} via {@link #withCodecRegistry(CodecRegistry)}. * * @author Christoph Strobl * @since 3.2 */ public class BindableMongoExpression implements MongoExpression { - private final String json; + private final String expressionString; @Nullable // private final CodecRegistryProvider codecRegistryProvider; @@ -60,24 +60,24 @@ public class BindableMongoExpression implements MongoExpression { /** * Create a new instance of {@link BindableMongoExpression}. * - * @param json must not be {@literal null}. + * @param expression must not be {@literal null}. * @param args can be {@literal null}. */ - public BindableMongoExpression(String json, @Nullable Object[] args) { - this(json, null, args); + public BindableMongoExpression(String expression, @Nullable Object[] args) { + this(expression, null, args); } /** * Create a new instance of {@link BindableMongoExpression}. * - * @param json must not be {@literal null}. + * @param expression must not be {@literal null}. * @param codecRegistryProvider can be {@literal null}. * @param args can be {@literal null}. */ - public BindableMongoExpression(String json, @Nullable CodecRegistryProvider codecRegistryProvider, + public BindableMongoExpression(String expression, @Nullable CodecRegistryProvider codecRegistryProvider, @Nullable Object[] args) { - this.json = wrapJsonIfNecessary(json); + this.expressionString = expression; this.codecRegistryProvider = codecRegistryProvider; this.args = args; this.target = Lazy.of(this::parse); @@ -90,7 +90,7 @@ public BindableMongoExpression(String json, @Nullable CodecRegistryProvider code * @return new instance of {@link BindableMongoExpression}. */ public BindableMongoExpression withCodecRegistry(CodecRegistry codecRegistry) { - return new BindableMongoExpression(json, () -> codecRegistry, args); + return new BindableMongoExpression(expressionString, () -> codecRegistry, args); } /** @@ -100,12 +100,11 @@ public BindableMongoExpression withCodecRegistry(CodecRegistry codecRegistry) { * @return new instance of {@link BindableMongoExpression}. */ public BindableMongoExpression bind(Object... args) { - return new BindableMongoExpression(json, codecRegistryProvider, args); + return new BindableMongoExpression(expressionString, codecRegistryProvider, args); } /* * (non-Javadoc) - * * @see org.springframework.data.mongodb.MongoExpression#toDocument() */ @Override @@ -113,6 +112,15 @@ public Document toDocument() { return target.get(); } + /* + * (non-Javadoc) + * @see java.lang.Object#toString() + */ + @Override + public String toString() { + return "BindableMongoExpression{" + "expressionString='" + expressionString + '\'' + ", args=" + args + '}'; + } + private String wrapJsonIfNecessary(String json) { if (StringUtils.hasText(json) && (json.startsWith("{") && json.endsWith("}"))) { @@ -124,18 +132,20 @@ private String wrapJsonIfNecessary(String json) { private Document parse() { + String expression = wrapJsonIfNecessary(expressionString); + if (ObjectUtils.isEmpty(args)) { if (codecRegistryProvider == null) { - return Document.parse(json); + return Document.parse(expression); } - return Document.parse(json, codecRegistryProvider.getCodecFor(Document.class) + return Document.parse(expression, codecRegistryProvider.getCodecFor(Document.class) .orElseGet(() -> new DocumentCodec(codecRegistryProvider.getCodecRegistry()))); } ParameterBindingDocumentCodec codec = codecRegistryProvider == null ? new ParameterBindingDocumentCodec() : new ParameterBindingDocumentCodec(codecRegistryProvider.getCodecRegistry()); - return codec.decode(json, args); + return codec.decode(expression, args); } } diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/MongoExpression.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/MongoExpression.java index 0bc4f1e0b6..c69fd97f40 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/MongoExpression.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/MongoExpression.java @@ -15,8 +15,6 @@ */ package org.springframework.data.mongodb; -import java.util.function.Function; - /** * Wrapper object for MongoDB expressions like {@code $toUpper : $name} that manifest as {@link org.bson.Document} when * passed on to the driver. @@ -36,7 +34,7 @@ * @see org.springframework.data.mongodb.core.aggregation.DateOperators * @see org.springframework.data.mongodb.core.aggregation.ObjectOperators * @see org.springframework.data.mongodb.core.aggregation.SetOperators - * @see org.springframework.data.mongodb.core.aggregation.StringOperators @ + * @see org.springframework.data.mongodb.core.aggregation.StringOperators */ @FunctionalInterface public interface MongoExpression { @@ -49,38 +47,27 @@ public interface MongoExpression { org.bson.Document toDocument(); /** - * Convert this instance to another expression applying the given conversion {@link Function}. - * - * @param function must not be {@literal null}. - * @param - * @return never {@literal null}. - */ - default T as(Function function) { - return function.apply(this); - } - - /** - * Create a new {@link MongoExpression} from plain String (eg. {@code $toUpper : $name}).
- * The given source value will be wrapped with { } to match an actual MongoDB {@link org.bson.Document} + * Create a new {@link MongoExpression} from plain {@link String} (eg. {@code $toUpper : $name}).
+ * The given expression will be wrapped with { ... } to match an actual MongoDB {@link org.bson.Document} * if necessary. * - * @param json must not be {@literal null}. + * @param expression must not be {@literal null}. * @return new instance of {@link MongoExpression}. */ - static MongoExpression expressionFromString(String json) { - return new BindableMongoExpression(json, null); + static MongoExpression create(String expression) { + return new BindableMongoExpression(expression, null); } /** - * Create a new {@link MongoExpression} from plain String containing placeholders (eg. {@code $toUpper : ?0}) that - * will be resolved on {@link #toDocument()}.
- * The given source value will be wrapped with { } to match an actual MongoDB {@link org.bson.Document} + * Create a new {@link MongoExpression} from plain {@link String} containing placeholders (eg. {@code $toUpper : ?0}) + * that will be resolved on first call of {@link #toDocument()}.
+ * The given expression will be wrapped with { ... } to match an actual MongoDB {@link org.bson.Document} * if necessary. * - * @param json must not be {@literal null}. + * @param expression must not be {@literal null}. * @return new instance of {@link MongoExpression}. */ - static MongoExpression expressionFromString(String json, Object... args) { - return new BindableMongoExpression(json, args); + static MongoExpression create(String expression, Object... args) { + return new BindableMongoExpression(expression, args); } } 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 0eab203177..1ec8fc9366 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 @@ -300,8 +300,7 @@ Document getMappedFields(@Nullable MongoPersistentEntity entity, Class tar AggregationOperationContext ctx = entity == null ? Aggregation.DEFAULT_CONTEXT : new RelaxedTypeBasedAggregationOperationContext(entity.getType(), mappingContext, queryMapper); - fields.put(entry.getKey(), - ((MongoExpression) entry.getValue()).as(AggregationExpression::create).toDocument(ctx)); + fields.put(entry.getKey(), AggregationExpression.from((MongoExpression) entry.getValue()).toDocument(ctx)); } else { fields.put(entry.getKey(), entry.getValue()); } diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/AggregationExpression.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/AggregationExpression.java index f49e0792cb..879751b294 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/AggregationExpression.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/aggregation/AggregationExpression.java @@ -41,14 +41,15 @@ default Document toDocument() { } /** - * Create an {@link AggregationExpression} out of a given {@link MongoExpression}.
- * If the given expression is already an {@link AggregationExpression} return the very same instance. + * Create an {@link AggregationExpression} out of a given {@link MongoExpression} to ensure the resulting + * {@link MongoExpression#toDocument() Document} is mapped against the {@link AggregationOperationContext}.
+ * If the given expression is already an {@link AggregationExpression} the very same instance is returned. * * @param expression must not be {@literal null}. * @return never {@literal null}. * @since 3.2 */ - static AggregationExpression create(MongoExpression expression) { + static AggregationExpression from(MongoExpression expression) { if (expression instanceof AggregationExpression) { return AggregationExpression.class.cast(expression); diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/MongoTemplateFieldProjectionTests.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/MongoTemplateFieldProjectionTests.java index 351f6671d2..d97e0afdcc 100644 --- a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/MongoTemplateFieldProjectionTests.java +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/MongoTemplateFieldProjectionTests.java @@ -68,7 +68,7 @@ void afterEach() { void usesMongoExpressionAsIs() { Person result = findLuke(fields -> { - fields.include("firstname").project(MongoExpression.expressionFromString("'$toUpper' : '$last_name'")) + fields.include("firstname").project(MongoExpression.create("'$toUpper' : '$last_name'")) .as("last_name"); }); @@ -79,7 +79,7 @@ void usesMongoExpressionAsIs() { void usesMongoExpressionWithPlaceholdersAsIs() { Person result = findLuke(fields -> { - fields.include("firstname").project(MongoExpression.expressionFromString("'$toUpper' : '$?0'", "last_name")) + fields.include("firstname").project(MongoExpression.create("'$toUpper' : '$?0'", "last_name")) .as("last_name"); }); diff --git a/src/main/asciidoc/reference/mongodb.adoc b/src/main/asciidoc/reference/mongodb.adoc index 30e843d80e..604dd88b85 100644 --- a/src/main/asciidoc/reference/mongodb.adoc +++ b/src/main/asciidoc/reference/mongodb.adoc @@ -1291,7 +1291,7 @@ Starting with MongoDB 4.4 it is possible to use the aggregation expressions synt [source,java] ---- query.fields() - .project(MongoExpression.expressionFromString("'$toUpper' : '$last_name'")) <1> + .project(MongoExpression.create("'$toUpper' : '$last_name'")) <1> .as("last_name"); <2> query.fields()