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