DATAMONGO-1783 - Apply query limit and offset to CountOptions for MongoOperations.count.

We now pass on the range defined by Query.skip and Query.limit to MongoDB. This allows to count documents within a certain range so that it is possible to find the number of matches within eg. the first 10,000 documents.

Original pull request: #650.
This commit is contained in:
Christoph Strobl
2019-02-26 10:35:04 +01:00
committed by Mark Paluch
parent 38d8529b81
commit ed9173c384
15 changed files with 307 additions and 12 deletions

View File

@@ -1094,6 +1094,11 @@ public interface MongoOperations extends FluentMongoOperations {
/**
* Returns the number of documents for the given {@link Query} by querying the collection of the given entity class.
* <br />
* <strong>NOTE:</strong> Query {@link Query#getSkip() offset} and {@link Query#getLimit() limit} can have direct
* influence on the resulting number of documents found as those values are passed on to the server and potentially
* limit the range and order within which the server performs the count operation. Use an {@literal unpaged} query to
* count all matches.
*
* @param query the {@link Query} class that specifies the criteria used to find documents. Must not be
* {@literal null}.
@@ -1105,7 +1110,11 @@ public interface MongoOperations extends FluentMongoOperations {
/**
* Returns the number of documents for the given {@link Query} querying the given collection. The given {@link Query}
* must solely consist of document field references as we lack type information to map potential property references
* onto document fields. Use {@link #count(Query, Class, String)} to get full type specific support.
* onto document fields. Use {@link #count(Query, Class, String)} to get full type specific support. <br />
* <strong>NOTE:</strong> Query {@link Query#getSkip() offset} and {@link Query#getLimit() limit} can have direct
* influence on the resulting number of documents found as those values are passed on to the server and potentially
* limit the range and order within which the server performs the count operation. Use an {@literal unpaged} query to
* count all matches.
*
* @param query the {@link Query} class that specifies the criteria used to find documents.
* @param collectionName must not be {@literal null} or empty.
@@ -1116,7 +1125,11 @@ public interface MongoOperations extends FluentMongoOperations {
/**
* Returns the number of documents for the given {@link Query} by querying the given collection using the given entity
* class to map the given {@link Query}.
* class to map the given {@link Query}. <br />
* <strong>NOTE:</strong> Query {@link Query#getSkip() offset} and {@link Query#getLimit() limit} can have direct
* influence on the resulting number of documents found as those values are passed on to the server and potentially
* limit the range and order within which the server performs the count operation. Use an {@literal unpaged} query to
* count all matches.
*
* @param query the {@link Query} class that specifies the criteria used to find documents. Must not be
* {@literal null}.

View File

@@ -1145,6 +1145,13 @@ public class MongoTemplate implements MongoOperations, ApplicationContextAware,
CountOptions options = new CountOptions();
query.getCollation().map(Collation::toMongoCollation).ifPresent(options::collation);
if (query.getLimit() > 0) {
options.limit(query.getLimit());
}
if (query.getSkip() > 0) {
options.skip((int) query.getSkip());
}
Document document = queryMapper.getMappedObject(query.getQueryObject(),
Optional.ofNullable(entityClass).map(it -> mappingContext.getPersistentEntity(entityClass)));

View File

@@ -880,6 +880,11 @@ public interface ReactiveMongoOperations extends ReactiveFluentMongoOperations {
/**
* Returns the number of documents for the given {@link Query} by querying the collection of the given entity class.
* <br />
* <strong>NOTE:</strong> Query {@link Query#getSkip() offset} and {@link Query#getLimit() limit} can have direct
* influence on the resulting number of documents found as those values are passed on to the server and potentially
* limit the range and order within which the server performs the count operation. Use an {@literal unpaged} query to
* count all matches.
*
* @param query the {@link Query} class that specifies the criteria used to find documents. Must not be
* {@literal null}.
@@ -891,7 +896,11 @@ public interface ReactiveMongoOperations extends ReactiveFluentMongoOperations {
/**
* Returns the number of documents for the given {@link Query} querying the given collection. The given {@link Query}
* must solely consist of document field references as we lack type information to map potential property references
* onto document fields. Use {@link #count(Query, Class, String)} to get full type specific support.
* onto document fields. Use {@link #count(Query, Class, String)} to get full type specific support. <br />
* <strong>NOTE:</strong> Query {@link Query#getSkip() offset} and {@link Query#getLimit() limit} can have direct
* influence on the resulting number of documents found as those values are passed on to the server and potentially
* limit the range and order within which the server performs the count operation. Use an {@literal unpaged} query to
* count all matches.
*
* @param query the {@link Query} class that specifies the criteria used to find documents.
* @param collectionName must not be {@literal null} or empty.
@@ -902,7 +911,11 @@ public interface ReactiveMongoOperations extends ReactiveFluentMongoOperations {
/**
* Returns the number of documents for the given {@link Query} by querying the given collection using the given entity
* class to map the given {@link Query}.
* class to map the given {@link Query}. <br />
* <strong>NOTE:</strong> Query {@link Query#getSkip() offset} and {@link Query#getLimit() limit} can have direct
* influence on the resulting number of documents found as those values are passed on to the server and potentially
* limit the range and order within which the server performs the count operation. Use an {@literal unpaged} query to
* count all matches.
*
* @param query the {@link Query} class that specifies the criteria used to find documents. Must not be
* {@literal null}.

View File

@@ -1171,6 +1171,13 @@ public class ReactiveMongoTemplate implements ReactiveMongoOperations, Applicati
CountOptions options = new CountOptions();
if (query != null) {
query.getCollation().map(Collation::toMongoCollation).ifPresent(options::collation);
if (query.getLimit() > 0) {
options.limit(query.getLimit());
}
if (query.getSkip() > 0) {
options.skip((int) query.getSkip());
}
}
if (LOGGER.isDebugEnabled()) {

View File

@@ -212,7 +212,7 @@ public class Meta {
* @param key must not be {@literal null} or empty.
* @param value
*/
private void setValue(String key, @Nullable Object value) {
void setValue(String key, @Nullable Object value) {
Assert.hasText(key, "Meta key must not be 'null' or blank.");

View File

@@ -34,6 +34,7 @@ import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.domain.Sort.Order;
import org.springframework.data.mongodb.InvalidMongoDbApiUsageException;
import org.springframework.data.mongodb.util.BsonUtils;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
@@ -475,6 +476,59 @@ public class Query {
return new ArrayList<>(this.criteria.values());
}
/**
* Create an independent copy of the given {@link Query}. <br />
* The resulting {@link Query} will not be {@link Object#equals(Object) binary equal} to the given source but
* semantically equal in terms of creating the same result when executed.
*
* @param source The source {@link Query} to use a reference. Must not be {@literal null}.
* @return new {@link Query}.
* @since 2.2
*/
public static Query of(Query source) {
Assert.notNull(source, "Source must not be null!");
Document sourceFields = source.getFieldsObject();
Document sourceSort = source.getSortObject();
Document sourceQuery = source.getQueryObject();
Query target = new Query() {
@Override
public Document getFieldsObject() {
return BsonUtils.merge(sourceFields, super.getFieldsObject());
}
@Override
public Document getSortObject() {
return BsonUtils.merge(sourceSort, super.getSortObject());
}
@Override
public Document getQueryObject() {
return BsonUtils.merge(sourceQuery, super.getQueryObject());
}
};
target.criteria.putAll(source.criteria);
target.skip = source.skip;
target.limit = source.limit;
target.sort = Sort.unsorted().and(source.sort);
target.hint = source.hint;
target.collation = source.collation;
target.restrictedTypes.addAll(source.restrictedTypes);
if (source.getMeta() != null && source.getMeta().hasValues()) {
Meta meta = new Meta();
source.getMeta().values().forEach(it -> meta.setValue(it.getKey(), it.getValue()));
target.setMeta(meta);
}
return target;
}
/*
* (non-Javadoc)
* @see java.lang.Object#toString()

View File

@@ -92,6 +92,7 @@ interface MongoQueryExecution {
*
* @author Oliver Gierke
* @author Mark Paluch
* @author Christoph Strobl
*/
@RequiredArgsConstructor
final class PagedExecution implements MongoQueryExecution {
@@ -120,7 +121,7 @@ interface MongoQueryExecution {
return PageableExecutionUtils.getPage(matching.all(), pageable, () -> {
long count = matching.count();
long count = operation.matching(Query.of(query).skip(-1).limit(-1)).count();
return overallLimit != 0 ? Math.min(count, overallLimit) : count;
});
}

View File

@@ -147,7 +147,7 @@ abstract class QuerydslFetchableMongodbQuery<K, Q extends QuerydslFetchableMongo
*/
@Override
public long fetchCount() {
return find.matching(createQuery()).count();
return find.matching(Query.of(createQuery()).skip(-1).limit(-1)).count();
}
/**

View File

@@ -282,11 +282,11 @@ public class SimpleMongoRepository<T, ID> implements MongoRepository<T, ID> {
Assert.notNull(example, "Sample must not be null!");
Assert.notNull(pageable, "Pageable must not be null!");
Query q = new Query(new Criteria().alike(example)).with(pageable);
List<S> list = mongoOperations.find(q, example.getProbeType(), entityInformation.getCollectionName());
Query query = new Query(new Criteria().alike(example)).with(pageable);
List<S> list = mongoOperations.find(query, example.getProbeType(), entityInformation.getCollectionName());
return PageableExecutionUtils.getPage(list, pageable,
() -> mongoOperations.count(q, example.getProbeType(), entityInformation.getCollectionName()));
() -> mongoOperations.count(Query.of(query).limit(-1).skip(-1), example.getProbeType(), entityInformation.getCollectionName()));
}
/*

View File

@@ -15,6 +15,7 @@
*/
package org.springframework.data.mongodb.util;
import java.util.Arrays;
import java.util.Date;
import java.util.Map;
@@ -22,6 +23,7 @@ import org.bson.BsonValue;
import org.bson.Document;
import org.bson.conversions.Bson;
import org.springframework.lang.Nullable;
import org.springframework.util.ObjectUtils;
import com.mongodb.BasicDBObject;
import com.mongodb.DBObject;
@@ -104,4 +106,27 @@ public class BsonUtils {
return value;
}
}
/**
* Merge the given {@link Document documents} into on in the given order. Keys contained within multiple documents are
* overwritten by their follow ups.
*
* @param documents must not be {@literal null}. Can be empty.
* @return the document containing all key value pairs.
* @since 2.2
*/
public static Document merge(Document... documents) {
if (ObjectUtils.isEmpty(documents)) {
return new Document();
}
if (documents.length == 1) {
return documents[0];
}
Document target = new Document();
Arrays.asList(documents).forEach(target::putAll);
return target;
}
}

View File

@@ -1035,6 +1035,28 @@ public class MongoTemplateUnitTests extends MongoOperationsUnitTests {
is(equalTo(new Document("version", 11).append("_class", VersionedEntity.class.getName()))));
}
@Test // DATAMONGO-1783
public void usesQueryOffsetForCountOperation() {
template.count(new BasicQuery("{}").skip(100), AutogenerateableId.class);
ArgumentCaptor<CountOptions> options = ArgumentCaptor.forClass(CountOptions.class);
verify(collection).count(any(), options.capture());
assertThat(options.getValue().getSkip(), is(equalTo(100)));
}
@Test // DATAMONGO-1783
public void usesQueryLimitForCountOperation() {
template.count(new BasicQuery("{}").limit(10), AutogenerateableId.class);
ArgumentCaptor<CountOptions> options = ArgumentCaptor.forClass(CountOptions.class);
verify(collection).count(any(), options.capture());
assertThat(options.getValue().getLimit(), is(equalTo(10)));
}
@Test // DATAMONGO-2215
public void updateShouldApplyArrayFilters() {

View File

@@ -55,6 +55,7 @@ import org.springframework.data.mongodb.core.query.Query;
import org.springframework.data.mongodb.core.query.Update;
import org.springframework.test.util.ReflectionTestUtils;
import com.mongodb.client.model.CountOptions;
import com.mongodb.client.model.DeleteOptions;
import com.mongodb.client.model.FindOneAndDeleteOptions;
import com.mongodb.client.model.FindOneAndUpdateOptions;
@@ -103,6 +104,7 @@ public class ReactiveMongoTemplateUnitTests {
when(collection.find(any(Document.class), any(Class.class))).thenReturn(findPublisher);
when(collection.aggregate(anyList())).thenReturn(aggregatePublisher);
when(collection.aggregate(anyList(), any(Class.class))).thenReturn(aggregatePublisher);
when(collection.count(any(), any(CountOptions.class))).thenReturn(Mono.just(0L));
when(collection.updateOne(any(), any(), any(UpdateOptions.class))).thenReturn(updatePublisher);
when(collection.findOneAndUpdate(any(), any(), any(FindOneAndUpdateOptions.class))).thenReturn(findAndUpdatePublisher);
when(findPublisher.projection(any())).thenReturn(findPublisher);
@@ -350,6 +352,28 @@ public class ReactiveMongoTemplateUnitTests {
verify(findPublisher, never()).projection(any());
}
@Test // DATAMONGO-1783
public void countShouldUseSkipFromQuery() {
template.count(new Query().skip(10), Person.class, "star-wars").subscribe();
ArgumentCaptor<CountOptions> options = ArgumentCaptor.forClass(CountOptions.class);
verify(collection).count(any(), options.capture());
assertThat(options.getValue().getSkip(), is(equalTo(10)));
}
@Test // DATAMONGO-1783
public void countShouldUseLimitFromQuery() {
template.count(new Query().limit(100), Person.class, "star-wars").subscribe();
ArgumentCaptor<CountOptions> options = ArgumentCaptor.forClass(CountOptions.class);
verify(collection).count(any(), options.capture());
assertThat(options.getValue().getLimit(), is(equalTo(100)));
}
@Test // DATAMONGO-2215
public void updateShouldApplyArrayFilters() {

View File

@@ -219,7 +219,135 @@ public class QueryTests {
query.addCriteria(where("value").is(EnumType.VAL_2));
}).withMessageContaining("second 'value' criteria")
.withMessageContaining("already contains '{ \"value\" : { \"$java\" : VAL_1 } }'");
}
@Test // DATAMONGO-1783
public void queryOfShouldCreateNewQueryWithEqualBehaviour() {
Query source = new Query();
source.addCriteria(where("This you must ken!").is(EnumType.VAL_1));
compareQueries(Query.of(source), source);
}
@Test // DATAMONGO-1783
public void clonedQueryShouldNotDependOnCriteriaFromSource() {
Query source = new Query();
source.addCriteria(where("From one make ten").is("and two let be."));
Query target = Query.of(source);
compareQueries(target, source);
source.addCriteria(where("Make even three").is("then rich you'll be."));
assertThat(target.getQueryObject()).isEqualTo(new Document("From one make ten", "and two let be."))
.isNotEqualTo(source.getQueryObject());
}
@Test // DATAMONGO-1783
public void clonedQueryShouldAppendCriteria() {
Query source = new Query();
source.addCriteria(where("Skip o'er the four!").is("From five and six"));
Query target = Query.of(source);
compareQueries(target, source);
target.addCriteria(where("the Witch's tricks").is("make seven and eight"));
assertThat(target.getQueryObject()).isEqualTo(
new Document("Skip o'er the four!", "From five and six").append("the Witch's tricks", "make seven and eight"));
}
@Test // DATAMONGO-1783
public void clonedQueryShouldNotDependOnCollationFromSource() {
Query source = new Query().collation(Collation.simple());
Query target = Query.of(source);
compareQueries(target, source);
source.collation(Collation.of("Tis finished straight"));
assertThat(target.getCollation()).contains(Collation.simple()).isNotEqualTo(source.getCollation());
}
@Test // DATAMONGO-1783
public void clonedQueryShouldNotDependOnSortFromSource() {
Query source = new Query().with(Sort.by("And nine is one"));
Query target = Query.of(source);
compareQueries(target, source);
source.with(Sort.by("And ten is none"));
assertThat(target.getSortObject()).isEqualTo(new Document("And nine is one", 1))
.isNotEqualTo(source.getSortObject());
}
@Test // DATAMONGO-1783
public void clonedQueryShouldNotDependOnFieldsFromSource() {
Query source = new Query();
source.fields().include("That is the witch's one-time-one!");
Query target = Query.of(source);
compareQueries(target, source);
source.fields().exclude("Goethe");
assertThat(target.getFieldsObject()).isEqualTo(new Document("That is the witch's one-time-one!", 1))
.isNotEqualTo(source.getFieldsObject());
}
@Test // DATAMONGO-1783
public void clonedQueryShouldNotDependOnMetaFromSource() {
Query source = new Query().maxTimeMsec(100);
Query target = Query.of(source);
compareQueries(target, source);
source.slaveOk();
Meta meta = new Meta();
meta.setMaxTimeMsec(100);
assertThat(target.getMeta()).isEqualTo(meta).isNotEqualTo(source.getMeta());
}
@Test // DATAMONGO-1783
public void clonedQueryShouldNotDependOnRestrictedTypesFromSource() {
Query source = new Query();
source.restrict(EnumType.class);
Query target = Query.of(source);
compareQueries(target, source);
source.restrict(Query.class);
assertThat(target.getRestrictedTypes()).containsExactly(EnumType.class).isNotEqualTo(source.getRestrictedTypes());
}
@Test // DATAMONGO-1783
public void clonedQueryShouldApplyRestrictionsFromBasicQuery() {
BasicQuery source = new BasicQuery("{ 'foo' : 'bar'}");
Query target = Query.of(source);
compareQueries(target, source);
target.addCriteria(where("one").is("10"));
assertThat(target.getQueryObject()).isEqualTo(new Document("foo", "bar").append("one", "10"))
.isNotEqualTo(source.getQueryObject());
}
private void compareQueries(Query actual, Query expected) {
assertThat(actual.getCollation()).isEqualTo(expected.getCollation());
assertThat(actual.getSortObject()).isEqualTo(expected.getSortObject());
assertThat(actual.getFieldsObject()).isEqualTo(expected.getFieldsObject());
assertThat(actual.getQueryObject()).isEqualTo(expected.getQueryObject());
assertThat(actual.getHint()).isEqualTo(expected.getHint());
assertThat(actual.getLimit()).isEqualTo(expected.getLimit());
assertThat(actual.getSkip()).isEqualTo(expected.getSkip());
assertThat(actual.getMeta()).isEqualTo(expected.getMeta());
assertThat(actual.getRestrictedTypes()).isEqualTo(expected.getRestrictedTypes());
}
enum EnumType {

View File

@@ -174,12 +174,12 @@ public class AbstractMongoQueryUnitTests {
ArgumentCaptor<Query> captor = ArgumentCaptor.forClass(Query.class);
verify(executableFind).as(Person.class);
verify(withQueryMock).matching(captor.capture());
verify(withQueryMock, atLeast(1)).matching(captor.capture());
assertThat(captor.getValue().getMeta().getComment(), is("comment"));
}
@Test // DATAMONGO-957
@Test // DATAMONGO-957, DATAMONGO-1783
public void metadataShouldBeAddedToStringBasedQueryCorrectly() {
MongoQueryFake query = createQueryForMethod("findByAnnotatedQuery", String.class, Pageable.class);

View File

@@ -10,6 +10,7 @@
* Template API delete by entity considers the version property in delete queries.
* Repository deletes now throw `OptimisticLockingFailureException` when a versioned entity cannot be deleted.
* Support `Range<T>` in repository between queries.
* Changed behavior of `Reactive/MongoOperations#count` now limiting the range to count matches within by passing on _offset_ & _limit_ to the server.
* Kotlin extension methods accepting `KClass` are deprecated now in favor of `reified` methods.
* Support of array filters in `Update` operations.