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 000000000..4ecc52ce5
--- /dev/null
+++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/BindableMongoExpression.java
@@ -0,0 +1,151 @@
+/*
+ * 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 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()}.
+ *
+ *
+ *
+ * $toUpper : $name -> { '$toUpper' : '$name' }
+ *
+ * { '$toUpper' : '$name' } -> { '$toUpper' : '$name' }
+ *
+ * { '$toUpper' : '?0' }, "$name" -> { '$toUpper' : '$name' }
+ *
+ *
+ * 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 expressionString;
+
+ @Nullable //
+ private final CodecRegistryProvider codecRegistryProvider;
+
+ @Nullable //
+ private final Object[] args;
+
+ private final Lazy target;
+
+ /**
+ * Create a new instance of {@link BindableMongoExpression}.
+ *
+ * @param expression must not be {@literal null}.
+ * @param args can be {@literal null}.
+ */
+ public BindableMongoExpression(String expression, @Nullable Object[] args) {
+ this(expression, null, args);
+ }
+
+ /**
+ * Create a new instance of {@link BindableMongoExpression}.
+ *
+ * @param expression must not be {@literal null}.
+ * @param codecRegistryProvider can be {@literal null}.
+ * @param args can be {@literal null}.
+ */
+ public BindableMongoExpression(String expression, @Nullable CodecRegistryProvider codecRegistryProvider,
+ @Nullable Object[] args) {
+
+ this.expressionString = expression;
+ 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(expressionString, () -> 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(expressionString, codecRegistryProvider, args);
+ }
+
+ /*
+ * (non-Javadoc)
+ * @see org.springframework.data.mongodb.MongoExpression#toDocument()
+ */
+ @Override
+ 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("}"))) {
+ return json;
+ }
+
+ return "{" + json + "}";
+ }
+
+ private Document parse() {
+
+ String expression = wrapJsonIfNecessary(expressionString);
+
+ if (ObjectUtils.isEmpty(args)) {
+
+ if (codecRegistryProvider == null) {
+ return Document.parse(expression);
+ }
+
+ 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(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
new file mode 100644
index 000000000..c69fd97f4
--- /dev/null
+++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/MongoExpression.java
@@ -0,0 +1,73 @@
+/*
+ * 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;
+
+/**
+ * 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();
+
+ /**
+ * 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 expression must not be {@literal null}.
+ * @return new instance of {@link MongoExpression}.
+ */
+ static MongoExpression create(String expression) {
+ return new BindableMongoExpression(expression, null);
+ }
+
+ /**
+ * 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 expression must not be {@literal null}.
+ * @return new instance of {@link MongoExpression}.
+ */
+ 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 327d28219..1ec8fc936 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 @@ package org.springframework.data.mongodb.core;
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.PropertyPath;
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,21 @@ class QueryOperations {
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(), AggregationExpression.from((MongoExpression) entry.getValue()).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 2a0148cc5..879751b29 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,37 @@ import org.bson.Document;
* @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} 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 from(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 3d125f6bf..00371f945 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.BsonValue;
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.PropertyPath;
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 @@ public class QueryMapper {
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 class QueryMapper {
return null;
}
+ @Nullable
+ MongoPersistentEntity> getEntity() {
+ return null;
+ }
+
/**
* Returns whether the field represents an association.
*
@@ -1086,6 +1095,12 @@ public class QueryMapper {
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 32ab4e433..711e5d81d 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;
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 @@ import org.springframework.util.ObjectUtils;
*/
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 class 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 class Field {
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 class Field {
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 000000000..d97e0afdc
--- /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.create("'$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.create("'$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 f2cb95a0c..8ca46bdf4 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 @@ public abstract class AbstractPersonRepositoryIntegrationTests {
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 c39fc39de..97a6a7c25 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 @@ public interface PersonRepository extends MongoRepository, Query
@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 1f08d4452..604dd88b8 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.create("'$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