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 3037fe06b..d86671b8f 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 @@ -22,6 +22,7 @@ import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.RequiredArgsConstructor; +import java.beans.PropertyDescriptor; import java.io.IOException; import java.util.ArrayList; import java.util.Collection; @@ -112,11 +113,14 @@ 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.ProjectionInformation; +import org.springframework.data.projection.SpelAwareProxyProjectionFactory; import org.springframework.data.util.CloseableIterator; import org.springframework.data.util.Optionals; import org.springframework.data.util.Pair; import org.springframework.jca.cci.core.ConnectionCallback; import org.springframework.util.Assert; +import org.springframework.util.ClassUtils; import org.springframework.util.CollectionUtils; import org.springframework.util.ObjectUtils; import org.springframework.util.ResourceUtils; @@ -176,6 +180,7 @@ public class MongoTemplate implements MongoOperations, ApplicationContextAware, private static final String ID_FIELD = "_id"; private static final WriteResultChecking DEFAULT_WRITE_RESULT_CHECKING = WriteResultChecking.NONE; private static final Collection ITERABLE_CLASSES; + public static final SpelAwareProxyProjectionFactory PROJECTION_FACTORY = new SpelAwareProxyProjectionFactory(); static { @@ -372,14 +377,14 @@ public class MongoTemplate implements MongoOperations, ApplicationContextAware, MongoPersistentEntity persistentEntity = mappingContext.getRequiredPersistentEntity(entityType); - Document mappedFields = queryMapper.getMappedFields(query.getFieldsObject(), persistentEntity); + Document mappedFields = getMappedFieldsObject(query.getFieldsObject(), persistentEntity, returnType); Document mappedQuery = queryMapper.getMappedObject(query.getQueryObject(), persistentEntity); FindIterable cursor = new QueryCursorPreparer(query, entityType) .prepare(collection.find(mappedQuery).projection(mappedFields)); return new CloseableIterableCursorAdapter(cursor, exceptionTranslator, - new ReadDocumentCallback(mongoConverter, returnType, collectionName)); + new ProjectingReadCallback<>(mongoConverter, entityType, returnType, collectionName)); } }); } @@ -694,7 +699,7 @@ public class MongoTemplate implements MongoOperations, ApplicationContextAware, results = results == null ? Collections.emptyList() : results; DocumentCallback> callback = new GeoNearResultDocumentCallback( - new ReadDocumentCallback(mongoConverter, returnType, collectionName), near.getMetric()); + new ProjectingReadCallback<>(mongoConverter, domainType, returnType, collectionName), near.getMetric()); List> result = new ArrayList>(results.size()); int index = 0; @@ -2037,7 +2042,7 @@ public class MongoTemplate implements MongoOperations, ApplicationContextAware, 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()) { @@ -2046,7 +2051,7 @@ public class MongoTemplate implements MongoOperations, ApplicationContextAware, } return executeFindMultiInternal(new FindCallback(mappedQuery, mappedFields), preparer, - new ReadDocumentCallback(mongoConverter, targetClass, collectionName), collectionName); + new ProjectingReadCallback<>(mongoConverter, sourceClass, targetClass, collectionName), collectionName); } protected Document convertToDocument(CollectionOptions collectionOptions) { @@ -2331,6 +2336,37 @@ public class MongoTemplate implements MongoOperations, ApplicationContextAware, return queryMapper.getMappedSort(query.getSortObject(), mappingContext.getPersistentEntity(type)); } + 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 = PROJECTION_FACTORY.getProjectionInformation(targetType); + if (projectionInformation.isClosed()) { + + for (PropertyDescriptor descriptor : projectionInformation.getInputProperties()) { + fields.append(descriptor.getName(), 1); + } + } + + return fields; + } + /** * Tries to convert the given {@link RuntimeException} into a {@link DataAccessException} but returns the original * exception if the conversation failed. Thus allows safe re-throwing of the return value. @@ -2568,6 +2604,57 @@ public class MongoTemplate implements MongoOperations, ApplicationContextAware, } } + /** + * {@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 {@litera interface}. + * + * @param + * @param + * @since 2.0 + */ + class ProjectingReadCallback implements DocumentCallback { + + private final Class entityType; + private final Class targetType; + private final String collectionName; + private final EntityReader reader; + + ProjectingReadCallback(EntityReader reader, Class entityType, Class targetType, + String collectionName) { + + this.reader = reader; + this.entityType = entityType; + this.targetType = targetType; + this.collectionName = collectionName; + } + + public T doWith(Document object) { + + if (null != object) { + maybeEmitEvent(new AfterLoadEvent<>(object, targetType, collectionName)); + } + + T target = doRead(object, entityType, targetType); + + if (null != target) { + maybeEmitEvent(new AfterConvertEvent<>(object, target, collectionName)); + } + + return target; + } + + private T doRead(Document source, Class entityType, Class targetType) { + + if (targetType != entityType && targetType.isInterface()) { + + S target = (S) reader.read(entityType, source); + return (T) PROJECTION_FACTORY.createProjection(targetType, target); + } + + return (T) reader.read(targetType, source); + } + } + class UnwrapAndReadDocumentCallback extends ReadDocumentCallback { public UnwrapAndReadDocumentCallback(EntityReader reader, Class type, String collectionName) { 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 369a753b7..889e4054b 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 @@ -26,10 +26,12 @@ import java.util.stream.Stream; 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.GeoResults; import org.springframework.data.geo.Point; +import org.springframework.data.mongodb.core.ExecutableFindOperation.TerminatingFindOperation; import org.springframework.data.mongodb.core.index.GeoSpatialIndexType; import org.springframework.data.mongodb.core.index.GeospatialIndex; import org.springframework.data.mongodb.core.mapping.Field; @@ -47,27 +49,27 @@ import com.mongodb.MongoClient; public class ExecutableFindOperationSupportTests { private static final String STAR_WARS = "star-wars"; + private static final String STAR_WARS_PLANETS = "star-wars-universe"; MongoTemplate template; Person han; Person luke; + Planet alderan; + Planet dantooine; + @Before public void setUp() { template = new MongoTemplate(new SimpleMongoDbFactory(new MongoClient(), "ExecutableFindOperationSupportTests")); template.dropCollection(STAR_WARS); + template.dropCollection(STAR_WARS_PLANETS); - han = new Person(); - han.firstname = "han"; - han.id = "id-1"; + template.indexOps(Planet.class).ensureIndex( + new GeospatialIndex("coordinates").typed(GeoSpatialIndexType.GEO_2DSPHERE).named("planet-coordinate-idx")); - luke = new Person(); - luke.firstname = "luke"; - luke.id = "id-2"; - - template.save(han); - template.save(luke); + initPersons(); + initPlanets(); } @Test(expected = IllegalArgumentException.class) // DATAMONGO-1563 @@ -100,6 +102,13 @@ public class ExecutableFindOperationSupportTests { assertThat(template.query(Person.class).as(Jedi.class).all()).hasOnlyElementsOfType(Jedi.class).hasSize(2); } + @Test // DATAMONGO-1733 + public void findByReturningAllValuesAsClosedInterfaceProjection() { + + assertThat(template.query(Person.class).as(PersonProjection.class).all()) + .hasOnlyElementsOfTypes(PersonProjection.class); + } + @Test // DATAMONGO-1563 public void findAllBy() { @@ -166,6 +175,26 @@ public class ExecutableFindOperationSupportTests { .isIn(han, luke); } + @Test // DATAMONGO-1733 + public void findByReturningFirstValueAsClosedInterfaceProjection() { + + PersonProjection result = template.query(Person.class).as(PersonProjection.class) + .matching(query(where("firstname").is("han"))).firstValue(); + + assertThat(result).isInstanceOf(PersonProjection.class); + assertThat(result.getFirstname()).isEqualTo("han"); + } + + @Test // DATAMONGO-1733 + public void findByReturningFirstValueAsOpenInterfaceProjection() { + + PersonSpELProjection result = template.query(Person.class).as(PersonSpELProjection.class) + .matching(query(where("firstname").is("han"))).firstValue(); + + assertThat(result).isInstanceOf(PersonSpELProjection.class); + assertThat(result.getName()).isEqualTo("han"); + } + @Test // DATAMONGO-1563 public void streamAll() { @@ -190,6 +219,33 @@ public class ExecutableFindOperationSupportTests { } } + @Test // DATAMONGO-1733 + public void streamAllReturningResultsAsClosedInterfaceProjection() { + + TerminatingFindOperation operation = template.query(Person.class).as(PersonProjection.class); + + assertThat(operation.stream()) // + .hasSize(2) // + .allSatisfy(it -> { + assertThat(it).isInstanceOf(PersonProjection.class); + assertThat(it.getFirstname()).isNotBlank(); + }); + } + + @Test // DATAMONGO-1733 + public void streamAllReturningResultsAsOpenInterfaceProjection() { + + TerminatingFindOperation operation = template.query(Person.class) + .as(PersonSpELProjection.class); + + assertThat(operation.stream()) // + .hasSize(2) // + .allSatisfy(it -> { + assertThat(it).isInstanceOf(PersonSpELProjection.class); + assertThat(it.getName()).isNotBlank(); + }); + } + @Test // DATAMONGO-1563 public void streamAllBy() { @@ -201,15 +257,6 @@ public class ExecutableFindOperationSupportTests { @Test // DATAMONGO-1563 public void findAllNearBy() { - template.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)); - - template.save(alderan); - template.save(dantooine); - GeoResults results = template.query(Planet.class).near(NearQuery.near(-73.9667, 40.78).spherical(true)) .all(); assertThat(results.getContent()).hasSize(2); @@ -219,16 +266,7 @@ public class ExecutableFindOperationSupportTests { @Test // DATAMONGO-1563 public void findAllNearByWithCollectionAndProjection() { - template.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)); - - template.save(alderan); - template.save(dantooine); - - GeoResults results = template.query(Object.class).inCollection(STAR_WARS).as(Human.class) + GeoResults results = template.query(Object.class).inCollection(STAR_WARS_PLANETS).as(Human.class) .near(NearQuery.near(-73.9667, 40.78).spherical(true)).all(); assertThat(results.getContent()).hasSize(2); @@ -237,6 +275,32 @@ public class ExecutableFindOperationSupportTests { assertThat(results.getContent().get(0).getContent().getId()).isEqualTo("alderan"); } + @Test // DATAMONGO-1733 + public void findAllNearByReturningGeoResultContentAsClosedInterfaceProjection() { + + GeoResults results = template.query(Planet.class).as(PlanetProjection.class) + .near(NearQuery.near(-73.9667, 40.78).spherical(true)).all(); + + assertThat(results.getContent()).allSatisfy(it -> { + + assertThat(it.getContent()).isInstanceOf(PlanetProjection.class); + assertThat(it.getContent().getName()).isNotBlank(); + }); + } + + @Test // DATAMONGO-1733 + public void findAllNearByReturningGeoResultContentAsOpenInterfaceProjection() { + + GeoResults results = template.query(Planet.class).as(PlanetSpELProjection.class) + .near(NearQuery.near(-73.9667, 40.78).spherical(true)).all(); + + assertThat(results.getContent()).allSatisfy(it -> { + + assertThat(it.getContent()).isInstanceOf(PlanetSpELProjection.class); + assertThat(it.getContent().getId()).isNotBlank(); + }); + } + @Test // DATAMONGO-1728 public void firstShouldReturnFirstEntryInCollection() { assertThat(template.query(Person.class).first()).isNotEmpty(); @@ -286,6 +350,16 @@ public class ExecutableFindOperationSupportTests { String firstname; } + interface PersonProjection { + String getFirstname(); + } + + public interface PersonSpELProjection { + + @Value("#{target.firstname}") + String getName(); + } + @Data static class Human { @Id String id; @@ -299,10 +373,43 @@ public class ExecutableFindOperationSupportTests { @Data @AllArgsConstructor - @org.springframework.data.mongodb.core.mapping.Document(collection = STAR_WARS) + @org.springframework.data.mongodb.core.mapping.Document(collection = STAR_WARS_PLANETS) static class Planet { @Id String name; Point coordinates; } + + interface PlanetProjection { + String getName(); + } + + interface PlanetSpELProjection { + + @Value("#{target.name}") + String getId(); + } + + private void initPersons() { + + han = new Person(); + han.firstname = "han"; + han.id = "id-1"; + + luke = new Person(); + luke.firstname = "luke"; + luke.id = "id-2"; + + template.save(han); + template.save(luke); + } + + private void initPlanets() { + + alderan = new Planet("alderan", new Point(-73.9836, 40.7538)); + dantooine = new Planet("dantooine", new Point(-73.9928, 40.7193)); + + template.save(alderan); + template.save(dantooine); + } } 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 f4de63154..dedbbb056 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 @@ -22,6 +22,8 @@ import static org.mockito.Mockito.any; import static org.springframework.data.mongodb.core.aggregation.Aggregation.*; import static org.springframework.data.mongodb.test.util.IsBsonObject.*; +import lombok.Data; + import java.math.BigInteger; import java.util.Collections; import java.util.List; @@ -44,6 +46,7 @@ import org.mockito.Matchers; import org.mockito.Mock; import org.mockito.Mockito; import org.mockito.junit.MockitoJUnitRunner; +import org.springframework.beans.factory.annotation.Value; import org.springframework.context.support.GenericApplicationContext; import org.springframework.core.convert.converter.Converter; import org.springframework.dao.DataAccessException; @@ -61,6 +64,7 @@ import org.springframework.data.mongodb.core.convert.MappingMongoConverter; import org.springframework.data.mongodb.core.convert.MongoCustomConversions; import org.springframework.data.mongodb.core.convert.QueryMapper; import org.springframework.data.mongodb.core.index.MongoPersistentEntityIndexCreator; +import org.springframework.data.mongodb.core.mapping.Field; import org.springframework.data.mongodb.core.mapping.MongoMappingContext; import org.springframework.data.mongodb.core.mapping.event.AbstractMongoEventListener; import org.springframework.data.mongodb.core.mapping.event.BeforeConvertEvent; @@ -782,12 +786,70 @@ public class MongoTemplateUnitTests extends MongoOperationsUnitTests { public void groupShouldUseCollationWhenPresent() { commandResultDocument.append("retval", Collections.emptySet()); - template.group("collection-1", GroupBy.key("id").reduceFunction("bar").collation(Collation.of("fr")), AutogenerateableId.class); + template.group("collection-1", GroupBy.key("id").reduceFunction("bar").collation(Collation.of("fr")), + AutogenerateableId.class); ArgumentCaptor cmd = ArgumentCaptor.forClass(Document.class); verify(db).runCommand(cmd.capture(), Mockito.any(Class.class)); - assertThat(cmd.getValue().get("group", Document.class).get("collation", Document.class), equalTo(new Document("locale", "fr"))); + assertThat(cmd.getValue().get("group", Document.class).get("collation", Document.class), + equalTo(new Document("locale", "fr"))); + } + + @Test // DATAMONGO-1733 + public void appliesFieldsWhenInterfaceProjectionIsClosedAndQueryDoesNotDefineFields() { + + template.doFind("star-wars", new Document(), new Document(), Person.class, PersonProjection.class, null); + + verify(findIterable).projection(eq(new Document("firstname", 1))); + } + + @Test // DATAMONGO-1733 + public void doesNotApplyFieldsWhenInterfaceProjectionIsClosedAndQueryDefinesFields() { + + template.doFind("star-wars", new Document(), new Document("bar", 1), Person.class, PersonProjection.class, null); + + verify(findIterable).projection(eq(new Document("bar", 1))); + } + + @Test // DATAMONGO-1733 + public void doesNotApplyFieldsWhenInterfaceProjectionIsOpen() { + + template.doFind("star-wars", new Document(), new Document(), Person.class, PersonSpELProjection.class, null); + + verify(findIterable, never()).projection(any()); + } + + @Test // DATAMONGO-1733 + public void doesNotApplyFieldsToDtoProjection() { + + template.doFind("star-wars", new Document(), new Document(), Person.class, Jedi.class, null); + + verify(findIterable, never()).projection(any()); + } + + @Test // DATAMONGO-1733 + public void doesNotApplyFieldsToDtoProjectionWhenQueryDefinesFields() { + + template.doFind("star-wars", new Document(), new Document("bar", 1), Person.class, Jedi.class, null); + + verify(findIterable).projection(eq(new Document("bar", 1))); + } + + @Test // DATAMONGO-1733 + public void doesNotApplyFieldsWhenTargetIsNotAProjection() { + + template.doFind("star-wars", new Document(), new Document(), Person.class, Person.class, null); + + verify(findIterable, never()).projection(any()); + } + + @Test // DATAMONGO-1733 + public void doesNotApplyFieldsWhenTargetExtendsDomainType() { + + template.doFind("star-wars", new Document(), new Document(), Person.class, PersonExtended.class, null); + + verify(findIterable, never()).projection(any()); } class AutogenerateableId { @@ -819,6 +881,40 @@ public class MongoTemplateUnitTests extends MongoOperationsUnitTests { } } + @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 Human { + @Id String id; + } + + @Data + static class Jedi { + + @Field("firstname") String name; + } + class Wrapper { AutogenerateableId foo; diff --git a/src/main/asciidoc/reference/mongodb.adoc b/src/main/asciidoc/reference/mongodb.adoc index 5cd978ad5..2b14aa85c 100644 --- a/src/main/asciidoc/reference/mongodb.adoc +++ b/src/main/asciidoc/reference/mongodb.adoc @@ -1425,6 +1425,55 @@ AggregationResults results = template.aggregate(aggregation, "tags", T WARNING: Indexes are only used if the collation used for the operation and the index collation matches. +[[mongo.query.fluent-template-api]] +=== Fluent Template API + +The `MongoOperations` interface is one of the central components when it comes to more low level interaction with MongoDB. It offers a wide range of methods covering needs from collection / index creation and CRUD operations to more advanced functionality like map-reduce and aggregations. +One can find multiple overloads for each and every method. Most of them just cover optional / nullable parts of the API. + +`FluentMongoOperations` provide a more narrow interface for common methods of `MongoOperations` providing a more readable, fluent API. +The entry points `insert(…)`, `find(…)`, `update(…)`, etc. follow a natural naming schema based on the operation to execute. Moving on from the entry point the API is designed to only offer context dependent methods guiding towards a terminating method that invokes the actual `MongoOperations` counterpart. + +==== +[source,java] +---- +List all = ops.find(SWCharacter.class) + .inCollection("star-wars") <1> + .all(); +---- +<1> Skip this step if `SWCharacter` defines the collection via `@Document` or if using the class name as the collection name is just fine. +==== + +Sometimes a collection in MongoDB holds entities of different types. Like a `Jedi` within a collection of `SWCharacters`. +To use different types for `Query` and return value mapping one can use `as(Class targetType)` map results differently. + +==== +[source,java] +---- +List all = ops.find(SWCharacter.class) <1> + .as(Jedi.class) <2> + .matching(query(where("jedi").is(true))) + .all(); +---- +<1> The query fields are mapped against the `SWCharacter` type. +<2> Resulting documents are mapped into `Jedi`. +==== + +TIP: It is possible to directly apply <> to resulting documents by providing just the `interface` type via `as(Class)`. + +Switching between retrieving a single entity, multiple ones as `List` or `Stream` like is done via the terminating methods `first()`, `one()`, `all()` or `stream()`. + +When writing a geo-spatial query via `near(NearQuery)` the number of terminating methods is altered to just the ones valid for executing a `geoNear` command in MongoDB fetching entities as `GeoResult` within `GeoResults`. + +==== +[source,java] +---- +GeoResults results = mongoOps.query(SWCharacter.class) + .as(Jedi.class) + .near(alderaan) // NearQuery.near(-73.9667, 40.78).maxDis… + .all(); +---- +==== include::../{spring-data-commons-docs}/query-by-example.adoc[leveloffset=+1] include::query-by-example.adoc[leveloffset=+1]