diff --git a/pom.xml b/pom.xml index 9f4b6bc897..ba99633bfa 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ org.springframework.data spring-data-mongodb-parent - 4.5.0-SNAPSHOT + 4.5.0-QRC-SNAPSHOT pom Spring Data MongoDB diff --git a/spring-data-mongodb-distribution/pom.xml b/spring-data-mongodb-distribution/pom.xml index 58c63dfc97..ec441c4e1f 100644 --- a/spring-data-mongodb-distribution/pom.xml +++ b/spring-data-mongodb-distribution/pom.xml @@ -15,7 +15,7 @@ org.springframework.data spring-data-mongodb-parent - 4.5.0-SNAPSHOT + 4.5.0-QRC-SNAPSHOT ../pom.xml diff --git a/spring-data-mongodb/pom.xml b/spring-data-mongodb/pom.xml index b842a2def3..1cd1bc9335 100644 --- a/spring-data-mongodb/pom.xml +++ b/spring-data-mongodb/pom.xml @@ -13,7 +13,7 @@ org.springframework.data spring-data-mongodb-parent - 4.5.0-SNAPSHOT + 4.5.0-QRC-SNAPSHOT ../pom.xml diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/EntityResultConverter.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/EntityResultConverter.java new file mode 100644 index 0000000000..c04ae9d603 --- /dev/null +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/EntityResultConverter.java @@ -0,0 +1,33 @@ +/* + * Copyright 2025 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 org.bson.Document; + +enum EntityResultConverter implements QueryResultConverter { + + INSTANCE; + + @Override + public Object mapDocument(Document document, ConversionResultSupplier reader) { + return reader.get(); + } + + @Override + public QueryResultConverter andThen(QueryResultConverter after) { + return (QueryResultConverter) after; + } +} diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/ExecutableAggregationOperation.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/ExecutableAggregationOperation.java index 67ed188655..e4becc491a 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/ExecutableAggregationOperation.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/ExecutableAggregationOperation.java @@ -19,6 +19,7 @@ import org.springframework.data.mongodb.core.aggregation.Aggregation; import org.springframework.data.mongodb.core.aggregation.AggregationResults; +import org.springframework.lang.Contract; /** * {@link ExecutableAggregationOperation} allows creation and execution of MongoDB aggregation operations in a fluent @@ -45,7 +46,7 @@ public interface ExecutableAggregationOperation { /** * Start creating an aggregation operation that returns results mapped to the given domain type.
* Use {@link org.springframework.data.mongodb.core.aggregation.TypedAggregation} to specify a potentially different - * input type for he aggregation. + * input type for the aggregation. * * @param domainType must not be {@literal null}. * @return new instance of {@link ExecutableAggregation}. @@ -76,10 +77,23 @@ interface AggregationWithCollection { * Trigger execution by calling one of the terminating methods. * * @author Christoph Strobl + * @author Mark Paluch * @since 2.0 */ interface TerminatingAggregation { + /** + * Map the query result to a different type using {@link QueryResultConverter}. + * + * @param {@link Class type} of the result. + * @param converter the converter, must not be {@literal null}. + * @return new instance of {@link TerminatingAggregation}. + * @throws IllegalArgumentException if {@link QueryResultConverter converter} is {@literal null}. + * @since x.y + */ + @Contract("_ -> new") + TerminatingAggregation map(QueryResultConverter converter); + /** * Apply pipeline operations as specified and get all matching elements. * diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/ExecutableAggregationOperationSupport.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/ExecutableAggregationOperationSupport.java index ca5aa7a513..d74e955f6f 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/ExecutableAggregationOperationSupport.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/ExecutableAggregationOperationSupport.java @@ -43,25 +43,28 @@ public ExecutableAggregation aggregateAndReturn(Class domainType) { Assert.notNull(domainType, "DomainType must not be null"); - return new ExecutableAggregationSupport<>(template, domainType, null, null); + return new ExecutableAggregationSupport<>(template, domainType, QueryResultConverter.entity(), null, null); } /** * @author Christoph Strobl * @since 2.0 */ - static class ExecutableAggregationSupport + static class ExecutableAggregationSupport implements AggregationWithAggregation, ExecutableAggregation, TerminatingAggregation { private final MongoTemplate template; - private final Class domainType; + private final Class domainType; + private final QueryResultConverter resultConverter; private final Aggregation aggregation; private final String collection; - public ExecutableAggregationSupport(MongoTemplate template, Class domainType, Aggregation aggregation, + public ExecutableAggregationSupport(MongoTemplate template, Class domainType, + QueryResultConverter resultConverter, Aggregation aggregation, String collection) { this.template = template; this.domainType = domainType; + this.resultConverter = resultConverter; this.aggregation = aggregation; this.collection = collection; } @@ -71,7 +74,7 @@ public AggregationWithAggregation inCollection(String collection) { Assert.hasText(collection, "Collection must not be null nor empty"); - return new ExecutableAggregationSupport<>(template, domainType, aggregation, collection); + return new ExecutableAggregationSupport<>(template, domainType, resultConverter, aggregation, collection); } @Override @@ -79,17 +82,26 @@ public TerminatingAggregation by(Aggregation aggregation) { Assert.notNull(aggregation, "Aggregation must not be null"); - return new ExecutableAggregationSupport<>(template, domainType, aggregation, collection); + return new ExecutableAggregationSupport<>(template, domainType, resultConverter, aggregation, collection); + } + + @Override + public TerminatingAggregation map(QueryResultConverter converter) { + + Assert.notNull(converter, "QueryResultConverter must not be null"); + + return new ExecutableAggregationSupport<>(template, domainType, this.resultConverter.andThen(converter), + aggregation, collection); } @Override public AggregationResults all() { - return template.aggregate(aggregation, getCollectionName(aggregation), domainType); + return template.doAggregate(aggregation, getCollectionName(aggregation), domainType, resultConverter); } @Override public Stream stream() { - return template.aggregateStream(aggregation, getCollectionName(aggregation), domainType); + return template.doAggregateStream(aggregation, getCollectionName(aggregation), domainType, resultConverter, null); } private String getCollectionName(Aggregation aggregation) { diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/ExecutableFindOperation.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/ExecutableFindOperation.java index 3358ff2b17..b4d6a4dd80 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/ExecutableFindOperation.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/ExecutableFindOperation.java @@ -27,6 +27,7 @@ import org.springframework.data.mongodb.core.query.CriteriaDefinition; import org.springframework.data.mongodb.core.query.NearQuery; import org.springframework.data.mongodb.core.query.Query; +import org.springframework.lang.Contract; import org.springframework.lang.Nullable; import com.mongodb.client.MongoCollection; @@ -71,9 +72,33 @@ public interface ExecutableFindOperation { * Trigger find execution by calling one of the terminating methods. * * @author Christoph Strobl + * @author Mark Paluch * @since 2.0 */ - interface TerminatingFind { + interface TerminatingFind extends TerminatingResults, TerminatingProjection { + + } + + /** + * Trigger find execution by calling one of the terminating methods. + * + * @author Christoph Strobl + * @author Mark Paluch + * @since x.y + */ + interface TerminatingResults { + + /** + * Map the query result to a different type using {@link QueryResultConverter}. + * + * @param {@link Class type} of the result. + * @param converter the converter, must not be {@literal null}. + * @return new instance of {@link TerminatingResults}. + * @throws IllegalArgumentException if {@link QueryResultConverter converter} is {@literal null}. + * @since x.y + */ + @Contract("_ -> new") + TerminatingResults map(QueryResultConverter converter); /** * Get exactly zero or one result. @@ -142,6 +167,16 @@ default Optional first() { */ Window scroll(ScrollPosition scrollPosition); + } + + /** + * Trigger find execution by calling one of the terminating methods. + * + * @author Christoph Strobl + * @since x.y + */ + interface TerminatingProjection { + /** * Get the number of matching elements.
* This method uses an @@ -160,16 +195,30 @@ default Optional first() { * @return {@literal true} if at least one matching element exists. */ boolean exists(); + } /** - * Trigger geonear execution by calling one of the terminating methods. + * Trigger {@code geoNear} execution by calling one of the terminating methods. * * @author Christoph Strobl + * @author Mark Paluch * @since 2.0 */ interface TerminatingFindNear { + /** + * Map the query result to a different type using {@link QueryResultConverter}. + * + * @param {@link Class type} of the result. + * @param converter the converter, must not be {@literal null}. + * @return new instance of {@link TerminatingFindNear}. + * @throws IllegalArgumentException if {@link QueryResultConverter converter} is {@literal null}. + * @since x.y + */ + @Contract("_ -> new") + TerminatingFindNear map(QueryResultConverter converter); + /** * Find all matching elements and return them as {@link org.springframework.data.geo.GeoResult}. * diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/ExecutableFindOperationSupport.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/ExecutableFindOperationSupport.java index 4e6c3547c5..6cf5d5924f 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/ExecutableFindOperationSupport.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/ExecutableFindOperationSupport.java @@ -24,6 +24,7 @@ import org.springframework.dao.IncorrectResultSizeDataAccessException; import org.springframework.data.domain.ScrollPosition; import org.springframework.data.domain.Window; +import org.springframework.data.geo.GeoResults; import org.springframework.data.mongodb.core.query.NearQuery; import org.springframework.data.mongodb.core.query.Query; import org.springframework.data.mongodb.core.query.SerializationUtils; @@ -57,7 +58,8 @@ public ExecutableFind query(Class domainType) { Assert.notNull(domainType, "DomainType must not be null"); - return new ExecutableFindSupport<>(template, domainType, domainType, null, ALL_QUERY); + return new ExecutableFindSupport<>(template, domainType, domainType, QueryResultConverter.entity(), null, + ALL_QUERY); } /** @@ -65,19 +67,22 @@ public ExecutableFind query(Class domainType) { * @author Christoph Strobl * @since 2.0 */ - static class ExecutableFindSupport + static class ExecutableFindSupport implements ExecutableFind, FindWithCollection, FindWithProjection, FindWithQuery { private final MongoTemplate template; private final Class domainType; - private final Class returnType; + private final Class returnType; + private final QueryResultConverter resultConverter; private final @Nullable String collection; private final Query query; - ExecutableFindSupport(MongoTemplate template, Class domainType, Class returnType, @Nullable String collection, + ExecutableFindSupport(MongoTemplate template, Class domainType, Class returnType, + QueryResultConverter resultConverter, @Nullable String collection, Query query) { this.template = template; this.domainType = domainType; + this.resultConverter = resultConverter; this.returnType = returnType; this.collection = collection; this.query = query; @@ -88,7 +93,7 @@ public FindWithProjection inCollection(String collection) { Assert.hasText(collection, "Collection name must not be null nor empty"); - return new ExecutableFindSupport<>(template, domainType, returnType, collection, query); + return new ExecutableFindSupport<>(template, domainType, returnType, resultConverter, collection, query); } @Override @@ -96,7 +101,8 @@ public FindWithQuery as(Class returnType) { Assert.notNull(returnType, "ReturnType must not be null"); - return new ExecutableFindSupport<>(template, domainType, returnType, collection, query); + return new ExecutableFindSupport<>(template, domainType, returnType, QueryResultConverter.entity(), collection, + query); } @Override @@ -104,7 +110,16 @@ public TerminatingFind matching(Query query) { Assert.notNull(query, "Query must not be null"); - return new ExecutableFindSupport<>(template, domainType, returnType, collection, query); + return new ExecutableFindSupport<>(template, domainType, returnType, resultConverter, collection, query); + } + + @Override + public TerminatingResults map(QueryResultConverter converter) { + + Assert.notNull(converter, "QueryResultConverter must not be null"); + + return new ExecutableFindSupport<>(template, domainType, returnType, this.resultConverter.andThen(converter), + collection, query); } @Override @@ -143,12 +158,13 @@ public Stream stream() { @Override public Window scroll(ScrollPosition scrollPosition) { - return template.doScroll(query.with(scrollPosition), domainType, returnType, getCollectionName()); + return template.doScroll(query.with(scrollPosition), domainType, returnType, resultConverter, + getCollectionName()); } @Override public TerminatingFindNear near(NearQuery nearQuery) { - return () -> template.geoNear(nearQuery, domainType, getCollectionName(), returnType); + return new TerminatingFindNearSupport<>(nearQuery, this.resultConverter); } @Override @@ -176,17 +192,17 @@ private List doFind(@Nullable CursorPreparer preparer) { Document fieldsObject = query.getFieldsObject(); return template.doFind(template.createDelegate(query), getCollectionName(), queryObject, fieldsObject, domainType, - returnType, getCursorPreparer(query, preparer)); + returnType, resultConverter, getCursorPreparer(query, preparer)); } private List doFindDistinct(String field) { return template.findDistinct(query, field, getCollectionName(), domainType, - returnType == domainType ? (Class) Object.class : returnType); + returnType == domainType ? (Class) Object.class : returnType); } private Stream doStream() { - return template.doStream(query, domainType, getCollectionName(), returnType); + return template.doStream(query, domainType, getCollectionName(), returnType, resultConverter); } private CursorPreparer getCursorPreparer(Query query, @Nullable CursorPreparer preparer) { @@ -200,6 +216,31 @@ private String getCollectionName() { private String asString() { return SerializationUtils.serializeToJsonSafely(query); } + + class TerminatingFindNearSupport implements TerminatingFindNear { + + private final NearQuery nearQuery; + private final QueryResultConverter resultConverter; + + public TerminatingFindNearSupport(NearQuery nearQuery, + QueryResultConverter resultConverter) { + this.nearQuery = nearQuery; + this.resultConverter = resultConverter; + } + + @Override + public TerminatingFindNear map(QueryResultConverter converter) { + + Assert.notNull(converter, "QueryResultConverter must not be null"); + + return new TerminatingFindNearSupport<>(nearQuery, this.resultConverter.andThen(converter)); + } + + @Override + public GeoResults all() { + return template.doGeoNear(nearQuery, domainType, getCollectionName(), returnType, resultConverter); + } + } } /** @@ -245,19 +286,19 @@ public Document getSortObject() { * @author Christoph Strobl * @since 2.1 */ - static class DistinctOperationSupport implements TerminatingDistinct { + static class DistinctOperationSupport implements TerminatingDistinct { private final String field; - private final ExecutableFindSupport delegate; + private final ExecutableFindSupport delegate; - public DistinctOperationSupport(ExecutableFindSupport delegate, String field) { + public DistinctOperationSupport(ExecutableFindSupport delegate, String field) { this.delegate = delegate; this.field = field; } @Override - @SuppressWarnings("unchecked") + @SuppressWarnings({ "unchecked", "rawtypes" }) public TerminatingDistinct as(Class resultType) { Assert.notNull(resultType, "ResultType must not be null"); @@ -270,12 +311,13 @@ public TerminatingDistinct matching(Query query) { Assert.notNull(query, "Query must not be null"); - return new DistinctOperationSupport<>((ExecutableFindSupport) delegate.matching(query), field); + return new DistinctOperationSupport<>((ExecutableFindSupport) delegate.matching(query), field); } @Override public List all() { return delegate.doFindDistinct(field); } + } } diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/MongoTemplate.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/MongoTemplate.java index fd547c61a0..e46573b476 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/MongoTemplate.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/MongoTemplate.java @@ -110,6 +110,7 @@ import org.springframework.data.mongodb.util.MongoCompatibilityAdapter; import org.springframework.data.projection.EntityProjection; import org.springframework.data.util.CloseableIterator; +import org.springframework.data.util.Lazy; import org.springframework.data.util.Optionals; import org.springframework.lang.Nullable; import org.springframework.util.Assert; @@ -479,15 +480,20 @@ public Stream stream(Query query, Class entityType, String collectionN return doStream(query, entityType, collectionName, entityType); } - @SuppressWarnings("ConstantConditions") protected Stream doStream(Query query, Class entityType, String collectionName, Class returnType) { + return doStream(query, entityType, collectionName, returnType, QueryResultConverter.entity()); + } + + @SuppressWarnings("ConstantConditions") + Stream doStream(Query query, Class entityType, String collectionName, Class returnType, + QueryResultConverter resultConverter) { Assert.notNull(query, "Query must not be null"); Assert.notNull(entityType, "Entity type must not be null"); Assert.hasText(collectionName, "Collection name must not be null or empty"); Assert.notNull(returnType, "ReturnType must not be null"); - return execute(collectionName, (CollectionCallback>) collection -> { + return execute(collectionName, (CollectionCallback>) collection -> { MongoPersistentEntity persistentEntity = mappingContext.getPersistentEntity(entityType); @@ -501,8 +507,10 @@ protected Stream doStream(Query query, Class entityType, String collec FindIterable cursor = new QueryCursorPreparer(query, entityType).initiateFind(collection, col -> readPreference.prepare(col).find(mappedQuery, Document.class).projection(mappedFields)); + DocumentCallback resultReader = getResultReader(projection, collectionName, resultConverter); + return new CloseableIterableCursorAdapter<>(cursor, exceptionTranslator, - new ProjectingReadCallback<>(mongoConverter, projection, collectionName)).stream(); + resultReader).stream(); }); } @@ -898,10 +906,11 @@ public Window scroll(Query query, Class entityType) { @Override public Window scroll(Query query, Class entityType, String collectionName) { - return doScroll(query, entityType, entityType, collectionName); + return doScroll(query, entityType, entityType, QueryResultConverter.entity(), collectionName); } - Window doScroll(Query query, Class sourceClass, Class targetClass, String collectionName) { + Window doScroll(Query query, Class sourceClass, Class targetClass, + QueryResultConverter resultConverter, String collectionName) { Assert.notNull(query, "Query must not be null"); Assert.notNull(collectionName, "CollectionName must not be null"); @@ -909,7 +918,7 @@ Window doScroll(Query query, Class sourceClass, Class targetClass, Assert.notNull(targetClass, "Target type must not be null"); EntityProjection projection = operations.introspectProjection(targetClass, sourceClass); - ProjectingReadCallback callback = new ProjectingReadCallback<>(mongoConverter, projection, collectionName); + DocumentCallback callback = getResultReader(projection, collectionName, resultConverter); int limit = query.isLimited() ? query.getLimit() + 1 : Integer.MAX_VALUE; if (query.hasKeyset()) { @@ -917,14 +926,14 @@ Window doScroll(Query query, Class sourceClass, Class targetClass, KeysetScrollQuery keysetPaginationQuery = ScrollUtils.createKeysetPaginationQuery(query, operations.getIdPropertyName(sourceClass)); - List result = doFind(collectionName, createDelegate(query), keysetPaginationQuery.query(), + List result = doFind(collectionName, createDelegate(query), keysetPaginationQuery.query(), keysetPaginationQuery.fields(), sourceClass, new QueryCursorPreparer(query, keysetPaginationQuery.sort(), limit, 0, sourceClass), callback); return ScrollUtils.createWindow(query, result, sourceClass, operations); } - List result = doFind(collectionName, createDelegate(query), query.getQueryObject(), query.getFieldsObject(), + List result = doFind(collectionName, createDelegate(query), query.getQueryObject(), query.getFieldsObject(), sourceClass, new QueryCursorPreparer(query, query.getSortObject(), limit, query.getSkip(), sourceClass), callback); @@ -1016,6 +1025,11 @@ public GeoResults geoNear(NearQuery near, Class domainType, String col } public GeoResults geoNear(NearQuery near, Class domainType, String collectionName, Class returnType) { + return doGeoNear(near, domainType, collectionName, returnType, QueryResultConverter.entity()); + } + + GeoResults doGeoNear(NearQuery near, Class domainType, String collectionName, Class returnType, + QueryResultConverter resultConverter) { if (near == null) { throw new InvalidDataAccessApiUsageException("NearQuery must not be null"); @@ -1047,15 +1061,15 @@ public GeoResults geoNear(NearQuery near, Class domainType, String col AggregationResults results = aggregate($geoNear, collection, Document.class); EntityProjection projection = operations.introspectProjection(returnType, domainType); - DocumentCallback> callback = new GeoNearResultDocumentCallback<>(distanceField, - new ProjectingReadCallback<>(mongoConverter, projection, collection), near.getMetric()); + DocumentCallback> callback = new GeoNearResultDocumentCallback<>(distanceField, + getResultReader(projection, collectionName, resultConverter), near.getMetric()); - List> result = new ArrayList<>(results.getMappedResults().size()); + List> result = new ArrayList<>(results.getMappedResults().size()); BigDecimal aggregate = BigDecimal.ZERO; for (Document element : results) { - GeoResult geoResult = callback.doWith(element); + GeoResult geoResult = callback.doWith(element); aggregate = aggregate.add(BigDecimal.valueOf(geoResult.getDistance().getValue())); result.add(geoResult); } @@ -2022,7 +2036,7 @@ public AggregationResults aggregate(TypedAggregation aggregation, Clas @Override public AggregationResults aggregate(TypedAggregation aggregation, String inputCollectionName, Class outputType) { - return aggregate(aggregation, inputCollectionName, outputType, null); + return aggregate(aggregation, inputCollectionName, outputType, (AggregationOperationContext) null); } @Override @@ -2035,7 +2049,7 @@ public AggregationResults aggregate(Aggregation aggregation, Class inp @Override public AggregationResults aggregate(Aggregation aggregation, String collectionName, Class outputType) { - return aggregate(aggregation, collectionName, outputType, null); + return doAggregate(aggregation, collectionName, outputType, QueryResultConverter.entity()); } @Override @@ -2165,11 +2179,25 @@ private AggregationResults doAggregate(Aggregation aggregation, String co return doAggregate(aggregation, collectionName, outputType, context.getAggregationOperationContext()); } + AggregationResults doAggregate(Aggregation aggregation, String collectionName, Class outputType, + QueryResultConverter resultConverter) { + + return doAggregate(aggregation, collectionName, outputType, resultConverter, queryOperations + .createAggregation(aggregation, (AggregationOperationContext) null).getAggregationOperationContext()); + } + @SuppressWarnings("ConstantConditions") protected AggregationResults doAggregate(Aggregation aggregation, String collectionName, Class outputType, AggregationOperationContext context) { + return doAggregate(aggregation, collectionName, outputType, QueryResultConverter.entity(), context); + } - ReadDocumentCallback callback = new ReadDocumentCallback<>(mongoConverter, outputType, collectionName); + @SuppressWarnings("ConstantConditions") + AggregationResults doAggregate(Aggregation aggregation, String collectionName, Class outputType, + QueryResultConverter resultConverter, AggregationOperationContext context) { + + DocumentCallback callback = new QueryResultConverterCallback<>(resultConverter, + new ReadDocumentCallback<>(mongoConverter, outputType, collectionName)); AggregationOptions options = aggregation.getOptions(); AggregationUtil aggregationUtil = new AggregationUtil(queryMapper, mappingContext); @@ -2248,9 +2276,15 @@ protected AggregationResults doAggregate(Aggregation aggregation, String }); } - @SuppressWarnings("ConstantConditions") protected Stream aggregateStream(Aggregation aggregation, String collectionName, Class outputType, @Nullable AggregationOperationContext context) { + return doAggregateStream(aggregation, collectionName, outputType, QueryResultConverter.entity(), context); + } + + @SuppressWarnings("ConstantConditions") + protected Stream doAggregateStream(Aggregation aggregation, String collectionName, Class outputType, + QueryResultConverter resultConverter, + @Nullable AggregationOperationContext context) { Assert.notNull(aggregation, "Aggregation pipeline must not be null"); Assert.hasText(collectionName, "Collection name must not be null or empty"); @@ -2267,7 +2301,8 @@ protected Stream aggregateStream(Aggregation aggregation, String collecti String.format("Streaming aggregation: %s in collection %s", serializeToJsonSafely(pipeline), collectionName)); } - ReadDocumentCallback readCallback = new ReadDocumentCallback<>(mongoConverter, outputType, collectionName); + DocumentCallback readCallback = new QueryResultConverterCallback<>(resultConverter, + new ReadDocumentCallback<>(mongoConverter, outputType, collectionName)); return execute(collectionName, (CollectionCallback>) collection -> { @@ -2629,11 +2664,12 @@ protected List doFind(String collectionName, * * @since 2.0 */ - List doFind(CollectionPreparer> collectionPreparer, String collectionName, - Document query, Document fields, Class sourceClass, Class targetClass, CursorPreparer preparer) { + List doFind(CollectionPreparer> collectionPreparer, String collectionName, + Document query, Document fields, Class sourceClass, Class targetClass, + QueryResultConverter resultConverter, CursorPreparer preparer) { MongoPersistentEntity entity = mappingContext.getPersistentEntity(sourceClass); - EntityProjection projection = operations.introspectProjection(targetClass, sourceClass); + EntityProjection projection = operations.introspectProjection(targetClass, sourceClass); QueryContext queryContext = queryOperations.createQueryContext(new BasicQuery(query, fields)); Document mappedFields = queryContext.getMappedFields(entity, projection); @@ -2649,8 +2685,9 @@ List doFind(CollectionPreparer> collectionPr collectionName)); } + DocumentCallback callback = getResultReader(projection, collectionName, resultConverter); return executeFindMultiInternal(new FindCallback(collectionPreparer, mappedQuery, mappedFields, null), preparer, - new ProjectingReadCallback<>(mongoConverter, projection, collectionName), collectionName); + callback, collectionName); } /** @@ -2969,6 +3006,16 @@ private void executeQueryInternal(CollectionCallback> col } } + @SuppressWarnings("unchecked") + private DocumentCallback getResultReader(EntityProjection projection, String collectionName, + QueryResultConverter resultConverter) { + + DocumentCallback readCallback = new ProjectingReadCallback<>(mongoConverter, projection, collectionName); + + return resultConverter == QueryResultConverter.entity() ? (DocumentCallback) readCallback + : new QueryResultConverterCallback(resultConverter, readCallback); + } + public PersistenceExceptionTranslator getExceptionTranslator() { return exceptionTranslator; } @@ -3328,6 +3375,24 @@ public T doWith(Document document) { } } + static final class QueryResultConverterCallback implements DocumentCallback { + + private final QueryResultConverter converter; + private final DocumentCallback delegate; + + QueryResultConverterCallback(QueryResultConverter converter, DocumentCallback delegate) { + this.converter = converter; + this.delegate = delegate; + } + + @Override + public R doWith(Document object) { + + Lazy lazy = Lazy.of(() -> delegate.doWith(object)); + return converter.mapDocument(object, lazy::get); + } + } + /** * {@link DocumentCallback} transforming {@link Document} into the given {@code targetType} or decorating the * {@code sourceType} with a {@literal projection} in case the {@code targetType} is an {@literal interface}. diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/QueryResultConverter.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/QueryResultConverter.java new file mode 100644 index 0000000000..e271ee23cc --- /dev/null +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/QueryResultConverter.java @@ -0,0 +1,85 @@ +/* + * Copyright 2025 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 org.bson.Document; + +/** + * Converter for MongoDB query results. + *

+ * This is a functional interface that allows for mapping a {@link Document} to a result type. + * {@link #mapDocument(Document, ConversionResultSupplier) row mapping} can obtain upstream a + * {@link ConversionResultSupplier upstream converter} to enrich the final result object. This is useful when e.g. + * wrapping result objects where the wrapper needs to obtain information from the actual {@link Document}. + * + * @param object type accepted by this converter. + * @param the returned result type. + * @author Mark Paluch + * @since x.x + */ +@FunctionalInterface +public interface QueryResultConverter { + + /** + * Returns a function that returns the materialized entity. + * + * @param the type of the input and output entity to the function. + * @return a function that returns the materialized entity. + */ + @SuppressWarnings("unchecked") + static QueryResultConverter entity() { + return (QueryResultConverter) EntityResultConverter.INSTANCE; + } + + /** + * Map a {@link Document} that is read from the MongoDB query/aggregation operation to a query result. + * + * @param document the raw document from the MongoDB query/aggregation result. + * @param reader reader object that supplies an upstream result from an earlier converter. + * @return the mapped result. + */ + R mapDocument(Document document, ConversionResultSupplier reader); + + /** + * Returns a composed function that first applies this function to its input, and then applies the {@code after} + * function to the result. If evaluation of either function throws an exception, it is relayed to the caller of the + * composed function. + * + * @param the type of output of the {@code after} function, and of the composed function. + * @param after the function to apply after this function is applied. + * @return a composed function that first applies this function and then applies the {@code after} function. + */ + default QueryResultConverter andThen(QueryResultConverter after) { + return (row, reader) -> after.mapDocument(row, () -> mapDocument(row, reader)); + } + + /** + * A supplier that converts a {@link Document} into {@code T}. Allows for lazy reading of query results. + * + * @param type of the returned result. + */ + interface ConversionResultSupplier { + + /** + * Obtain the upstream conversion result. + * + * @return the upstream conversion result. + */ + T get(); + + } + +} diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/ReactiveAggregationOperation.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/ReactiveAggregationOperation.java index 54129e6b5d..883bc65579 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/ReactiveAggregationOperation.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/ReactiveAggregationOperation.java @@ -18,6 +18,7 @@ import reactor.core.publisher.Flux; import org.springframework.data.mongodb.core.aggregation.Aggregation; +import org.springframework.lang.Contract; /** * {@link ReactiveAggregationOperation} allows creation and execution of reactive MongoDB aggregation operations in a @@ -44,7 +45,7 @@ public interface ReactiveAggregationOperation { /** * Start creating an aggregation operation that returns results mapped to the given domain type.
* Use {@link org.springframework.data.mongodb.core.aggregation.TypedAggregation} to specify a potentially different - * input type for he aggregation. + * input type for the aggregation. * * @param domainType must not be {@literal null}. * @return new instance of {@link ReactiveAggregation}. Never {@literal null}. @@ -73,6 +74,18 @@ interface AggregationOperationWithCollection { */ interface TerminatingAggregationOperation { + /** + * Map the query result to a different type using {@link QueryResultConverter}. + * + * @param {@link Class type} of the result. + * @param converter the converter, must not be {@literal null}. + * @return new instance of {@link ExecutableFindOperation.TerminatingFindNear}. + * @throws IllegalArgumentException if {@link QueryResultConverter converter} is {@literal null}. + * @since x.y + */ + @Contract("_ -> new") + TerminatingAggregationOperation map(QueryResultConverter converter); + /** * Apply pipeline operations as specified and stream all matching elements.
* diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/ReactiveAggregationOperationSupport.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/ReactiveAggregationOperationSupport.java index 954fd61716..a25d0eed6c 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/ReactiveAggregationOperationSupport.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/ReactiveAggregationOperationSupport.java @@ -51,22 +51,25 @@ public ReactiveAggregation aggregateAndReturn(Class domainType) { Assert.notNull(domainType, "DomainType must not be null"); - return new ReactiveAggregationSupport<>(template, domainType, null, null); + return new ReactiveAggregationSupport<>(template, domainType, QueryResultConverter.entity(), null, null); } - static class ReactiveAggregationSupport + static class ReactiveAggregationSupport implements AggregationOperationWithAggregation, ReactiveAggregation, TerminatingAggregationOperation { private final ReactiveMongoTemplate template; - private final Class domainType; + private final Class domainType; + private final QueryResultConverter resultConverter; private final Aggregation aggregation; private final String collection; - ReactiveAggregationSupport(ReactiveMongoTemplate template, Class domainType, Aggregation aggregation, + ReactiveAggregationSupport(ReactiveMongoTemplate template, Class domainType, + QueryResultConverter resultConverter, Aggregation aggregation, String collection) { this.template = template; this.domainType = domainType; + this.resultConverter = resultConverter; this.aggregation = aggregation; this.collection = collection; } @@ -76,7 +79,7 @@ public AggregationOperationWithAggregation inCollection(String collection) { Assert.hasText(collection, "Collection must not be null nor empty"); - return new ReactiveAggregationSupport<>(template, domainType, aggregation, collection); + return new ReactiveAggregationSupport<>(template, domainType, resultConverter, aggregation, collection); } @Override @@ -84,12 +87,21 @@ public TerminatingAggregationOperation by(Aggregation aggregation) { Assert.notNull(aggregation, "Aggregation must not be null"); - return new ReactiveAggregationSupport<>(template, domainType, aggregation, collection); + return new ReactiveAggregationSupport<>(template, domainType, resultConverter, aggregation, collection); + } + + @Override + public TerminatingAggregationOperation map(QueryResultConverter converter) { + + Assert.notNull(converter, "QueryResultConverter must not be null"); + + return new ReactiveAggregationSupport<>(template, domainType, resultConverter.andThen(converter), aggregation, + collection); } @Override public Flux all() { - return template.aggregate(aggregation, getCollectionName(aggregation), domainType); + return template.doAggregate(aggregation, getCollectionName(aggregation), domainType, domainType, resultConverter); } private String getCollectionName(Aggregation aggregation) { diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/ReactiveFindOperation.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/ReactiveFindOperation.java index cba827ffed..24d8c975bb 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/ReactiveFindOperation.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/ReactiveFindOperation.java @@ -25,6 +25,7 @@ import org.springframework.data.mongodb.core.query.CriteriaDefinition; import org.springframework.data.mongodb.core.query.NearQuery; import org.springframework.data.mongodb.core.query.Query; +import org.springframework.lang.Contract; /** * {@link ReactiveFindOperation} allows creation and execution of reactive MongoDB find operations in a fluent API @@ -66,7 +67,28 @@ public interface ReactiveFindOperation { /** * Compose find execution by calling one of the terminating methods. */ - interface TerminatingFind { + interface TerminatingFind extends TerminatingResults, TerminatingProjection { + + } + + /** + * Compose find execution by calling one of the terminating methods. + * + * @since x.y + */ + interface TerminatingResults { + + /** + * Map the query result to a different type using {@link QueryResultConverter}. + * + * @param {@link Class type} of the result. + * @param converter the converter, must not be {@literal null}. + * @return new instance of {@link TerminatingResults}. + * @throws IllegalArgumentException if {@link QueryResultConverter converter} is {@literal null}. + * @since x.y + */ + @Contract("_ -> new") + TerminatingResults map(QueryResultConverter converter); /** * Get exactly zero or one result. @@ -120,6 +142,15 @@ interface TerminatingFind { */ Flux tail(); + } + + /** + * Compose find execution by calling one of the terminating methods. + * + * @since x.y + */ + interface TerminatingProjection { + /** * Get the number of matching elements.
* This method uses an @@ -145,6 +176,18 @@ interface TerminatingFind { */ interface TerminatingFindNear { + /** + * Map the query result to a different type using {@link QueryResultConverter}. + * + * @param {@link Class type} of the result. + * @param converter the converter, must not be {@literal null}. + * @return new instance of {@link ExecutableFindOperation.TerminatingFindNear}. + * @throws IllegalArgumentException if {@link QueryResultConverter converter} is {@literal null}. + * @since x.y + */ + @Contract("_ -> new") + TerminatingFindNear map(QueryResultConverter converter); + /** * Find all matching elements and return them as {@link org.springframework.data.geo.GeoResult}. * diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/ReactiveFindOperationSupport.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/ReactiveFindOperationSupport.java index d1aec8af36..6292205dcd 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/ReactiveFindOperationSupport.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/ReactiveFindOperationSupport.java @@ -19,9 +19,11 @@ import reactor.core.publisher.Mono; import org.bson.Document; + import org.springframework.dao.IncorrectResultSizeDataAccessException; -import org.springframework.data.domain.Window; import org.springframework.data.domain.ScrollPosition; +import org.springframework.data.domain.Window; +import org.springframework.data.geo.GeoResult; import org.springframework.data.mongodb.core.CollectionPreparerSupport.ReactiveCollectionPreparerDelegate; import org.springframework.data.mongodb.core.query.NearQuery; import org.springframework.data.mongodb.core.query.Query; @@ -52,7 +54,7 @@ public ReactiveFind query(Class domainType) { Assert.notNull(domainType, "DomainType must not be null"); - return new ReactiveFindSupport<>(template, domainType, domainType, null, ALL_QUERY); + return new ReactiveFindSupport<>(template, domainType, domainType, QueryResultConverter.entity(), null, ALL_QUERY); } /** @@ -61,21 +63,24 @@ public ReactiveFind query(Class domainType) { * @author Christoph Strobl * @since 2.0 */ - static class ReactiveFindSupport + static class ReactiveFindSupport implements ReactiveFind, FindWithCollection, FindWithProjection, FindWithQuery { private final ReactiveMongoTemplate template; private final Class domainType; - private final Class returnType; - private final String collection; + private final Class returnType; + private final QueryResultConverter resultConverter; + private final @Nullable String collection; private final Query query; - ReactiveFindSupport(ReactiveMongoTemplate template, Class domainType, Class returnType, String collection, + ReactiveFindSupport(ReactiveMongoTemplate template, Class domainType, Class returnType, + QueryResultConverter resultConverter, @Nullable String collection, Query query) { this.template = template; this.domainType = domainType; this.returnType = returnType; + this.resultConverter = resultConverter; this.collection = collection; this.query = query; } @@ -85,7 +90,7 @@ public FindWithProjection inCollection(String collection) { Assert.hasText(collection, "Collection name must not be null nor empty"); - return new ReactiveFindSupport<>(template, domainType, returnType, collection, query); + return new ReactiveFindSupport<>(template, domainType, returnType, resultConverter, collection, query); } @Override @@ -93,7 +98,8 @@ public FindWithQuery as(Class returnType) { Assert.notNull(returnType, "ReturnType must not be null"); - return new ReactiveFindSupport<>(template, domainType, returnType, collection, query); + return new ReactiveFindSupport<>(template, domainType, returnType, QueryResultConverter.entity(), collection, + query); } @Override @@ -101,7 +107,16 @@ public TerminatingFind matching(Query query) { Assert.notNull(query, "Query must not be null"); - return new ReactiveFindSupport<>(template, domainType, returnType, collection, query); + return new ReactiveFindSupport<>(template, domainType, returnType, resultConverter, collection, query); + } + + @Override + public TerminatingResults map(QueryResultConverter converter) { + + Assert.notNull(converter, "QueryResultConverter must not be null"); + + return new ReactiveFindSupport<>(template, domainType, returnType, this.resultConverter.andThen(converter), + collection, query); } @Override @@ -141,7 +156,8 @@ public Flux all() { @Override public Mono> scroll(ScrollPosition scrollPosition) { - return template.doScroll(query.with(scrollPosition), domainType, returnType, getCollectionName()); + return template.doScroll(query.with(scrollPosition), domainType, returnType, resultConverter, + getCollectionName()); } @Override @@ -151,7 +167,7 @@ public Flux tail() { @Override public TerminatingFindNear near(NearQuery nearQuery) { - return () -> template.geoNear(nearQuery, domainType, getCollectionName(), returnType); + return new TerminatingFindNearSupport<>(nearQuery, resultConverter); } @Override @@ -178,14 +194,15 @@ private Flux doFind(@Nullable FindPublisherPreparer preparer) { Document fieldsObject = query.getFieldsObject(); return template.doFind(getCollectionName(), ReactiveCollectionPreparerDelegate.of(query), queryObject, - fieldsObject, domainType, returnType, preparer != null ? preparer : getCursorPreparer(query)); + fieldsObject, domainType, returnType, resultConverter, + preparer != null ? preparer : getCursorPreparer(query)); } - @SuppressWarnings("unchecked") + @SuppressWarnings({ "unchecked", "rawtypes" }) private Flux doFindDistinct(String field) { return template.findDistinct(query, field, getCollectionName(), domainType, - returnType == domainType ? (Class) Object.class : returnType); + returnType == domainType ? (Class) Object.class : returnType); } private FindPublisherPreparer getCursorPreparer(Query query) { @@ -200,10 +217,36 @@ private String asString() { return SerializationUtils.serializeToJsonSafely(query); } + class TerminatingFindNearSupport implements TerminatingFindNear { + + private final NearQuery nearQuery; + private final QueryResultConverter resultConverter; + + public TerminatingFindNearSupport(NearQuery nearQuery, + QueryResultConverter resultConverter) { + this.nearQuery = nearQuery; + this.resultConverter = resultConverter; + } + + @Override + public TerminatingFindNear map(QueryResultConverter converter) { + + Assert.notNull(converter, "QueryResultConverter must not be null"); + + return new TerminatingFindNearSupport<>(nearQuery, this.resultConverter.andThen(converter)); + } + + @Override + public Flux> all() { + return template.doGeoNear(nearQuery, domainType, getCollectionName(), returnType, resultConverter); + } + } + /** * @author Christoph Strobl * @since 2.1 */ + @SuppressWarnings({ "unchecked", "rawtypes" }) static class DistinctOperationSupport implements TerminatingDistinct { private final String field; @@ -224,12 +267,11 @@ public TerminatingDistinct as(Class resultType) { } @Override - @SuppressWarnings("unchecked") public TerminatingDistinct matching(Query query) { Assert.notNull(query, "Query must not be null"); - return new DistinctOperationSupport<>((ReactiveFindSupport) delegate.matching(query), field); + return new DistinctOperationSupport<>((ReactiveFindSupport) delegate.matching(query), field); } @Override diff --git a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/ReactiveMongoTemplate.java b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/ReactiveMongoTemplate.java index b74ec6aa1c..0fa2f6b019 100644 --- a/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/ReactiveMongoTemplate.java +++ b/spring-data-mongodb/src/main/java/org/springframework/data/mongodb/core/ReactiveMongoTemplate.java @@ -877,10 +877,11 @@ public Mono> scroll(Query query, Class entityType) { @Override public Mono> scroll(Query query, Class entityType, String collectionName) { - return doScroll(query, entityType, entityType, collectionName); + return doScroll(query, entityType, entityType, QueryResultConverter.entity(), collectionName); } - Mono> doScroll(Query query, Class sourceClass, Class targetClass, String collectionName) { + Mono> doScroll(Query query, Class sourceClass, Class targetClass, + QueryResultConverter resultConverter, String collectionName) { Assert.notNull(query, "Query must not be null"); Assert.notNull(collectionName, "CollectionName must not be null"); @@ -888,7 +889,7 @@ Mono> doScroll(Query query, Class sourceClass, Class targetC Assert.notNull(targetClass, "Target type must not be null"); EntityProjection projection = operations.introspectProjection(targetClass, sourceClass); - ProjectingReadCallback callback = new ProjectingReadCallback<>(mongoConverter, projection, collectionName); + DocumentCallback callback = getResultReader(projection, collectionName, resultConverter); int limit = query.isLimited() ? query.getLimit() + 1 : Integer.MAX_VALUE; if (query.hasKeyset()) { @@ -896,7 +897,7 @@ Mono> doScroll(Query query, Class sourceClass, Class targetC KeysetScrollQuery keysetPaginationQuery = ScrollUtils.createKeysetPaginationQuery(query, operations.getIdPropertyName(sourceClass)); - Mono> result = doFind(collectionName, ReactiveCollectionPreparerDelegate.of(query), + Mono> result = doFind(collectionName, ReactiveCollectionPreparerDelegate.of(query), keysetPaginationQuery.query(), keysetPaginationQuery.fields(), sourceClass, new QueryFindPublisherPreparer(query, keysetPaginationQuery.sort(), limit, 0, sourceClass), callback) .collectList(); @@ -904,7 +905,7 @@ Mono> doScroll(Query query, Class sourceClass, Class targetC return result.map(it -> ScrollUtils.createWindow(query, it, sourceClass, operations)); } - Mono> result = doFind(collectionName, ReactiveCollectionPreparerDelegate.of(query), query.getQueryObject(), + Mono> result = doFind(collectionName, ReactiveCollectionPreparerDelegate.of(query), query.getQueryObject(), query.getFieldsObject(), sourceClass, new QueryFindPublisherPreparer(query, query.getSortObject(), limit, query.getSkip(), sourceClass), callback) .collectList(); @@ -1003,6 +1004,11 @@ public Flux aggregate(Aggregation aggregation, String collectionName, Cla protected Flux doAggregate(Aggregation aggregation, String collectionName, @Nullable Class inputType, Class outputType) { + return doAggregate(aggregation, collectionName, inputType, outputType, QueryResultConverter.entity()); + } + + Flux doAggregate(Aggregation aggregation, String collectionName, @Nullable Class inputType, + Class outputType, QueryResultConverter resultConverter) { Assert.notNull(aggregation, "Aggregation pipeline must not be null"); Assert.hasText(collectionName, "Collection name must not be null or empty"); @@ -1018,13 +1024,14 @@ protected Flux doAggregate(Aggregation aggregation, String collectionName serializeToJsonSafely(ctx.getAggregationPipeline()), collectionName)); } - ReadDocumentCallback readCallback = new ReadDocumentCallback<>(mongoConverter, outputType, collectionName); + DocumentCallback readCallback = new QueryResultConverterCallback<>(resultConverter, + new ReadDocumentCallback<>(mongoConverter, outputType, collectionName)); return execute(collectionName, collection -> aggregateAndMap(collection, ctx.getAggregationPipeline(), ctx.isOutOrMerge(), options, readCallback, ctx.getInputType())); } private Flux aggregateAndMap(MongoCollection collection, List pipeline, - boolean isOutOrMerge, AggregationOptions options, ReadDocumentCallback readCallback, + boolean isOutOrMerge, AggregationOptions options, DocumentCallback readCallback, @Nullable Class inputType) { ReactiveCollectionPreparerDelegate collectionPreparer = ReactiveCollectionPreparerDelegate.of(options); @@ -1070,9 +1077,14 @@ public Flux> geoNear(NearQuery near, Class entityClass, Stri return geoNear(near, entityClass, collectionName, entityClass); } - @SuppressWarnings("unchecked") protected Flux> geoNear(NearQuery near, Class entityClass, String collectionName, Class returnType) { + return doGeoNear(near, entityClass, collectionName, returnType, QueryResultConverter.entity()); + } + + @SuppressWarnings("unchecked") + Flux> doGeoNear(NearQuery near, Class entityClass, String collectionName, Class returnType, + QueryResultConverter resultConverter) { if (near == null) { throw new InvalidDataAccessApiUsageException("NearQuery must not be null"); @@ -1086,8 +1098,8 @@ protected Flux> geoNear(NearQuery near, Class entityClass, S String distanceField = operations.nearQueryDistanceFieldName(entityClass); EntityProjection projection = operations.introspectProjection(returnType, entityClass); - GeoNearResultDocumentCallback callback = new GeoNearResultDocumentCallback<>(distanceField, - new ProjectingReadCallback<>(mongoConverter, projection, collection), near.getMetric()); + GeoNearResultDocumentCallback callback = new GeoNearResultDocumentCallback<>(distanceField, + getResultReader(projection, collectionName, resultConverter), near.getMetric()); Builder optionsBuilder = AggregationOptions.builder(); if (near.hasReadPreference()) { @@ -2412,11 +2424,12 @@ CollectionPreparer> createCollectionPreparer(Query que * * @since 2.0 */ - Flux doFind(String collectionName, CollectionPreparer> collectionPreparer, - Document query, Document fields, Class sourceClass, Class targetClass, FindPublisherPreparer preparer) { + Flux doFind(String collectionName, CollectionPreparer> collectionPreparer, + Document query, Document fields, Class sourceClass, Class targetClass, + QueryResultConverter resultConverter, FindPublisherPreparer preparer) { MongoPersistentEntity entity = mappingContext.getPersistentEntity(sourceClass); - EntityProjection projection = operations.introspectProjection(targetClass, sourceClass); + EntityProjection projection = operations.introspectProjection(targetClass, sourceClass); QueryContext queryContext = queryOperations.createQueryContext(new BasicQuery(query, fields)); Document mappedFields = queryContext.getMappedFields(entity, projection); @@ -2428,7 +2441,7 @@ Flux doFind(String collectionName, CollectionPreparer(mongoConverter, projection, collectionName), collectionName); + getResultReader(projection, collectionName, resultConverter), collectionName); } protected CreateCollectionOptions convertToCreateCollectionOptions(@Nullable CollectionOptions collectionOptions) { @@ -2738,6 +2751,16 @@ private Flux executeFindMultiInternal(ReactiveCollectionQueryCallback DocumentCallback getResultReader(EntityProjection projection, String collectionName, + QueryResultConverter resultConverter) { + + DocumentCallback readCallback = new ProjectingReadCallback<>(mongoConverter, projection, collectionName); + + return resultConverter == QueryResultConverter.entity() ? (DocumentCallback) readCallback + : new QueryResultConverterCallback(resultConverter, readCallback); + } + /** * Exception translation {@link Function} intended for {@link Flux#onErrorMap(Function)} usage. * @@ -3095,6 +3118,22 @@ interface ReactiveCollectionQueryCallback extends ReactiveCollectionCallback< FindPublisher doInCollection(MongoCollection collection) throws MongoException, DataAccessException; } + static final class QueryResultConverterCallback implements DocumentCallback { + + private final QueryResultConverter converter; + private final DocumentCallback delegate; + + QueryResultConverterCallback(QueryResultConverter converter, DocumentCallback delegate) { + this.converter = converter; + this.delegate = delegate; + } + + @Override + public Mono doWith(Document object) { + return delegate.doWith(object).map(it -> converter.mapDocument(object, () -> it)); + } + } + /** * Simple {@link DocumentCallback} that will transform {@link Document} into the given target type using the given * {@link EntityReader}. diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/ExecutableAggregationOperationSupportUnitTests.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/ExecutableAggregationOperationSupportUnitTests.java index 05f0695839..80373562c8 100644 --- a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/ExecutableAggregationOperationSupportUnitTests.java +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/ExecutableAggregationOperationSupportUnitTests.java @@ -33,6 +33,7 @@ * Unit tests for {@link ExecutableAggregationOperationSupport}. * * @author Christoph Strobl + * @author Mark Paluch */ @ExtendWith(MockitoExtension.class) public class ExecutableAggregationOperationSupportUnitTests { @@ -72,7 +73,8 @@ void aggregateWithUntypedAggregationAndExplicitCollection() { opSupport.aggregateAndReturn(Person.class).inCollection("star-wars").by(newAggregation(project("foo"))).all(); ArgumentCaptor captor = ArgumentCaptor.forClass(Class.class); - verify(template).aggregate(any(Aggregation.class), eq("star-wars"), captor.capture()); + verify(template).doAggregate(any(Aggregation.class), eq("star-wars"), captor.capture(), + eq(QueryResultConverter.entity())); assertThat(captor.getValue()).isEqualTo(Person.class); } @@ -86,7 +88,8 @@ void aggregateWithUntypedAggregation() { ArgumentCaptor captor = ArgumentCaptor.forClass(Class.class); verify(template).getCollectionName(captor.capture()); - verify(template).aggregate(any(Aggregation.class), eq("person"), captor.capture()); + verify(template).doAggregate(any(Aggregation.class), eq("person"), captor.capture(), + eq(QueryResultConverter.entity())); assertThat(captor.getAllValues()).containsExactly(Person.class, Person.class); } @@ -101,7 +104,8 @@ void aggregateWithTypeAggregation() { ArgumentCaptor captor = ArgumentCaptor.forClass(Class.class); verify(template).getCollectionName(captor.capture()); - verify(template).aggregate(any(Aggregation.class), eq("person"), captor.capture()); + verify(template).doAggregate(any(Aggregation.class), eq("person"), captor.capture(), + eq(QueryResultConverter.entity())); assertThat(captor.getAllValues()).containsExactly(Person.class, Jedi.class); } @@ -112,7 +116,8 @@ void aggregateStreamWithUntypedAggregationAndExplicitCollection() { opSupport.aggregateAndReturn(Person.class).inCollection("star-wars").by(newAggregation(project("foo"))).stream(); ArgumentCaptor captor = ArgumentCaptor.forClass(Class.class); - verify(template).aggregateStream(any(Aggregation.class), eq("star-wars"), captor.capture()); + verify(template).doAggregateStream(any(Aggregation.class), eq("star-wars"), captor.capture(), + eq(QueryResultConverter.entity()), any()); assertThat(captor.getValue()).isEqualTo(Person.class); } @@ -126,7 +131,8 @@ void aggregateStreamWithUntypedAggregation() { ArgumentCaptor captor = ArgumentCaptor.forClass(Class.class); verify(template).getCollectionName(captor.capture()); - verify(template).aggregateStream(any(Aggregation.class), eq("person"), captor.capture()); + verify(template).doAggregateStream(any(Aggregation.class), eq("person"), captor.capture(), + eq(QueryResultConverter.entity()), any()); assertThat(captor.getAllValues()).containsExactly(Person.class, Person.class); } @@ -141,7 +147,8 @@ void aggregateStreamWithTypeAggregation() { ArgumentCaptor captor = ArgumentCaptor.forClass(Class.class); verify(template).getCollectionName(captor.capture()); - verify(template).aggregateStream(any(Aggregation.class), eq("person"), captor.capture()); + verify(template).doAggregateStream(any(Aggregation.class), eq("person"), captor.capture(), + eq(QueryResultConverter.entity()), any()); assertThat(captor.getAllValues()).containsExactly(Person.class, Jedi.class); } diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/ExecutableFindOperationSupportTests.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/ExecutableFindOperationSupportTests.java index eac248e69a..3f7e167bd2 100644 --- a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/ExecutableFindOperationSupportTests.java +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/ExecutableFindOperationSupportTests.java @@ -21,7 +21,9 @@ import static org.springframework.data.mongodb.test.util.DirtiesStateExtension.*; import java.util.Date; +import java.util.List; import java.util.Objects; +import java.util.Optional; import java.util.stream.Stream; import org.bson.BsonString; @@ -170,6 +172,16 @@ void findAllByWithProjection() { .hasOnlyElementsOfType(Jedi.class).hasSize(1); } + @Test // GH- + void findAllByWithConverter() { + + List> result = template.query(Person.class).as(Jedi.class) + .matching(query(where("firstname").is("luke"))).map((document, reader) -> Optional.of(reader.get())).all(); + + assertThat(result).hasOnlyElementsOfType(Optional.class).hasSize(1); + assertThat(result).extracting(Optional::get).hasOnlyElementsOfType(Jedi.class).hasSize(1); + } + @Test // DATAMONGO-1563 void findBy() { assertThat(template.query(Person.class).matching(query(where("firstname").is("luke"))).one()).contains(luke); @@ -260,6 +272,15 @@ void streamAllWithProjection() { } } + @Test // GH- + void streamAllWithConverter() { + + try (Stream> stream = template.query(Person.class).as(Jedi.class) + .map((document, reader) -> Optional.of(reader.get())).stream()) { + assertThat(stream).extracting(Optional::get).hasOnlyElementsOfType(Jedi.class).hasSize(2); + } + } + @Test // DATAMONGO-1733 void streamAllReturningResultsAsClosedInterfaceProjection() { @@ -315,6 +336,20 @@ void findAllNearByWithCollectionAndProjection() { assertThat(results.getContent().get(0).getContent().getId()).isEqualTo("alderan"); } + @Test // GH- + void findAllNearByWithConverter() { + + GeoResults> results = template.query(Object.class).inCollection(STAR_WARS_PLANETS).as(Human.class) + .near(NearQuery.near(-73.9667, 40.78).spherical(true)).map((document, reader) -> Optional.of(reader.get())) + .all(); + + assertThat(results.getContent()).hasSize(2); + assertThat(results.getContent().get(0).getDistance()).isNotNull(); + assertThat(results.getContent().get(0).getContent()).isInstanceOf(Optional.class); + assertThat(results.getContent().get(0).getContent().get()).isInstanceOf(Human.class); + assertThat(results.getContent().get(0).getContent().get().getId()).isEqualTo("alderan"); + } + @Test // DATAMONGO-1733 void findAllNearByReturningGeoResultContentAsClosedInterfaceProjection() { diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/MongoTemplateUnitTests.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/MongoTemplateUnitTests.java index 79a0bb1fcb..81408cc22d 100644 --- a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/MongoTemplateUnitTests.java +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/MongoTemplateUnitTests.java @@ -1156,7 +1156,7 @@ void countShouldApplyQueryHintAsIndexNameIfPresent() { void appliesFieldsWhenInterfaceProjectionIsClosedAndQueryDoesNotDefineFields() { template.doFind(CollectionPreparer.identity(), "star-wars", new Document(), new Document(), Person.class, - PersonProjection.class, CursorPreparer.NO_OP_PREPARER); + PersonProjection.class, QueryResultConverter.entity(), CursorPreparer.NO_OP_PREPARER); verify(findIterable).projection(eq(new Document("firstname", 1))); } @@ -1165,7 +1165,7 @@ void appliesFieldsWhenInterfaceProjectionIsClosedAndQueryDoesNotDefineFields() { void doesNotApplyFieldsWhenInterfaceProjectionIsClosedAndQueryDefinesFields() { template.doFind(CollectionPreparer.identity(), "star-wars", new Document(), new Document("bar", 1), Person.class, - PersonProjection.class, CursorPreparer.NO_OP_PREPARER); + PersonProjection.class, QueryResultConverter.entity(), CursorPreparer.NO_OP_PREPARER); verify(findIterable).projection(eq(new Document("bar", 1))); } @@ -1174,7 +1174,7 @@ void doesNotApplyFieldsWhenInterfaceProjectionIsClosedAndQueryDefinesFields() { void doesNotApplyFieldsWhenInterfaceProjectionIsOpen() { template.doFind(CollectionPreparer.identity(), "star-wars", new Document(), new Document(), Person.class, - PersonSpELProjection.class, CursorPreparer.NO_OP_PREPARER); + PersonSpELProjection.class, QueryResultConverter.entity(), CursorPreparer.NO_OP_PREPARER); verify(findIterable).projection(eq(BsonUtils.EMPTY_DOCUMENT)); } @@ -1183,7 +1183,7 @@ void doesNotApplyFieldsWhenInterfaceProjectionIsOpen() { void appliesFieldsToDtoProjection() { template.doFind(CollectionPreparer.identity(), "star-wars", new Document(), new Document(), Person.class, - Jedi.class, CursorPreparer.NO_OP_PREPARER); + Jedi.class, QueryResultConverter.entity(), CursorPreparer.NO_OP_PREPARER); verify(findIterable).projection(eq(new Document("firstname", 1))); } @@ -1192,7 +1192,7 @@ void appliesFieldsToDtoProjection() { void doesNotApplyFieldsToDtoProjectionWhenQueryDefinesFields() { template.doFind(CollectionPreparer.identity(), "star-wars", new Document(), new Document("bar", 1), Person.class, - Jedi.class, CursorPreparer.NO_OP_PREPARER); + Jedi.class, QueryResultConverter.entity(), CursorPreparer.NO_OP_PREPARER); verify(findIterable).projection(eq(new Document("bar", 1))); } @@ -1201,7 +1201,7 @@ void doesNotApplyFieldsToDtoProjectionWhenQueryDefinesFields() { void doesNotApplyFieldsWhenTargetIsNotAProjection() { template.doFind(CollectionPreparer.identity(), "star-wars", new Document(), new Document(), Person.class, - Person.class, CursorPreparer.NO_OP_PREPARER); + Person.class, QueryResultConverter.entity(), CursorPreparer.NO_OP_PREPARER); verify(findIterable).projection(eq(BsonUtils.EMPTY_DOCUMENT)); } @@ -1210,7 +1210,7 @@ void doesNotApplyFieldsWhenTargetIsNotAProjection() { void doesNotApplyFieldsWhenTargetExtendsDomainType() { template.doFind(CollectionPreparer.identity(), "star-wars", new Document(), new Document(), Person.class, - PersonExtended.class, CursorPreparer.NO_OP_PREPARER); + PersonExtended.class, QueryResultConverter.entity(), CursorPreparer.NO_OP_PREPARER); verify(findIterable).projection(eq(BsonUtils.EMPTY_DOCUMENT)); } diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/ReactiveAggregationOperationSupportUnitTests.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/ReactiveAggregationOperationSupportUnitTests.java index 9d4ed339b5..83e1b3c272 100644 --- a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/ReactiveAggregationOperationSupportUnitTests.java +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/ReactiveAggregationOperationSupportUnitTests.java @@ -72,7 +72,8 @@ void aggregateWithUntypedAggregationAndExplicitCollection() { opSupport.aggregateAndReturn(Person.class).inCollection("star-wars").by(newAggregation(project("foo"))).all(); ArgumentCaptor captor = ArgumentCaptor.forClass(Class.class); - verify(template).aggregate(any(Aggregation.class), eq("star-wars"), captor.capture()); + verify(template).doAggregate(any(Aggregation.class), eq("star-wars"), captor.capture(), any(Class.class), + eq(QueryResultConverter.entity())); assertThat(captor.getValue()).isEqualTo(Person.class); } @@ -86,7 +87,8 @@ void aggregateWithUntypedAggregation() { ArgumentCaptor captor = ArgumentCaptor.forClass(Class.class); verify(template).getCollectionName(captor.capture()); - verify(template).aggregate(any(Aggregation.class), eq("person"), captor.capture()); + verify(template).doAggregate(any(Aggregation.class), eq("person"), captor.capture(), any(Class.class), + eq(QueryResultConverter.entity())); assertThat(captor.getAllValues()).containsExactly(Person.class, Person.class); } @@ -101,7 +103,8 @@ void aggregateWithTypeAggregation() { ArgumentCaptor captor = ArgumentCaptor.forClass(Class.class); verify(template).getCollectionName(captor.capture()); - verify(template).aggregate(any(Aggregation.class), eq("person"), captor.capture()); + verify(template).doAggregate(any(Aggregation.class), eq("person"), captor.capture(), any(Class.class), + eq(QueryResultConverter.entity())); assertThat(captor.getAllValues()).containsExactly(Person.class, Jedi.class); } diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/ReactiveFindOperationSupportTests.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/ReactiveFindOperationSupportTests.java index f23e973202..28b77cdfa9 100644 --- a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/ReactiveFindOperationSupportTests.java +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/ReactiveFindOperationSupportTests.java @@ -26,6 +26,7 @@ import java.util.Date; import java.util.Objects; +import java.util.Optional; import java.util.concurrent.BlockingQueue; import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.TimeUnit; @@ -167,6 +168,17 @@ void findAllWithProjection() { .verifyComplete(); } + @Test // GH-… + void findAllWithConverter() { + + template.query(Person.class).as(Jedi.class).map((document, reader) -> Optional.of(reader.get())).all() + .map(Optional::get) // + .map(it -> it.getClass().getName()) // + .as(StepVerifier::create) // + .expectNext(Jedi.class.getName(), Jedi.class.getName()) // + .verifyComplete(); + } + @Test // DATAMONGO-1719 void findAllBy() { @@ -299,6 +311,32 @@ void findAllNearByWithCollectionAndProjection() { .verifyComplete(); } + @Test // GH-… + @DirtiesState + void findAllNearByWithConverter() { + + blocking.indexOps(Planet.class).ensureIndex( + new GeospatialIndex("coordinates").typed(GeoSpatialIndexType.GEO_2DSPHERE).named("planet-coordinate-idx")); + + Planet alderan = new Planet("alderan", new Point(-73.9836, 40.7538)); + Planet dantooine = new Planet("dantooine", new Point(-73.9928, 40.7193)); + + blocking.save(alderan); + blocking.save(dantooine); + + template.query(Object.class).inCollection(STAR_WARS).as(Human.class) + .near(NearQuery.near(-73.9667, 40.78).spherical(true)).map((document, reader) -> Optional.of(reader.get())) // + .all() // + .as(StepVerifier::create).consumeNextWith(actual -> { + assertThat(actual.getDistance()).isNotNull(); + assertThat(actual.getContent()).isInstanceOf(Optional.class); + assertThat(actual.getContent().get()).isInstanceOf(Human.class); + assertThat(actual.getContent().get().getId()).isEqualTo("alderan"); + }) // + .expectNextCount(1) // + .verifyComplete(); + } + @Test // DATAMONGO-1719 @DirtiesState void findAllNearByReturningGeoResultContentAsClosedInterfaceProjection() { diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/ReactiveMongoTemplateUnitTests.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/ReactiveMongoTemplateUnitTests.java index f89b2fa8c1..cc50a684cc 100644 --- a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/ReactiveMongoTemplateUnitTests.java +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/ReactiveMongoTemplateUnitTests.java @@ -18,6 +18,7 @@ import static org.assertj.core.api.Assertions.*; import static org.mockito.Mockito.*; import static org.springframework.data.mongodb.core.aggregation.Aggregation.*; +import static org.springframework.data.mongodb.test.util.Assertions.*; import static org.springframework.data.mongodb.test.util.Assertions.assertThat; import reactor.core.publisher.Flux; @@ -53,6 +54,7 @@ import org.mockito.quality.Strictness; import org.reactivestreams.Publisher; import org.reactivestreams.Subscriber; + import org.springframework.beans.factory.annotation.Value; import org.springframework.context.ApplicationContext; import org.springframework.context.ApplicationListener; @@ -437,7 +439,7 @@ void geoNearShouldHonorReadConcernFromQuery() { void appliesFieldsWhenInterfaceProjectionIsClosedAndQueryDoesNotDefineFields() { template.doFind("star-wars", CollectionPreparer.identity(), new Document(), new Document(), Person.class, - PersonProjection.class, FindPublisherPreparer.NO_OP_PREPARER).subscribe(); + PersonProjection.class, QueryResultConverter.entity(), FindPublisherPreparer.NO_OP_PREPARER).subscribe(); verify(findPublisher).projection(eq(new Document("firstname", 1))); } @@ -446,7 +448,7 @@ void appliesFieldsWhenInterfaceProjectionIsClosedAndQueryDoesNotDefineFields() { void doesNotApplyFieldsWhenInterfaceProjectionIsClosedAndQueryDefinesFields() { template.doFind("star-wars", CollectionPreparer.identity(), new Document(), new Document("bar", 1), Person.class, - PersonProjection.class, FindPublisherPreparer.NO_OP_PREPARER).subscribe(); + PersonProjection.class, QueryResultConverter.entity(), FindPublisherPreparer.NO_OP_PREPARER).subscribe(); verify(findPublisher).projection(eq(new Document("bar", 1))); } @@ -455,7 +457,7 @@ void doesNotApplyFieldsWhenInterfaceProjectionIsClosedAndQueryDefinesFields() { void doesNotApplyFieldsWhenInterfaceProjectionIsOpen() { template.doFind("star-wars", CollectionPreparer.identity(), new Document(), new Document(), Person.class, - PersonSpELProjection.class, FindPublisherPreparer.NO_OP_PREPARER).subscribe(); + PersonSpELProjection.class, QueryResultConverter.entity(), FindPublisherPreparer.NO_OP_PREPARER).subscribe(); verify(findPublisher, never()).projection(any()); } @@ -464,7 +466,7 @@ void doesNotApplyFieldsWhenInterfaceProjectionIsOpen() { void appliesFieldsToDtoProjection() { template.doFind("star-wars", CollectionPreparer.identity(), new Document(), new Document(), Person.class, - Jedi.class, FindPublisherPreparer.NO_OP_PREPARER).subscribe(); + Jedi.class, QueryResultConverter.entity(), FindPublisherPreparer.NO_OP_PREPARER).subscribe(); verify(findPublisher).projection(eq(new Document("firstname", 1))); } @@ -473,7 +475,7 @@ void appliesFieldsToDtoProjection() { void doesNotApplyFieldsToDtoProjectionWhenQueryDefinesFields() { template.doFind("star-wars", CollectionPreparer.identity(), new Document(), new Document("bar", 1), Person.class, - Jedi.class, FindPublisherPreparer.NO_OP_PREPARER).subscribe(); + Jedi.class, QueryResultConverter.entity(), FindPublisherPreparer.NO_OP_PREPARER).subscribe(); verify(findPublisher).projection(eq(new Document("bar", 1))); } @@ -482,7 +484,7 @@ void doesNotApplyFieldsToDtoProjectionWhenQueryDefinesFields() { void doesNotApplyFieldsWhenTargetIsNotAProjection() { template.doFind("star-wars", CollectionPreparer.identity(), new Document(), new Document(), Person.class, - Person.class, FindPublisherPreparer.NO_OP_PREPARER).subscribe(); + Person.class, QueryResultConverter.entity(), FindPublisherPreparer.NO_OP_PREPARER).subscribe(); verify(findPublisher, never()).projection(any()); } @@ -491,7 +493,7 @@ void doesNotApplyFieldsWhenTargetIsNotAProjection() { void doesNotApplyFieldsWhenTargetExtendsDomainType() { template.doFind("star-wars", CollectionPreparer.identity(), new Document(), new Document(), Person.class, - PersonExtended.class, FindPublisherPreparer.NO_OP_PREPARER).subscribe(); + PersonExtended.class, QueryResultConverter.entity(), FindPublisherPreparer.NO_OP_PREPARER).subscribe(); verify(findPublisher, never()).projection(any()); } diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/aggregation/AggregationTests.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/aggregation/AggregationTests.java index 99579b34a7..95a29fe8ba 100644 --- a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/aggregation/AggregationTests.java +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/aggregation/AggregationTests.java @@ -37,6 +37,7 @@ import java.util.Date; import java.util.List; import java.util.Objects; +import java.util.Optional; import java.util.Scanner; import java.util.stream.Stream; @@ -287,6 +288,60 @@ void shouldAggregateEmptyCollectionAndStream() { } } + @Test // GH- + void shouldAggregateAsStreamWithConverter() { + + MongoCollection coll = mongoTemplate.getCollection(INPUT_COLLECTION); + + coll.insertOne(createDocument("Doc1", "spring", "mongodb", "nosql")); + coll.insertOne(createDocument("Doc2")); + + Aggregation aggregation = newAggregation(// + project("tags"), // + unwind("tags"), // + group("tags") // + .count().as("n"), // + project("n") // + .and("tag").previousOperation(), // + sort(DESC, "n") // + ); + + try (Stream> stream = mongoTemplate.aggregateAndReturn(TagCount.class) + .inCollection(INPUT_COLLECTION).by(aggregation).map((document, reader) -> Optional.of(reader.get())).stream()) { + + List tagCount = stream.flatMap(Optional::stream).toList(); + + assertThat(tagCount).hasSize(3); + } + } + + @Test // GH- + void shouldAggregateWithConverter() { + + MongoCollection coll = mongoTemplate.getCollection(INPUT_COLLECTION); + + coll.insertOne(createDocument("Doc1", "spring", "mongodb", "nosql")); + coll.insertOne(createDocument("Doc2")); + + Aggregation aggregation = newAggregation(// + project("tags"), // + unwind("tags"), // + group("tags") // + .count().as("n"), // + project("n") // + .and("tag").previousOperation(), // + sort(DESC, "n") // + ); + + AggregationResults> results = mongoTemplate.aggregateAndReturn(TagCount.class) + .inCollection(INPUT_COLLECTION) // + .by(aggregation) // + .map((document, reader) -> Optional.of(reader.get())) // + .all(); + + assertThat(results.getMappedResults()).extracting(Optional::get).hasOnlyElementsOfType(TagCount.class).hasSize(3); + } + @Test // DATAMONGO-1391 void shouldUnwindWithIndex() { @@ -501,7 +556,7 @@ void findStatesWithPopulationOver10MillionAggregationExample() { /* //complex mongodb aggregation framework example from https://docs.mongodb.org/manual/tutorial/aggregation-examples/#largest-and-smallest-cities-by-state - + db.zipcodes.aggregate( { $group: { diff --git a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/aggregation/ReactiveAggregationTests.java b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/aggregation/ReactiveAggregationTests.java index 55d6bf3b60..62d13a8f27 100644 --- a/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/aggregation/ReactiveAggregationTests.java +++ b/spring-data-mongodb/src/test/java/org/springframework/data/mongodb/core/aggregation/ReactiveAggregationTests.java @@ -22,6 +22,7 @@ import reactor.test.StepVerifier; import java.util.Arrays; +import java.util.Optional; import org.bson.Document; import org.junit.After; @@ -115,6 +116,29 @@ public void shouldProjectMultipleDocuments() { }).verifyComplete(); } + @Test // GH-… + public void shouldProjectAndConvertMultipleDocuments() { + + City dresden = new City("Dresden", 100); + City linz = new City("Linz", 101); + City braunschweig = new City("Braunschweig", 102); + City weinheim = new City("Weinheim", 103); + + reactiveMongoTemplate.insertAll(Arrays.asList(dresden, linz, braunschweig, weinheim)).as(StepVerifier::create) + .expectNextCount(4).verifyComplete(); + + Aggregation agg = newAggregation( // + match(where("population").lt(103))); + + reactiveMongoTemplate.aggregateAndReturn(City.class).inCollection("city").by(agg) + .map((document, reader) -> Optional.of(reader.get())) // + .all() // + .collectList() // + .as(StepVerifier::create).consumeNextWith(actual -> { + assertThat(actual).hasSize(3).extracting(Optional::get).contains(dresden, linz, braunschweig); + }).verifyComplete(); + } + @Test // DATAMONGO-1646 public void shouldAggregateToOutCollection() { diff --git a/src/main/antora/modules/ROOT/pages/repositories/core-concepts.adoc b/src/main/antora/modules/ROOT/pages/repositories/core-concepts.adoc index 1a4af7a60b..7d31acb2d4 100644 --- a/src/main/antora/modules/ROOT/pages/repositories/core-concepts.adoc +++ b/src/main/antora/modules/ROOT/pages/repositories/core-concepts.adoc @@ -2,11 +2,3 @@ include::{commons}@data-commons::page$repositories/core-concepts.adoc[] [[mongodb.entity-persistence.state-detection-strategies]] include::{commons}@data-commons::page$is-new-state-detection.adoc[leveloffset=+1] - -[NOTE] -==== -Cassandra provides no means to generate identifiers upon inserting data. -As consequence, entities must be associated with identifier values. -Spring Data defaults to identifier inspection to determine whether an entity is new. -If you want to use xref:mongodb/auditing.adoc[auditing] make sure to either use xref:mongodb/template-crud-operations.adoc#mongo-template.optimistic-locking[Optimistic Locking] or implement `Persistable` for proper entity state detection. -====