DATAMONGO-1733 - Added support for projections on FluentMongoOperations.

Interfaces based projections handed to queries built using the FluentMongoOperations APIs now get projected as expected and also apply querying optimizations so that only fields needed in the projection are read in the first place.

Original pull request: #486.
This commit is contained in:
Christoph Strobl
2017-07-10 10:51:05 +02:00
committed by Oliver Gierke
parent 7258cb8d1d
commit 2230b51a79
4 changed files with 375 additions and 36 deletions

View File

@@ -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<String> 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<Document> cursor = new QueryCursorPreparer(query, entityType)
.prepare(collection.find(mappedQuery).projection(mappedFields));
return new CloseableIterableCursorAdapter<T>(cursor, exceptionTranslator,
new ReadDocumentCallback<T>(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<GeoResult<T>> callback = new GeoNearResultDocumentCallback<T>(
new ReadDocumentCallback<T>(mongoConverter, returnType, collectionName), near.getMetric());
new ProjectingReadCallback<>(mongoConverter, domainType, returnType, collectionName), near.getMetric());
List<GeoResult<T>> result = new ArrayList<GeoResult<T>>(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<T>(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 <S>
* @param <T>
* @since 2.0
*/
class ProjectingReadCallback<S, T> implements DocumentCallback<T> {
private final Class<S> entityType;
private final Class<T> targetType;
private final String collectionName;
private final EntityReader<Object, Bson> reader;
ProjectingReadCallback(EntityReader<Object, Bson> reader, Class<S> entityType, Class<T> 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<T> extends ReadDocumentCallback<T> {
public UnwrapAndReadDocumentCallback(EntityReader<? super T, Bson> reader, Class<T> type, String collectionName) {

View File

@@ -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<PersonProjection> 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<PersonSpELProjection> 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<Planet> 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<Human> results = template.query(Object.class).inCollection(STAR_WARS).as(Human.class)
GeoResults<Human> 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<PlanetProjection> 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<PlanetSpELProjection> 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);
}
}

View File

@@ -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<Document> 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;

View File

@@ -1425,6 +1425,55 @@ AggregationResults<TagCount> 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<SWCharacter> 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<Jedi> 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 <<projections>> 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<Jedi> 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]