From 9cea9e478f589a5fe71c737fb00b7d5d2c116ad6 Mon Sep 17 00:00:00 2001 From: Sebastian Schmidt Date: Wed, 10 Jan 2018 20:02:18 -0800 Subject: [PATCH] Adding support for DocumentSnapshot cursors --- .../cloud/firestore/DocumentTransform.java | 3 +- .../com/google/cloud/firestore/Query.java | 277 +++++++++++++++--- .../google/cloud/firestore/UpdateBuilder.java | 3 +- .../cloud/firestore/UserDataConverter.java | 3 +- .../cloud/firestore/LocalFirestoreHelper.java | 13 +- .../com/google/cloud/firestore/QueryTest.java | 126 ++++++++ .../cloud/firestore/it/ITSystemTest.java | 54 ++++ 7 files changed, 424 insertions(+), 55 deletions(-) diff --git a/google-cloud-firestore/src/main/java/com/google/cloud/firestore/DocumentTransform.java b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/DocumentTransform.java index 3d7f2cab3325..ba7f3aef50db 100644 --- a/google-cloud-firestore/src/main/java/com/google/cloud/firestore/DocumentTransform.java +++ b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/DocumentTransform.java @@ -18,7 +18,6 @@ import com.google.firestore.v1beta1.DocumentTransform.FieldTransform; import com.google.firestore.v1beta1.Write; -import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Map; @@ -32,7 +31,7 @@ final class DocumentTransform { private DocumentReference documentReference; - private final SortedMap transforms; // Sorted for testing. + private final SortedMap transforms; // Sorted for testing. private DocumentTransform( DocumentReference documentReference, SortedMap transforms) { diff --git a/google-cloud-firestore/src/main/java/com/google/cloud/firestore/Query.java b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/Query.java index 646814bb5735..cddbdcb9711b 100644 --- a/google-cloud-firestore/src/main/java/com/google/cloud/firestore/Query.java +++ b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/Query.java @@ -33,6 +33,7 @@ import com.google.firestore.v1beta1.RunQueryResponse; import com.google.firestore.v1beta1.StructuredQuery; import com.google.firestore.v1beta1.StructuredQuery.CompositeFilter; +import com.google.firestore.v1beta1.StructuredQuery.FieldFilter.Operator; import com.google.firestore.v1beta1.StructuredQuery.FieldReference; import com.google.firestore.v1beta1.StructuredQuery.Filter; import com.google.firestore.v1beta1.StructuredQuery.Order; @@ -75,6 +76,88 @@ StructuredQuery.Direction getDirection() { } } + private abstract static class FieldFilter { + final FieldPath fieldPath; + final Object value; + + FieldFilter(FieldPath fieldPath, Object value) { + this.value = value; + this.fieldPath = fieldPath; + } + + Value encodeValue() { + Object sanitizedObject = CustomClassMapper.serialize(value); + Value encodedValue = + UserDataConverter.encodeValue(fieldPath, sanitizedObject, UserDataConverter.NO_DELETES); + + if (encodedValue == null) { + throw FirestoreException.invalidState("Cannot use Firestore Sentinels in FieldFilter"); + } + return encodedValue; + } + + abstract boolean isEqualsFilter(); + + abstract Filter toProto(); + } + + private static class UnaryFilter extends FieldFilter { + UnaryFilter(FieldPath fieldPath, Object value) { + super(fieldPath, value); + Preconditions.checkArgument( + isUnaryComparison(value), "Cannot use '%s' in unary comparison", value); + } + + @Override + boolean isEqualsFilter() { + return true; + } + + Filter toProto() { + Filter.Builder result = Filter.newBuilder(); + + result + .getUnaryFilterBuilder() + .setField(FieldReference.newBuilder().setFieldPath(fieldPath.getEncodedPath())) + .setOp( + value == null + ? StructuredQuery.UnaryFilter.Operator.IS_NULL + : StructuredQuery.UnaryFilter.Operator.IS_NAN); + + return result.build(); + } + } + + private static class ComparisonFilter extends FieldFilter { + final StructuredQuery.FieldFilter.Operator operator; + + ComparisonFilter( + FieldPath fieldPath, StructuredQuery.FieldFilter.Operator operator, Object value) { + super(fieldPath, value); + Preconditions.checkArgument( + !isUnaryComparison(value), "Cannot use '%s' in field comparison", value); + this.operator = operator; + } + + @Override + boolean isEqualsFilter() { + return operator.equals(Operator.EQUAL); + } + + Filter toProto() { + Filter.Builder result = Filter.newBuilder(); + + Value encodedValue = encodeValue(); + + result + .getFieldFilterBuilder() + .setField(FieldReference.newBuilder().setFieldPath(fieldPath.getEncodedPath())) + .setValue(encodedValue) + .setOp(operator); + return result.build(); + } + } + private static class FieldOrder { final FieldPath fieldPath; final Direction direction; @@ -84,7 +167,7 @@ private static class FieldOrder { this.direction = direction; } - private Order toProto() { + Order toProto() { Order.Builder result = Order.newBuilder(); result.setField(FieldReference.newBuilder().setFieldPath(fieldPath.getEncodedPath())); result.setDirection(direction.getDirection()); @@ -99,7 +182,7 @@ private static class QueryOptions { private int offset; private Cursor startCursor; private Cursor endCursor; - private List fieldFilters; + private List fieldFilters; private List fieldOrders; private List fieldProjections; @@ -194,54 +277,66 @@ private static boolean isUnaryComparison(@Nullable Object value) { return value == null || value.equals(Double.NaN) || value.equals(Float.NaN); } - private Filter createFieldFilter( - FieldPath fieldPath, StructuredQuery.FieldFilter.Operator operator, Object value) { - Preconditions.checkState( - !isUnaryComparison(value), "Firestore only support equals comparisons with Null and NaN"); - - Filter.Builder result = Filter.newBuilder(); - - Object sanitizedObject = CustomClassMapper.serialize(value); - Value encodedValue = - UserDataConverter.encodeValue(fieldPath, sanitizedObject, UserDataConverter.NO_DELETES); - - if (encodedValue == null) { - throw FirestoreException.invalidState("Cannot use Firestore Sentinels in FieldFilter"); + /** Computes the backend ordering semantics for DocumentSnapshot cursors. */ + private List createImplicitOrderBy() { + List implicitOrders = new ArrayList<>(options.fieldOrders); + boolean hasDocumentId = false; + + if (implicitOrders.isEmpty()) { + // If no explicit ordering is specified, use the first inequality to define an implicit order. + for (FieldFilter fieldFilter : options.fieldFilters) { + if (!fieldFilter.isEqualsFilter()) { + implicitOrders.add(new FieldOrder(fieldFilter.fieldPath, Direction.ASCENDING)); + break; + } + } + } else { + for (FieldOrder fieldOrder : options.fieldOrders) { + if (fieldOrder.fieldPath.equals(FieldPath.DOCUMENT_ID)) { + hasDocumentId = true; + } + } } - result - .getFieldFilterBuilder() - .setField(FieldReference.newBuilder().setFieldPath(fieldPath.getEncodedPath())) - .setValue(encodedValue) - .setOp(operator); + if (!hasDocumentId) { + // Add implicit sorting by name, using the last specified direction. + Direction lastDirection = + implicitOrders.isEmpty() + ? Direction.ASCENDING + : implicitOrders.get(implicitOrders.size() - 1).direction; - return result.build(); + implicitOrders.add(new FieldOrder(FieldPath.documentId(), lastDirection)); + } + return implicitOrders; } - private Filter createUnaryFilter(FieldPath fieldPath, Object value) { - Preconditions.checkState(isUnaryComparison(value)); + private Cursor createCursor( + List order, DocumentSnapshot documentSnapshot, boolean before) { + List fieldValues = new ArrayList<>(); - Filter.Builder result = Filter.newBuilder(); - result - .getUnaryFilterBuilder() - .setField(FieldReference.newBuilder().setFieldPath(fieldPath.getEncodedPath())) - .setOp( - value == null - ? StructuredQuery.UnaryFilter.Operator.IS_NULL - : StructuredQuery.UnaryFilter.Operator.IS_NAN); + for (FieldOrder fieldOrder : order) { + if (fieldOrder.fieldPath.equals(FieldPath.DOCUMENT_ID)) { + fieldValues.add(documentSnapshot.getReference()); + } else { + Preconditions.checkArgument( + documentSnapshot.contains(fieldOrder.fieldPath), + "Field '%s' is missing in the provided DocumentSnapshot. Please provide a document that contains values for all specified orderBy() and where() constraints."); + fieldValues.add(documentSnapshot.get(fieldOrder.fieldPath)); + } + } - return result.build(); + return createCursor(order, fieldValues.toArray(), before); } - private Cursor createCursor(Object[] fieldValues, boolean before) { + private Cursor createCursor(List order, Object[] fieldValues, boolean before) { Cursor.Builder result = Cursor.newBuilder(); Preconditions.checkState( - fieldValues.length <= options.fieldOrders.size(), + fieldValues.length <= order.size(), "Too many cursor values specified. The specified values must match the " + "orderBy() constraints of the query."); - Iterator fieldOrderIterator = options.fieldOrders.iterator(); + Iterator fieldOrderIterator = order.iterator(); for (Object fieldValue : fieldValues) { Object sanitizedValue; @@ -280,8 +375,10 @@ private Cursor createCursor(Object[] fieldValues, boolean before) { Value encodedValue = UserDataConverter.encodeValue(fieldPath, sanitizedValue, UserDataConverter.NO_DELETES); + if (encodedValue == null) { - throw FirestoreException.invalidState("Cannot use Firestore Sentinels in Cursor"); + throw FirestoreException.invalidState( + "Cannot use FieldValue.delete() or FieldValue.serverTimestamp() in a query boundary"); } result.addValues(encodedValue); } @@ -314,12 +411,16 @@ public Query whereEqualTo(@Nonnull String field, @Nullable Object value) { */ @Nonnull public Query whereEqualTo(@Nonnull FieldPath fieldPath, @Nullable Object value) { + Preconditions.checkState( + options.startCursor == null && options.endCursor == null, + "Cannot call whereEqualTo() after defining a boundary with startAt(), " + + "startAfter(), endBefore() or endAt()."); QueryOptions newOptions = new QueryOptions(options); if (isUnaryComparison(value)) { - newOptions.fieldFilters.add(createUnaryFilter(fieldPath, value)); + newOptions.fieldFilters.add(new UnaryFilter(fieldPath, value)); } else { - newOptions.fieldFilters.add(createFieldFilter(fieldPath, EQUAL, value)); + newOptions.fieldFilters.add(new ComparisonFilter(fieldPath, EQUAL, value)); } return new Query(firestore, path, newOptions); @@ -348,8 +449,12 @@ public Query whereLessThan(@Nonnull String field, @Nonnull Object value) { */ @Nonnull public Query whereLessThan(@Nonnull FieldPath fieldPath, @Nonnull Object value) { + Preconditions.checkState( + options.startCursor == null && options.endCursor == null, + "Cannot call whereLessThan() after defining a boundary with startAt(), " + + "startAfter(), endBefore() or endAt()."); QueryOptions newOptions = new QueryOptions(options); - newOptions.fieldFilters.add(createFieldFilter(fieldPath, LESS_THAN, value)); + newOptions.fieldFilters.add(new ComparisonFilter(fieldPath, LESS_THAN, value)); return new Query(firestore, path, newOptions); } @@ -376,8 +481,12 @@ public Query whereLessThanOrEqualTo(@Nonnull String field, @Nonnull Object value */ @Nonnull public Query whereLessThanOrEqualTo(@Nonnull FieldPath fieldPath, @Nonnull Object value) { + Preconditions.checkState( + options.startCursor == null && options.endCursor == null, + "Cannot call whereLessThanOrEqualTo() after defining a boundary with startAt(), " + + "startAfter(), endBefore() or endAt()."); QueryOptions newOptions = new QueryOptions(options); - newOptions.fieldFilters.add(createFieldFilter(fieldPath, LESS_THAN_OR_EQUAL, value)); + newOptions.fieldFilters.add(new ComparisonFilter(fieldPath, LESS_THAN_OR_EQUAL, value)); return new Query(firestore, path, newOptions); } @@ -404,8 +513,12 @@ public Query whereGreaterThan(@Nonnull String field, @Nonnull Object value) { */ @Nonnull public Query whereGreaterThan(@Nonnull FieldPath fieldPath, @Nonnull Object value) { + Preconditions.checkState( + options.startCursor == null && options.endCursor == null, + "Cannot call whereGreaterThan() after defining a boundary with startAt(), " + + "startAfter(), endBefore() or endAt()."); QueryOptions newOptions = new QueryOptions(options); - newOptions.fieldFilters.add(createFieldFilter(fieldPath, GREATER_THAN, value)); + newOptions.fieldFilters.add(new ComparisonFilter(fieldPath, GREATER_THAN, value)); return new Query(firestore, path, newOptions); } @@ -432,8 +545,12 @@ public Query whereGreaterThanOrEqualTo(@Nonnull String field, @Nonnull Object va */ @Nonnull public Query whereGreaterThanOrEqualTo(@Nonnull FieldPath fieldPath, @Nonnull Object value) { + Preconditions.checkState( + options.startCursor == null && options.endCursor == null, + "Cannot call whereGreaterThanOrEqualTo() after defining a boundary with startAt(), " + + "startAfter(), endBefore() or endAt()."); QueryOptions newOptions = new QueryOptions(options); - newOptions.fieldFilters.add(createFieldFilter(fieldPath, GREATER_THAN_OR_EQUAL, value)); + newOptions.fieldFilters.add(new ComparisonFilter(fieldPath, GREATER_THAN_OR_EQUAL, value)); return new Query(firestore, path, newOptions); } @@ -520,6 +637,22 @@ public Query offset(int offset) { return new Query(firestore, path, newOptions); } + /** + * Creates and returns a new Query that starts at the provided document (inclusive). The starting + * position is relative to the order of the query. The document must contain all of the fields + * provided in the orderBy of this query. + * + * @param snapshot The snapshot of the document to start at. + * @return The created Query. + */ + @Nonnull + public Query startAt(@Nonnull DocumentSnapshot snapshot) { + QueryOptions newOptions = new QueryOptions(options); + newOptions.fieldOrders = createImplicitOrderBy(); + newOptions.startCursor = createCursor(newOptions.fieldOrders, snapshot, true); + return new Query(firestore, path, newOptions); + } + /** * Creates and returns a new Query that starts at the provided fields relative to the order of the * query. The order of the field values must match the order of the order by clauses of the query. @@ -530,7 +663,7 @@ public Query offset(int offset) { @Nonnull public Query startAt(Object... fieldValues) { QueryOptions newOptions = new QueryOptions(options); - newOptions.startCursor = createCursor(fieldValues, true); + newOptions.startCursor = createCursor(newOptions.fieldOrders, fieldValues, true); return new Query(firestore, path, newOptions); } @@ -579,6 +712,22 @@ public Query select(FieldPath... fieldPaths) { return new Query(firestore, path, newOptions); } + /** + * Creates and returns a new Query that starts after the provided document (exclusive). The + * starting position is relative to the order of the query. The document must contain all of the + * fields provided in the orderBy of this query. + * + * @param snapshot The snapshot of the document to start after. + * @return The created Query. + */ + @Nonnull + public Query startAfter(@Nonnull DocumentSnapshot snapshot) { + QueryOptions newOptions = new QueryOptions(options); + newOptions.fieldOrders = createImplicitOrderBy(); + newOptions.startCursor = createCursor(newOptions.fieldOrders, snapshot, false); + return new Query(firestore, path, newOptions); + } + /** * Creates and returns a new Query that starts after the provided fields relative to the order of * the query. The order of the field values must match the order of the order by clauses of the @@ -590,7 +739,23 @@ public Query select(FieldPath... fieldPaths) { */ public Query startAfter(Object... fieldValues) { QueryOptions newOptions = new QueryOptions(options); - newOptions.startCursor = createCursor(fieldValues, false); + newOptions.startCursor = createCursor(newOptions.fieldOrders, fieldValues, false); + return new Query(firestore, path, newOptions); + } + + /** + * Creates and returns a new Query that ends before the provided document (exclusive). The end + * position is relative to the order of the query. The document must contain all of the fields + * provided in the orderBy of this query. + * + * @param snapshot The snapshot of the document to end before. + * @return The created Query. + */ + @Nonnull + public Query endBefore(@Nonnull DocumentSnapshot snapshot) { + QueryOptions newOptions = new QueryOptions(options); + newOptions.fieldOrders = createImplicitOrderBy(); + newOptions.endCursor = createCursor(newOptions.fieldOrders, snapshot, true); return new Query(firestore, path, newOptions); } @@ -605,7 +770,7 @@ public Query startAfter(Object... fieldValues) { @Nonnull public Query endBefore(Object... fieldValues) { QueryOptions newOptions = new QueryOptions(options); - newOptions.endCursor = createCursor(fieldValues, true); + newOptions.endCursor = createCursor(newOptions.fieldOrders, fieldValues, true); return new Query(firestore, path, newOptions); } @@ -619,7 +784,23 @@ public Query endBefore(Object... fieldValues) { @Nonnull public Query endAt(Object... fieldValues) { QueryOptions newOptions = new QueryOptions(options); - newOptions.endCursor = createCursor(fieldValues, false); + newOptions.endCursor = createCursor(newOptions.fieldOrders, fieldValues, false); + return new Query(firestore, path, newOptions); + } + + /** + * Creates and returns a new Query that ends at the provided document (inclusive). The end + * position is relative to the order of the query. The document must contain all of the fields + * provided in the orderBy of this query. + * + * @param snapshot The snapshot of the document to end at. + * @return The created Query. + */ + @Nonnull + public Query endAt(@Nonnull DocumentSnapshot snapshot) { + QueryOptions newOptions = new QueryOptions(options); + newOptions.fieldOrders = createImplicitOrderBy(); + newOptions.endCursor = createCursor(newOptions.fieldOrders, snapshot, false); return new Query(firestore, path, newOptions); } @@ -634,7 +815,9 @@ StructuredQuery.Builder buildQuery() { StructuredQuery.CompositeFilter.Builder compositeFilter = StructuredQuery.CompositeFilter.newBuilder(); compositeFilter.setOp(CompositeFilter.Operator.AND); - compositeFilter.addAllFilters(options.fieldFilters); + for (FieldFilter fieldFilter : options.fieldFilters) { + compositeFilter.addFilters(fieldFilter.toProto()); + } filter.setCompositeFilter(compositeFilter.build()); structuredQuery.setWhere(filter.build()); } diff --git a/google-cloud-firestore/src/main/java/com/google/cloud/firestore/UpdateBuilder.java b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/UpdateBuilder.java index 6e8d362a9882..2e696a0b903e 100644 --- a/google-cloud-firestore/src/main/java/com/google/cloud/firestore/UpdateBuilder.java +++ b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/UpdateBuilder.java @@ -135,7 +135,8 @@ private T performCreate( /** Adds a new mutation to the batch. */ private Mutation addMutation() { - Preconditions.checkState(!committed, "Cannot modify a WriteBatch that has already been committed."); + Preconditions.checkState( + !committed, "Cannot modify a WriteBatch that has already been committed."); Mutation mutation = new Mutation(); mutations.add(mutation); return mutation; diff --git a/google-cloud-firestore/src/main/java/com/google/cloud/firestore/UserDataConverter.java b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/UserDataConverter.java index 7f928a930925..88dbba782072 100644 --- a/google-cloud-firestore/src/main/java/com/google/cloud/firestore/UserDataConverter.java +++ b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/UserDataConverter.java @@ -55,8 +55,7 @@ public boolean allowDelete(FieldPath fieldPath) { } }; - private UserDataConverter() { - } + private UserDataConverter() {} /** * Encodes a Java Object to a Firestore Value proto. diff --git a/google-cloud-firestore/src/test/java/com/google/cloud/firestore/LocalFirestoreHelper.java b/google-cloud-firestore/src/test/java/com/google/cloud/firestore/LocalFirestoreHelper.java index a8020b8f93a3..2cec4d6e65d1 100644 --- a/google-cloud-firestore/src/test/java/com/google/cloud/firestore/LocalFirestoreHelper.java +++ b/google-cloud-firestore/src/test/java/com/google/cloud/firestore/LocalFirestoreHelper.java @@ -357,6 +357,11 @@ public static CommitRequest commit(Write... writes) { } public static StructuredQuery filter(StructuredQuery.FieldFilter.Operator operator) { + return filter(operator, "foo", "bar"); + } + + public static StructuredQuery filter( + StructuredQuery.FieldFilter.Operator operator, String path, String value) { StructuredQuery.Builder structuredQuery = StructuredQuery.newBuilder(); StructuredQuery.CompositeFilter.Builder compositeFilter = structuredQuery.getWhereBuilder().getCompositeFilterBuilder(); @@ -364,9 +369,9 @@ public static StructuredQuery filter(StructuredQuery.FieldFilter.Operator operat StructuredQuery.FieldFilter.Builder fieldFilter = compositeFilter.addFiltersBuilder().getFieldFilterBuilder(); - fieldFilter.setField(StructuredQuery.FieldReference.newBuilder().setFieldPath("foo")); + fieldFilter.setField(StructuredQuery.FieldReference.newBuilder().setFieldPath(path)); fieldFilter.setOp(operator); - fieldFilter.setValue(Value.newBuilder().setStringValue("bar")); + fieldFilter.setValue(Value.newBuilder().setStringValue(value)); return structuredQuery.build(); } @@ -594,7 +599,9 @@ public boolean equals(Object o) { null, new DocumentReference( null, - ResourcePath.create(DatabaseRootName.of("", ""), ImmutableList.of("coll", "doc"))), + ResourcePath.create( + DatabaseRootName.of("test-project", "(default)"), + ImmutableList.of("coll", "doc"))), SINGLE_FIELD_PROTO, Instant.ofEpochSecond(5, 6), Instant.ofEpochSecond(3, 4), diff --git a/google-cloud-firestore/src/test/java/com/google/cloud/firestore/QueryTest.java b/google-cloud-firestore/src/test/java/com/google/cloud/firestore/QueryTest.java index 482d80512185..5643f65dbcd6 100644 --- a/google-cloud-firestore/src/test/java/com/google/cloud/firestore/QueryTest.java +++ b/google-cloud-firestore/src/test/java/com/google/cloud/firestore/QueryTest.java @@ -16,6 +16,7 @@ package com.google.cloud.firestore; +import static com.google.cloud.firestore.LocalFirestoreHelper.SINGLE_FIELD_SNAPSHOT; import static com.google.cloud.firestore.LocalFirestoreHelper.endAt; import static com.google.cloud.firestore.LocalFirestoreHelper.filter; import static com.google.cloud.firestore.LocalFirestoreHelper.limit; @@ -36,6 +37,8 @@ import com.google.cloud.firestore.spi.v1beta1.FirestoreRpc; import com.google.firestore.v1beta1.RunQueryRequest; import com.google.firestore.v1beta1.StructuredQuery; +import com.google.firestore.v1beta1.StructuredQuery.Direction; +import com.google.firestore.v1beta1.StructuredQuery.FieldFilter.Operator; import com.google.firestore.v1beta1.Value; import java.util.Arrays; import java.util.Iterator; @@ -247,6 +250,129 @@ public void withFieldPathSelect() throws Exception { } } + @Test + public void withDocumentSnapshotCursor() throws Exception { + doAnswer(queryResponse()) + .when(firestoreMock) + .streamRequest( + runQuery.capture(), + streamObserverCapture.capture(), + Matchers.any()); + + query.startAt(SINGLE_FIELD_SNAPSHOT).get(); + + Value documentBoundary = + Value.newBuilder().setReferenceValue(query.getResourcePath().toString() + "/doc").build(); + + RunQueryRequest queryRequest = + query( + order("__name__", StructuredQuery.Direction.ASCENDING), + startAt(documentBoundary, true)); + + assertEquals(queryRequest, runQuery.getValue()); + } + + @Test + public void withDocumentIdAndDocumentSnapshotCursor() throws Exception { + doAnswer(queryResponse()) + .when(firestoreMock) + .streamRequest( + runQuery.capture(), + streamObserverCapture.capture(), + Matchers.any()); + + query.orderBy(FieldPath.documentId()).startAt(SINGLE_FIELD_SNAPSHOT).get(); + + Value documentBoundary = + Value.newBuilder().setReferenceValue(query.getResourcePath().toString() + "/doc").build(); + + RunQueryRequest queryRequest = + query( + order("__name__", StructuredQuery.Direction.ASCENDING), + startAt(documentBoundary, true)); + + assertEquals(queryRequest, runQuery.getValue()); + } + + @Test + public void withExtractedDirectionForDocumentSnapshotCursor() throws Exception { + doAnswer(queryResponse()) + .when(firestoreMock) + .streamRequest( + runQuery.capture(), + streamObserverCapture.capture(), + Matchers.any()); + + query.orderBy("foo", Query.Direction.DESCENDING).startAt(SINGLE_FIELD_SNAPSHOT).get(); + + Value documentBoundary = + Value.newBuilder().setReferenceValue(query.getResourcePath().toString() + "/doc").build(); + + RunQueryRequest queryRequest = + query( + order("foo", Direction.DESCENDING), + order("__name__", StructuredQuery.Direction.DESCENDING), + startAt(true), + startAt(documentBoundary, true)); + + assertEquals(queryRequest, runQuery.getValue()); + } + + @Test + public void withInequalityFilterForDocumentSnapshotCursor() throws Exception { + doAnswer(queryResponse()) + .when(firestoreMock) + .streamRequest( + runQuery.capture(), + streamObserverCapture.capture(), + Matchers.any()); + + query + .whereEqualTo("a", "b") + .whereGreaterThanOrEqualTo("foo", "bar") + .whereEqualTo("c", "d") + .startAt(SINGLE_FIELD_SNAPSHOT) + .get(); + + Value documentBoundary = + Value.newBuilder().setReferenceValue(query.getResourcePath().toString() + "/doc").build(); + + RunQueryRequest queryRequest = + query( + filter(Operator.EQUAL, "a", "b"), + filter(Operator.GREATER_THAN_OR_EQUAL), + filter(Operator.EQUAL, "c", "d"), + order("foo", Direction.ASCENDING), + order("__name__", StructuredQuery.Direction.ASCENDING), + startAt(true), + startAt(documentBoundary, true)); + + assertEquals(queryRequest, runQuery.getValue()); + } + + @Test + public void withEqualityFilterForDocumentSnapshotCursor() throws Exception { + doAnswer(queryResponse()) + .when(firestoreMock) + .streamRequest( + runQuery.capture(), + streamObserverCapture.capture(), + Matchers.any()); + + query.whereEqualTo("foo", "bar").startAt(SINGLE_FIELD_SNAPSHOT).get(); + + Value documentBoundary = + Value.newBuilder().setReferenceValue(query.getResourcePath().toString() + "/doc").build(); + + RunQueryRequest queryRequest = + query( + filter(Operator.EQUAL), + order("__name__", StructuredQuery.Direction.ASCENDING), + startAt(documentBoundary, true)); + + assertEquals(queryRequest, runQuery.getValue()); + } + @Test public void withStartAt() throws Exception { doAnswer(queryResponse()) diff --git a/google-cloud-firestore/src/test/java/com/google/cloud/firestore/it/ITSystemTest.java b/google-cloud-firestore/src/test/java/com/google/cloud/firestore/it/ITSystemTest.java index ffc6d8aabf56..7d06c6edfe3b 100644 --- a/google-cloud-firestore/src/test/java/com/google/cloud/firestore/it/ITSystemTest.java +++ b/google-cloud-firestore/src/test/java/com/google/cloud/firestore/it/ITSystemTest.java @@ -48,6 +48,7 @@ import com.google.cloud.firestore.WriteBatch; import com.google.cloud.firestore.WriteResult; import com.google.common.collect.ImmutableList; +import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.Date; @@ -823,4 +824,57 @@ public void onEvent( } } } + + + private int paginateResults(Query query, List results) + throws ExecutionException, InterruptedException { + if (!results.isEmpty()) { + query = query.startAfter(results.get(results.size() - 1)); + } + + QuerySnapshot querySnapshot = query.get().get(); + + if (querySnapshot.isEmpty()) { + return 0; + } else { + results.addAll(querySnapshot.getDocuments()); + return 1 + paginateResults(query, results); + } + } + + @Test + public void queryPaginationWithOrderByClause() throws ExecutionException, InterruptedException { + WriteBatch batch = firestore.batch(); + + for (int i = 0; i < 10; ++i) { + batch.set(randomColl.document(), map("val", i)); + } + + batch.commit().get(); + + Query query = randomColl.orderBy("val").limit(3); + + List results = new ArrayList<>(); + int pageCount = paginateResults(query, results); + assertEquals(4, pageCount); + assertEquals(10, results.size()); + } + + @Test + public void queryPaginationWithWhereClause() throws ExecutionException, InterruptedException { + WriteBatch batch = firestore.batch(); + + for (int i = 0; i < 10; ++i) { + batch.set(randomColl.document(), map("val", i)); + } + + batch.commit().get(); + + Query query = randomColl.whereGreaterThanOrEqualTo("val", 1).limit(3); + + List results = new ArrayList<>(); + int pageCount = paginateResults(query, results); + assertEquals(3, pageCount); + assertEquals(9, results.size()); + } }