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 536390e74..14ac474e6 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 @@ -18,6 +18,10 @@ package org.springframework.data.mongodb.core; import static org.springframework.data.mongodb.core.query.Criteria.*; import static org.springframework.data.mongodb.core.query.SerializationUtils.*; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import org.springframework.data.projection.ProjectionInformation; +import org.springframework.util.ClassUtils; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import reactor.util.function.Tuple2; @@ -101,6 +105,7 @@ import org.springframework.data.mongodb.core.query.NearQuery; import org.springframework.data.mongodb.core.query.Query; import org.springframework.data.mongodb.core.query.Update; import org.springframework.data.mongodb.util.MongoClientVersion; +import org.springframework.data.projection.SpelAwareProxyProjectionFactory; import org.springframework.data.util.Optionals; import org.springframework.data.util.Pair; import org.springframework.util.Assert; @@ -172,6 +177,7 @@ public class ReactiveMongoTemplate implements ReactiveMongoOperations, Applicati private final PersistenceExceptionTranslator exceptionTranslator; private final QueryMapper queryMapper; private final UpdateMapper updateMapper; + private final SpelAwareProxyProjectionFactory projectionFactory; private WriteConcern writeConcern; private WriteConcernResolver writeConcernResolver = DefaultWriteConcernResolver.INSTANCE; @@ -214,6 +220,7 @@ public class ReactiveMongoTemplate implements ReactiveMongoOperations, Applicati this.mongoConverter = mongoConverter == null ? getDefaultMongoConverter() : mongoConverter; this.queryMapper = new QueryMapper(this.mongoConverter); this.updateMapper = new UpdateMapper(this.mongoConverter); + this.projectionFactory = new SpelAwareProxyProjectionFactory(); // We always have a mapping context in the converter, whether it's a simple one or not mappingContext = this.mongoConverter.getMappingContext(); @@ -281,6 +288,9 @@ public class ReactiveMongoTemplate implements ReactiveMongoOperations, Applicati if (mappingContext instanceof ApplicationEventPublisherAware) { ((ApplicationEventPublisherAware) mappingContext).setApplicationEventPublisher(eventPublisher); } + + projectionFactory.setBeanFactory(applicationContext); + projectionFactory.setBeanClassLoader(applicationContext.getClassLoader()); } /** @@ -796,7 +806,7 @@ public class ReactiveMongoTemplate implements ReactiveMongoOperations, Applicati } GeoNearResultDbObjectCallback callback = new GeoNearResultDbObjectCallback( - new ReadDocumentCallback(mongoConverter, returnType, collectionName), near.getMetric()); + new ProjectingReadCallback<>(mongoConverter, entityClass, returnType, collectionName), near.getMetric()); return executeCommand(command, this.readPreference).flatMapMany(document -> { @@ -1859,7 +1869,7 @@ public class ReactiveMongoTemplate implements ReactiveMongoOperations, Applicati MongoPersistentEntity entity = mappingContext.getRequiredPersistentEntity(sourceClass); - Document mappedFields = queryMapper.getMappedFields(fields, entity); + Document mappedFields = getMappedFieldsObject(fields, entity, targetClass); Document mappedQuery = queryMapper.getMappedObject(query, entity); if (LOGGER.isDebugEnabled()) { @@ -1868,7 +1878,36 @@ public class ReactiveMongoTemplate implements ReactiveMongoOperations, Applicati } return executeFindMultiInternal(new FindCallback(mappedQuery, mappedFields), preparer, - new ReadDocumentCallback(mongoConverter, targetClass, collectionName), collectionName); + new ProjectingReadCallback<>(mongoConverter, sourceClass, targetClass, collectionName), collectionName); + } + + private Document getMappedFieldsObject(Document fields, MongoPersistentEntity entity, Class targetType) { + return queryMapper.getMappedFields(addFieldsForProjection(fields, entity.getType(), targetType), entity); + } + + /** + * For cases where {@code fields} is {@literal null} or {@literal empty} add fields required for creating the + * projection (target) type if the {@code targetType} is a {@literal closed interface projection}. + * + * @param fields can be {@literal null}. + * @param domainType must not be {@literal null}. + * @param targetType must not be {@literal null}. + * @return {@link Document} with fields to be included. + */ + private Document addFieldsForProjection(Document fields, Class domainType, Class targetType) { + + if ((fields != null && !fields.isEmpty()) || !targetType.isInterface() + || ClassUtils.isAssignable(domainType, targetType)) { + return fields; + } + + ProjectionInformation projectionInformation = projectionFactory.getProjectionInformation(targetType); + + if (projectionInformation.isClosed()) { + projectionInformation.getInputProperties().forEach(it -> fields.append(it.getName(), 1)); + } + + return fields; } protected CreateCollectionOptions convertToCreateCollectionOptions(CollectionOptions collectionOptions) { @@ -2471,6 +2510,7 @@ public class ReactiveMongoTemplate implements ReactiveMongoOperations, Applicati } public T doWith(Document object) { + if (null != object) { maybeEmitEvent(new AfterLoadEvent(object, type, collectionName)); } @@ -2482,6 +2522,47 @@ public class ReactiveMongoTemplate implements ReactiveMongoOperations, Applicati } } + /** + * {@link MongoTemplate.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 + * {@litera interface}. + * + * @param + * @param + * @author Christoph Strobl + * @since 2.0 + */ + @RequiredArgsConstructor + private class ProjectingReadCallback implements DocumentCallback { + + private final @NonNull EntityReader reader; + private final @NonNull Class entityType; + private final @NonNull Class targetType; + private final @NonNull String collectionName; + + public T doWith(Document object) { + + if (object == null) { + return null; + } + + Class typeToRead = targetType.isInterface() || targetType.isAssignableFrom(entityType) ? entityType + : targetType; + + if (null != object) { + maybeEmitEvent(new AfterLoadEvent<>(object, typeToRead, collectionName)); + } + + Object source = reader.read(typeToRead, object); + Object result = targetType.isInterface() ? projectionFactory.createProjection(targetType, source) : source; + + if (null != source) { + maybeEmitEvent(new AfterConvertEvent<>(object, result, collectionName)); + } + return (T) result; + } + } + /** * {@link DocumentCallback} that assumes a {@link GeoResult} to be created, delegates actual content unmarshalling to * a delegate and creates a {@link GeoResult} from the result. 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 ad7d47fae..d4272f407 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 @@ -25,6 +25,7 @@ import reactor.test.StepVerifier; import org.junit.Before; import org.junit.Test; +import org.springframework.beans.factory.annotation.Value; import org.springframework.dao.IncorrectResultSizeDataAccessException; import org.springframework.data.annotation.Id; import org.springframework.data.geo.Point; @@ -41,6 +42,7 @@ import com.mongodb.reactivestreams.client.MongoClients; * Integration tests for {@link ReactiveFindOperationSupport}. * * @author Mark Paluch + * @author Christoph Strobl */ public class ReactiveFindOperationSupportTests { @@ -138,6 +140,29 @@ public class ReactiveFindOperationSupportTests { .consumeNextWith(it -> assertThat(it).isInstanceOf(Jedi.class)).verifyComplete(); } + @Test // DATAMONGO-1719 + public void findAllByWithClosedInterfaceProjection() { + + StepVerifier.create( + template.query(Person.class).as(PersonProjection.class).matching(query(where("firstname").is("luke"))).all()) + .consumeNextWith(it -> { + + assertThat(it).isInstanceOf(PersonProjection.class); + assertThat(it.getFirstname()).isEqualTo("luke"); + }).verifyComplete(); + } + + @Test // DATAMONGO-1719 + public void findAllByWithOpenInterfaceProjection() { + + StepVerifier.create(template.query(Person.class).as(PersonSpELProjection.class) + .matching(query(where("firstname").is("luke"))).all()).consumeNextWith(it -> { + + assertThat(it).isInstanceOf(PersonSpELProjection.class); + assertThat(it.getName()).isEqualTo("luke"); + }).verifyComplete(); + } + @Test // DATAMONGO-1719 public void findBy() { @@ -197,6 +222,48 @@ public class ReactiveFindOperationSupportTests { }).expectNextCount(1).verifyComplete(); } + @Test // DATAMONGO-1719 + public void findAllNearByReturningGeoResultContentAsClosedInterfaceProjection() { + + 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); + + StepVerifier.create(template.query(Planet.class).as(PlanetProjection.class) + .near(NearQuery.near(-73.9667, 40.78).spherical(true)).all()).consumeNextWith(it -> { + + assertThat(it.getDistance()).isNotNull(); + assertThat(it.getContent()).isInstanceOf(PlanetProjection.class); + assertThat(it.getContent().getName()).isEqualTo("alderan"); + }).expectNextCount(1).verifyComplete(); + } + + @Test // DATAMONGO-1719 + public void findAllNearByReturningGeoResultContentAsOpenInterfaceProjection() { + + 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); + + StepVerifier.create(template.query(Planet.class).as(PlanetSpELProjection.class) + .near(NearQuery.near(-73.9667, 40.78).spherical(true)).all()).consumeNextWith(it -> { + + assertThat(it.getDistance()).isNotNull(); + assertThat(it.getContent()).isInstanceOf(PlanetSpELProjection.class); + assertThat(it.getContent().getId()).isEqualTo("alderan"); + }).expectNextCount(1).verifyComplete(); + } + @Test // DATAMONGO-1719 public void firstShouldReturnFirstEntryInCollection() { StepVerifier.create(template.query(Person.class).first()).expectNextCount(1).verifyComplete(); @@ -243,13 +310,25 @@ public class ReactiveFindOperationSupportTests { .expectNext(false).verifyComplete(); } + interface Contact {} + @Data @org.springframework.data.mongodb.core.mapping.Document(collection = STAR_WARS) - static class Person { + static class Person implements Contact { @Id String id; String firstname; } + interface PersonProjection { + String getFirstname(); + } + + public interface PersonSpELProjection { + + @Value("#{target.firstname}") + String getName(); + } + @Data static class Human { @Id String id; @@ -269,4 +348,14 @@ public class ReactiveFindOperationSupportTests { @Id String name; Point coordinates; } + + interface PlanetProjection { + String getName(); + } + + interface PlanetSpELProjection { + + @Value("#{target.name}") + String getId(); + } } 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 7c02ec988..646be0f30 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 @@ -21,6 +21,7 @@ import static org.mockito.Mockito.*; import static org.mockito.Mockito.any; import static org.springframework.data.mongodb.core.aggregation.Aggregation.*; +import lombok.Data; import reactor.core.publisher.Mono; import org.bson.Document; @@ -33,13 +34,16 @@ import org.mockito.Mock; import org.mockito.Mockito; import org.mockito.junit.MockitoJUnitRunner; import org.reactivestreams.Publisher; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.annotation.Id; import org.springframework.data.mongodb.core.MongoTemplateUnitTests.AutogenerateableId; import org.springframework.data.mongodb.core.ReactiveMongoTemplate.NoOpDbRefResolver; import org.springframework.data.mongodb.core.aggregation.Aggregation; -import org.springframework.data.mongodb.core.query.Collation; import org.springframework.data.mongodb.core.convert.MappingMongoConverter; +import org.springframework.data.mongodb.core.mapping.Field; import org.springframework.data.mongodb.core.mapping.MongoMappingContext; import org.springframework.data.mongodb.core.query.BasicQuery; +import org.springframework.data.mongodb.core.query.Collation; import org.springframework.data.mongodb.core.query.NearQuery; import org.springframework.data.mongodb.core.query.Update; import org.springframework.test.util.ReflectionTestUtils; @@ -257,4 +261,91 @@ public class ReactiveMongoTemplateUnitTests { assertThat(cmd.getValue().get("collation", Document.class), equalTo(new Document("locale", "fr"))); } + @Test // DATAMONGO-1719 + public void appliesFieldsWhenInterfaceProjectionIsClosedAndQueryDoesNotDefineFields() { + + template.doFind("star-wars", new Document(), new Document(), Person.class, PersonProjection.class, null) + .subscribe(); + + verify(findPublisher).projection(eq(new Document("firstname", 1))); + } + + @Test // DATAMONGO-1719 + public void doesNotApplyFieldsWhenInterfaceProjectionIsClosedAndQueryDefinesFields() { + + template.doFind("star-wars", new Document(), new Document("bar", 1), Person.class, PersonProjection.class, null) + .subscribe(); + + verify(findPublisher).projection(eq(new Document("bar", 1))); + } + + @Test // DATAMONGO-1719 + public void doesNotApplyFieldsWhenInterfaceProjectionIsOpen() { + + template.doFind("star-wars", new Document(), new Document(), Person.class, PersonSpELProjection.class, null) + .subscribe(); + + verify(findPublisher, never()).projection(any()); + } + + @Test // DATAMONGO-1719 + public void doesNotApplyFieldsToDtoProjection() { + + template.doFind("star-wars", new Document(), new Document(), Person.class, Jedi.class, null).subscribe(); + + verify(findPublisher, never()).projection(any()); + } + + @Test // DATAMONGO-1719 + public void doesNotApplyFieldsToDtoProjectionWhenQueryDefinesFields() { + + template.doFind("star-wars", new Document(), new Document("bar", 1), Person.class, Jedi.class, null).subscribe(); + + verify(findPublisher).projection(eq(new Document("bar", 1))); + } + + @Test // DATAMONGO-1719 + public void doesNotApplyFieldsWhenTargetIsNotAProjection() { + + template.doFind("star-wars", new Document(), new Document(), Person.class, Person.class, null).subscribe(); + + verify(findPublisher, never()).projection(any()); + } + + @Test // DATAMONGO-1719 + public void doesNotApplyFieldsWhenTargetExtendsDomainType() { + + template.doFind("star-wars", new Document(), new Document(), Person.class, PersonExtended.class, null).subscribe(); + + verify(findPublisher, never()).projection(any()); + } + + @Data + @org.springframework.data.mongodb.core.mapping.Document(collection = "star-wars") + static class Person { + + @Id String id; + String firstname; + } + + static class PersonExtended extends Person { + + String lastname; + } + + interface PersonProjection { + String getFirstname(); + } + + public interface PersonSpELProjection { + + @Value("#{target.firstname}") + String getName(); + } + + @Data + static class Jedi { + + @Field("firstname") String name; + } }