newArrayList()
+ : Lists.newArrayList(originalDocument.getMetadata().getQueriesList());
+
+ // Update with document built from `documentSnapshot` because it is newer.
+ Timestamp snapReadTime =
+ documentSnapshot.getReadTime() == null
+ ? Timestamp.MIN_VALUE
+ : documentSnapshot.getReadTime();
+ if (originalDocument == null
+ || originalDocument.getMetadata().getReadTime() == null
+ || snapReadTime.compareTo(
+ Timestamp.fromProto(originalDocument.getMetadata().getReadTime()))
+ > 0) {
+ BundledDocumentMetadata metadata =
+ BundledDocumentMetadata.newBuilder()
+ .setName(documentName)
+ .setReadTime(snapReadTime.toProto())
+ .setExists(documentSnapshot.exists())
+ .build();
+ Document document =
+ documentSnapshot.exists() ? documentSnapshot.toDocumentPb().build() : null;
+ documents.put(documentName, new BundledDocument(metadata, document));
+ }
+
+ // Update queries to include all queries whose results include this document.
+ if (queryName.isPresent()) {
+ queries.add(queryName.get());
+ }
+ documents
+ .get(documentName)
+ .setMetadata(
+ documents
+ .get(documentName)
+ .getMetadata()
+ .toBuilder()
+ .clearQueries()
+ .addAllQueries(queries)
+ .build());
+
+ if (documentSnapshot.getReadTime().compareTo(latestReadTime) > 0) {
+ latestReadTime = documentSnapshot.getReadTime();
+ }
+
+ return this;
+ }
+
+ /**
+ * Adds a Firestore query snapshots to the bundle. Both the documents in the query snapshots and
+ * the query read time will be included in the bundle.
+ *
+ * @param queryName The name of the query to add.
+ * @param querySnap The query snapshot to add.
+ * @returns This instance.
+ */
+ public Builder add(String queryName, QuerySnapshot querySnap) {
+ BundledQuery query = querySnap.getQuery().toBundledQuery();
+ NamedQuery namedQuery =
+ NamedQuery.newBuilder()
+ .setName(queryName)
+ .setReadTime(querySnap.getReadTime().toProto())
+ .setBundledQuery(query)
+ .build();
+ namedQueries.put(queryName, namedQuery);
+
+ for (QueryDocumentSnapshot snapshot : querySnap.getDocuments()) {
+ add(snapshot, Optional.of(queryName));
+ }
+
+ if (querySnap.getReadTime().compareTo(latestReadTime) > 0) {
+ latestReadTime = querySnap.getReadTime();
+ }
+
+ return this;
+ }
+
+ public FirestoreBundle build() {
+ StringBuilder buffer = new StringBuilder();
+
+ for (NamedQuery namedQuery : namedQueries.values()) {
+ buffer.append(
+ elementToLengthPrefixedStringBuilder(
+ BundleElement.newBuilder().setNamedQuery(namedQuery).build()));
+ }
+
+ for (BundledDocument bundledDocument : documents.values()) {
+ buffer.append(
+ elementToLengthPrefixedStringBuilder(
+ BundleElement.newBuilder()
+ .setDocumentMetadata(bundledDocument.getMetadata())
+ .build()));
+ if (bundledDocument.getDocument() != null) {
+ buffer.append(
+ elementToLengthPrefixedStringBuilder(
+ BundleElement.newBuilder().setDocument(bundledDocument.getDocument()).build()));
+ }
+ }
+
+ BundleMetadata metadata =
+ BundleMetadata.newBuilder()
+ .setId(id)
+ .setCreateTime(latestReadTime.toProto())
+ .setVersion(BUNDLE_SCHEMA_VERSION)
+ .setTotalDocuments(documents.size())
+ .setTotalBytes(buffer.toString().getBytes().length)
+ .build();
+ BundleElement element = BundleElement.newBuilder().setMetadata(metadata).build();
+ buffer.insert(0, elementToLengthPrefixedStringBuilder(element));
+
+ return new FirestoreBundle(buffer.toString().getBytes(StandardCharsets.UTF_8));
+ }
+
+ private StringBuilder elementToLengthPrefixedStringBuilder(BundleElement element) {
+ String elementJson = null;
+ try {
+ elementJson = PRINTER.print(element);
+ } catch (InvalidProtocolBufferException e) {
+ throw new RuntimeException(e);
+ }
+ return new StringBuilder().append(elementJson.getBytes().length).append(elementJson);
+ }
+ }
+
+ private FirestoreBundle(byte[] data) {
+ bundleData = data;
+ }
+
+ /** Returns the bundle content as a readonly {@link ByteBuffer}. */
+ public ByteBuffer toByteBuffer() {
+ return ByteBuffer.wrap(bundleData).asReadOnlyBuffer();
+ }
+
+ /**
+ * Convenient class to hold both the metadata and the actual content of a document to be bundled.
+ */
+ private static class BundledDocument {
+ private BundledDocumentMetadata metadata;
+ private final Document document;
+
+ BundledDocument(BundledDocumentMetadata metadata, Document document) {
+ this.metadata = metadata;
+ this.document = document;
+ }
+
+ public BundledDocumentMetadata getMetadata() {
+ return metadata;
+ }
+
+ void setMetadata(BundledDocumentMetadata metadata) {
+ this.metadata = metadata;
+ }
+
+ public Document getDocument() {
+ return document;
+ }
+ }
+}
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 5e205d580..e236c40ea 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
@@ -38,6 +38,7 @@
import com.google.common.base.Preconditions;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
+import com.google.firestore.bundle.BundledQuery;
import com.google.firestore.v1.Cursor;
import com.google.firestore.v1.Document;
import com.google.firestore.v1.RunQueryRequest;
@@ -1143,6 +1144,72 @@ public Query endAt(@Nonnull DocumentSnapshot snapshot) {
/** Build the final Firestore query. */
StructuredQuery.Builder buildQuery() {
+ StructuredQuery.Builder structuredQuery = buildWithoutClientTranslation();
+ if (options.getLimitType().equals(LimitType.Last)) {
+ // Apply client translation for limitToLast.
+
+ if (!options.getFieldOrders().isEmpty()) {
+ structuredQuery.clearOrderBy();
+ for (FieldOrder order : options.getFieldOrders()) {
+ // Flip the orderBy directions since we want the last results
+ order =
+ new FieldOrder(
+ order.fieldReference,
+ order.direction.equals(Direction.ASCENDING)
+ ? Direction.DESCENDING
+ : Direction.ASCENDING);
+ structuredQuery.addOrderBy(order.toProto());
+ }
+ }
+
+ if (options.getStartCursor() != null) {
+ structuredQuery.clearEndAt();
+ // Swap the cursors to match the flipped query ordering.
+ Cursor cursor =
+ options
+ .getStartCursor()
+ .toBuilder()
+ .setBefore(!options.getStartCursor().getBefore())
+ .build();
+ structuredQuery.setEndAt(cursor);
+ }
+
+ if (options.getEndCursor() != null) {
+ structuredQuery.clearStartAt();
+ // Swap the cursors to match the flipped query ordering.
+ Cursor cursor =
+ options
+ .getEndCursor()
+ .toBuilder()
+ .setBefore(!options.getEndCursor().getBefore())
+ .build();
+ structuredQuery.setStartAt(cursor);
+ }
+ }
+
+ return structuredQuery;
+ }
+
+ /**
+ * Builds a {@link BundledQuery} that is able to be saved in a bundle file.
+ *
+ * This will not perform any limitToLast order flip, as {@link BundledQuery} has first class
+ * representation via {@link BundledQuery.LimitType}.
+ */
+ BundledQuery toBundledQuery() {
+ StructuredQuery.Builder structuredQuery = buildWithoutClientTranslation();
+
+ return BundledQuery.newBuilder()
+ .setStructuredQuery(structuredQuery)
+ .setParent(options.getParentPath().toString())
+ .setLimitType(
+ options.getLimitType().equals(LimitType.Last)
+ ? BundledQuery.LimitType.LAST
+ : BundledQuery.LimitType.FIRST)
+ .build();
+ }
+
+ private StructuredQuery.Builder buildWithoutClientTranslation() {
StructuredQuery.Builder structuredQuery = StructuredQuery.newBuilder();
CollectionSelector.Builder collectionSelector = CollectionSelector.newBuilder();
collectionSelector.setCollectionId(options.getCollectionId());
@@ -1172,21 +1239,7 @@ StructuredQuery.Builder buildQuery() {
if (!options.getFieldOrders().isEmpty()) {
for (FieldOrder order : options.getFieldOrders()) {
- switch (options.getLimitType()) {
- case First:
- structuredQuery.addOrderBy(order.toProto());
- break;
- case Last:
- // Flip the orderBy directions since we want the last results
- order =
- new FieldOrder(
- order.fieldReference,
- order.direction.equals(Direction.ASCENDING)
- ? Direction.DESCENDING
- : Direction.ASCENDING);
- structuredQuery.addOrderBy(order.toProto());
- break;
- }
+ structuredQuery.addOrderBy(order.toProto());
}
} else if (LimitType.Last.equals(options.getLimitType())) {
throw new IllegalStateException(
@@ -1206,39 +1259,11 @@ StructuredQuery.Builder buildQuery() {
}
if (options.getStartCursor() != null) {
- switch (options.getLimitType()) {
- case First:
- structuredQuery.setStartAt(options.getStartCursor());
- break;
- case Last:
- // Swap the cursors to match the flipped query ordering.
- Cursor cursor =
- options
- .getStartCursor()
- .toBuilder()
- .setBefore(!options.getStartCursor().getBefore())
- .build();
- structuredQuery.setEndAt(cursor);
- break;
- }
+ structuredQuery.setStartAt(options.getStartCursor());
}
if (options.getEndCursor() != null) {
- switch (options.getLimitType()) {
- case First:
- structuredQuery.setEndAt(options.getEndCursor());
- break;
- case Last:
- // Swap the cursors to match the flipped query ordering.
- Cursor cursor =
- options
- .getEndCursor()
- .toBuilder()
- .setBefore(!options.getEndCursor().getBefore())
- .build();
- structuredQuery.setStartAt(cursor);
- break;
- }
+ structuredQuery.setEndAt(options.getEndCursor());
}
return structuredQuery;
diff --git a/google-cloud-firestore/src/test/java/com/google/cloud/firestore/FirestoreBundleTest.java b/google-cloud-firestore/src/test/java/com/google/cloud/firestore/FirestoreBundleTest.java
new file mode 100644
index 000000000..3d0c29f82
--- /dev/null
+++ b/google-cloud-firestore/src/test/java/com/google/cloud/firestore/FirestoreBundleTest.java
@@ -0,0 +1,325 @@
+/*
+ * Copyright 2020 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.cloud.firestore;
+
+import static com.google.cloud.firestore.LocalFirestoreHelper.COLLECTION_ID;
+import static com.google.cloud.firestore.LocalFirestoreHelper.DOCUMENT_NAME;
+import static com.google.cloud.firestore.LocalFirestoreHelper.SINGLE_FIELD_SNAPSHOT;
+import static com.google.cloud.firestore.LocalFirestoreHelper.UPDATED_SINGLE_FIELD_SNAPSHOT;
+import static com.google.cloud.firestore.LocalFirestoreHelper.bundleToElementList;
+import static org.junit.Assert.assertArrayEquals;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+import com.google.cloud.firestore.spi.v1.FirestoreRpc;
+import com.google.common.collect.Lists;
+import com.google.firestore.bundle.BundleElement;
+import com.google.firestore.bundle.BundleMetadata;
+import com.google.firestore.bundle.BundledDocumentMetadata;
+import com.google.firestore.bundle.BundledQuery;
+import com.google.firestore.bundle.BundledQuery.LimitType;
+import com.google.firestore.bundle.NamedQuery;
+import com.google.firestore.v1.Document;
+import com.google.protobuf.InvalidProtocolBufferException;
+import com.google.protobuf.Timestamp;
+import com.google.protobuf.util.JsonFormat;
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.List;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mockito;
+import org.mockito.Spy;
+import org.mockito.runners.MockitoJUnitRunner;
+
+@RunWith(MockitoJUnitRunner.class)
+public class FirestoreBundleTest {
+ private static final String TEST_BUNDLE_ID = "test-bundle";
+ private static final int TEST_BUNDLE_VERSION = 1;
+ private static JsonFormat.Parser parser = JsonFormat.parser();
+
+ @Spy
+ private FirestoreImpl firestoreMock =
+ new FirestoreImpl(
+ FirestoreOptions.newBuilder().setProjectId("test-project").build(),
+ Mockito.mock(FirestoreRpc.class));
+
+ private Query query;
+
+ @Before
+ public void before() {
+ query = firestoreMock.collection(COLLECTION_ID);
+ }
+
+ public static void verifyMetadata(
+ BundleMetadata meta, Timestamp createTime, int totalDocuments, boolean expectEmptyContent) {
+ if (!expectEmptyContent) {
+ assertTrue(meta.getTotalBytes() > 0);
+ } else {
+ assertEquals(0, meta.getTotalBytes());
+ }
+ assertEquals(TEST_BUNDLE_ID, meta.getId());
+ assertEquals(TEST_BUNDLE_VERSION, meta.getVersion());
+ assertEquals(totalDocuments, meta.getTotalDocuments());
+ assertEquals(createTime, meta.getCreateTime());
+ }
+
+ public static void verifyDocumentAndMeta(
+ BundledDocumentMetadata documentMetadata,
+ Document document,
+ String expectedDocumentName,
+ List expectedQueries,
+ DocumentSnapshot equivalentSnapshot) {
+ verifyDocumentAndMeta(
+ documentMetadata,
+ document,
+ expectedDocumentName,
+ expectedQueries,
+ equivalentSnapshot,
+ equivalentSnapshot.getReadTime().toProto());
+ }
+
+ public static void verifyDocumentAndMeta(
+ BundledDocumentMetadata documentMetadata,
+ Document document,
+ String expectedDocumentName,
+ List expectedQueries,
+ DocumentSnapshot equivalentSnapshot,
+ Timestamp readTime) {
+ assertEquals(
+ BundledDocumentMetadata.newBuilder()
+ .setExists(true)
+ .setName(expectedDocumentName)
+ .setReadTime(readTime)
+ .addAllQueries(expectedQueries)
+ .build(),
+ documentMetadata);
+ assertEquals(
+ Document.newBuilder()
+ .putAllFields(equivalentSnapshot.getProtoFields())
+ .setCreateTime(equivalentSnapshot.getCreateTime().toProto())
+ .setUpdateTime(equivalentSnapshot.getUpdateTime().toProto())
+ .setName(expectedDocumentName)
+ .build(),
+ document);
+ }
+
+ public static void verifyNamedQuery(
+ NamedQuery namedQuery, String name, Timestamp readTime, Query query, LimitType limitType) {
+ assertEquals(
+ NamedQuery.newBuilder()
+ .setName(name)
+ .setReadTime(readTime)
+ .setBundledQuery(
+ BundledQuery.newBuilder()
+ .setParent(query.toProto().getParent())
+ .setStructuredQuery(query.toProto().getStructuredQuery())
+ .setLimitType(limitType)
+ .build())
+ .build(),
+ namedQuery);
+ }
+
+ @Test
+ public void bundleToElementListWorks() {
+ String bundleString =
+ "20{\"a\":\"string value\"}9{\"b\":123}26{\"c\":{\"d\":\"nested value\"}}";
+ List elements =
+ bundleToElementList(ByteBuffer.wrap(bundleString.getBytes(StandardCharsets.UTF_8)));
+ assertArrayEquals(
+ new String[] {
+ "{\"a\":\"string value\"}", "{\"b\":123}", "{\"c\":{\"d\":\"nested value\"}}"
+ },
+ elements.toArray());
+ }
+
+ public static List toBundleElements(ByteBuffer bundleBuffer) {
+ ArrayList result = new ArrayList<>();
+ for (String s : bundleToElementList(bundleBuffer)) {
+ BundleElement.Builder b = BundleElement.newBuilder();
+ try {
+ parser.merge(s, b);
+ } catch (InvalidProtocolBufferException e) {
+ throw new RuntimeException(e);
+ }
+ result.add(b.build());
+ }
+
+ return result;
+ }
+
+ @Test
+ public void bundleWithDocumentSnapshots() {
+ FirestoreBundle.Builder bundleBuilder = new FirestoreBundle.Builder(TEST_BUNDLE_ID);
+ bundleBuilder.add(UPDATED_SINGLE_FIELD_SNAPSHOT);
+ bundleBuilder.add(SINGLE_FIELD_SNAPSHOT);
+ ByteBuffer bundleBuffer = bundleBuilder.build().toByteBuffer();
+
+ // Expected bundle elements are [bundleMetadata, meta of UPDATED_SINGLE_FIELD_SNAPSHOT,
+ // UPDATED_SINGLE_FIELD_SNAPSHOT]
+ // because UPDATED_SINGLE_FIELD_SNAPSHOT is newer.
+ List elements = toBundleElements(bundleBuffer);
+ assertEquals(3, elements.size());
+
+ verifyMetadata(
+ elements.get(0).getMetadata(),
+ // Even this snapshot is not included, its read time is still used as create time
+ // because it is the largest read time came across.
+ UPDATED_SINGLE_FIELD_SNAPSHOT.getReadTime().toProto(),
+ /*totalDocuments*/ 1,
+ /*expectEmptyContent*/ false);
+
+ verifyDocumentAndMeta(
+ elements.get(1).getDocumentMetadata(),
+ elements.get(2).getDocument(),
+ DOCUMENT_NAME,
+ Lists.newArrayList(),
+ UPDATED_SINGLE_FIELD_SNAPSHOT);
+ }
+
+ @Test
+ public void bundleWithQuerySnapshot() {
+ FirestoreBundle.Builder bundleBuilder = new FirestoreBundle.Builder(TEST_BUNDLE_ID);
+ QuerySnapshot snapshot =
+ QuerySnapshot.withDocuments(
+ query,
+ SINGLE_FIELD_SNAPSHOT.getReadTime(),
+ Lists.newArrayList(
+ QueryDocumentSnapshot.fromDocument(
+ null,
+ SINGLE_FIELD_SNAPSHOT.getReadTime(),
+ SINGLE_FIELD_SNAPSHOT.toDocumentPb().build())));
+ bundleBuilder.add("test-query", snapshot);
+ ByteBuffer bundleBuffer = bundleBuilder.build().toByteBuffer();
+
+ // Expected bundle elements are [bundleMetadata, named query, meta of SINGLE_FIELD_SNAPSHOT,
+ // SINGLE_FIELD_SNAPSHOT]
+ List elements = toBundleElements(bundleBuffer);
+ assertEquals(4, elements.size());
+
+ verifyMetadata(
+ elements.get(0).getMetadata(),
+ SINGLE_FIELD_SNAPSHOT.getReadTime().toProto(),
+ /*totalDocuments*/ 1,
+ /*expectEmptyContent*/ false);
+
+ verifyNamedQuery(
+ elements.get(1).getNamedQuery(),
+ "test-query",
+ SINGLE_FIELD_SNAPSHOT.getReadTime().toProto(),
+ query,
+ LimitType.FIRST);
+
+ verifyDocumentAndMeta(
+ elements.get(2).getDocumentMetadata(),
+ elements.get(3).getDocument(),
+ DOCUMENT_NAME,
+ Lists.newArrayList("test-query"),
+ SINGLE_FIELD_SNAPSHOT);
+ }
+
+ @Test
+ public void bundleWithQueryReturningNoResult() {
+ FirestoreBundle.Builder bundleBuilder = new FirestoreBundle.Builder(TEST_BUNDLE_ID);
+ QuerySnapshot snapshot =
+ QuerySnapshot.withDocuments(
+ query,
+ SINGLE_FIELD_SNAPSHOT.getReadTime(),
+ Lists.newArrayList());
+ bundleBuilder.add("test-query", snapshot);
+ ByteBuffer bundleBuffer = bundleBuilder.build().toByteBuffer();
+
+ // Expected bundle elements are [bundleMetadata, named query]
+ List elements = toBundleElements(bundleBuffer);
+ assertEquals(2, elements.size());
+
+ verifyMetadata(
+ elements.get(0).getMetadata(),
+ SINGLE_FIELD_SNAPSHOT.getReadTime().toProto(),
+ /*totalDocuments*/ 0,
+ /*expectEmptyContent*/ false);
+
+ verifyNamedQuery(
+ elements.get(1).getNamedQuery(),
+ "test-query",
+ SINGLE_FIELD_SNAPSHOT.getReadTime().toProto(),
+ query,
+ LimitType.FIRST);
+ }
+
+ @Test
+ public void bundleBuiltMultipleTimes() {
+ FirestoreBundle.Builder bundleBuilder = new FirestoreBundle.Builder(TEST_BUNDLE_ID);
+ bundleBuilder.add(SINGLE_FIELD_SNAPSHOT);
+ ByteBuffer bundleBuffer = bundleBuilder.build().toByteBuffer();
+
+ // Expected bundle elements are [bundleMetadata, meta of SINGLE_FIELD_SNAPSHOT,
+ // SINGLE_FIELD_SNAPSHOT]
+ List elements = toBundleElements(bundleBuffer);
+ assertEquals(3, elements.size());
+
+ verifyMetadata(
+ elements.get(0).getMetadata(),
+ SINGLE_FIELD_SNAPSHOT.getReadTime().toProto(),
+ /*totalDocuments*/ 1,
+ /*expectEmptyContent*/ false);
+
+ verifyDocumentAndMeta(
+ elements.get(1).getDocumentMetadata(),
+ elements.get(2).getDocument(),
+ DOCUMENT_NAME,
+ Lists.newArrayList(),
+ SINGLE_FIELD_SNAPSHOT);
+
+ bundleBuilder.add(UPDATED_SINGLE_FIELD_SNAPSHOT);
+ bundleBuffer = bundleBuilder.build().toByteBuffer();
+
+ // Expected bundle elements are [bundleMetadata, meta of UPDATED_SINGLE_FIELD_SNAPSHOT,
+ // UPDATED_SINGLE_FIELD_SNAPSHOT]
+ elements = toBundleElements(bundleBuffer);
+ assertEquals(3, elements.size());
+ verifyMetadata(
+ elements.get(0).getMetadata(),
+ UPDATED_SINGLE_FIELD_SNAPSHOT.getReadTime().toProto(),
+ /*totalDocuments*/ 1,
+ /*expectEmptyContent*/ false);
+ verifyDocumentAndMeta(
+ elements.get(1).getDocumentMetadata(),
+ elements.get(2).getDocument(),
+ DOCUMENT_NAME,
+ Lists.newArrayList(),
+ UPDATED_SINGLE_FIELD_SNAPSHOT);
+ }
+
+ @Test
+ public void bundleWithNothingAdded() {
+ FirestoreBundle.Builder bundleBuilder = new FirestoreBundle.Builder(TEST_BUNDLE_ID);
+ ByteBuffer bundleBuffer = bundleBuilder.build().toByteBuffer();
+
+ // Expected bundle elements are [bundleMetadata]
+ List elements = toBundleElements(bundleBuffer);
+ assertEquals(1, elements.size());
+
+ verifyMetadata(
+ elements.get(0).getMetadata(),
+ com.google.cloud.Timestamp.MIN_VALUE.toProto(),
+ /*totalDocuments*/ 0,
+ /*expectEmptyContent*/ true);
+ }
+}
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 47d63e376..ad7c5c2dc 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
@@ -65,6 +65,8 @@
import com.google.type.LatLng;
import java.io.IOException;
import java.math.BigInteger;
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
@@ -117,6 +119,7 @@ public final class LocalFirestoreHelper {
public static final Map UPDATED_FIELD_PROTO;
public static final Map UPDATED_SINGLE_FIELD_PROTO;
public static final Map UPDATED_POJO_PROTO;
+ public static final DocumentSnapshot UPDATED_SINGLE_FIELD_SNAPSHOT;
public static final Map SINGLE_FLOAT_MAP;
public static final Map SINGLE_FLOAT_PROTO;
@@ -841,6 +844,18 @@ public boolean equals(Object o) {
.putFields("foo", Value.newBuilder().setStringValue("foobar").build()))
.build())
.build();
+ UPDATED_SINGLE_FIELD_SNAPSHOT =
+ new DocumentSnapshot(
+ null,
+ new DocumentReference(
+ null,
+ ResourcePath.create(
+ DatabaseRootName.of("test-project", "(default)"),
+ ImmutableList.of("coll", "doc"))),
+ UPDATED_SINGLE_FIELD_PROTO,
+ Timestamp.ofTimeSecondsAndNanos(50, 6),
+ Timestamp.ofTimeSecondsAndNanos(30, 4),
+ Timestamp.ofTimeSecondsAndNanos(10, 2));
SERVER_TIMESTAMP_MAP = new HashMap<>();
SERVER_TIMESTAMP_MAP.put("foo", FieldValue.serverTimestamp());
SERVER_TIMESTAMP_MAP.put("inner", new HashMap());
@@ -959,6 +974,13 @@ public static Map fromSingleQuotedString(String json) {
return fromJsonString(json.replace("'", "\""));
}
+ public static String fullPath(DocumentReference ref, FirestoreOptions options) {
+ return ResourcePath.create(
+ DatabaseRootName.of(options.getProjectId(), options.getDatabaseId()),
+ ImmutableList.copyOf(ref.getPath().split("/")))
+ .toString();
+ }
+
/**
* Contains a map of request/response pairs that are used to create stub responses when
* `sendRequest()` is called.
@@ -1037,4 +1059,33 @@ ApiFuture extends GeneratedMessageV3> verifyResponse(
return response;
}
}
+
+ /**
+ * Naive implementation to read bundle buffers into a list of JSON strings.
+ *
+ * Only works with UTF-8 encoded bundle buffer.
+ */
+ public static List bundleToElementList(ByteBuffer bundle) {
+ List result = new ArrayList<>();
+ StringBuilder lengthStringBuilder = new StringBuilder();
+ while (bundle.hasRemaining()) {
+ char b = (char) bundle.get();
+ if (b >= '0' && b <= '9') {
+ lengthStringBuilder.append(b);
+ } else if (b == '{') {
+ // Rewind position for bulk reading.
+ bundle.position(bundle.position() - 1);
+ int length = Integer.parseInt(lengthStringBuilder.toString());
+ // Reset lengthStringBuilder
+ lengthStringBuilder = new StringBuilder();
+ byte[] element = new byte[length];
+ bundle.get(element, 0, length);
+ result.add(new String(element, StandardCharsets.UTF_8));
+ } else {
+ throw new RuntimeException("Bad bundle buffer.");
+ }
+ }
+
+ return result;
+ }
}
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 871aea1e9..9e9dea642 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
@@ -16,9 +16,14 @@
package com.google.cloud.firestore.it;
+import static com.google.cloud.firestore.FirestoreBundleTest.toBundleElements;
+import static com.google.cloud.firestore.FirestoreBundleTest.verifyDocumentAndMeta;
+import static com.google.cloud.firestore.FirestoreBundleTest.verifyMetadata;
+import static com.google.cloud.firestore.FirestoreBundleTest.verifyNamedQuery;
import static com.google.cloud.firestore.LocalFirestoreHelper.FOO_LIST;
import static com.google.cloud.firestore.LocalFirestoreHelper.FOO_MAP;
import static com.google.cloud.firestore.LocalFirestoreHelper.UPDATE_SINGLE_FIELD_OBJECT;
+import static com.google.cloud.firestore.LocalFirestoreHelper.fullPath;
import static com.google.cloud.firestore.LocalFirestoreHelper.map;
import static com.google.common.truth.Truth.assertThat;
import static java.util.Arrays.asList;
@@ -44,6 +49,7 @@
import com.google.cloud.firestore.FieldPath;
import com.google.cloud.firestore.FieldValue;
import com.google.cloud.firestore.Firestore;
+import com.google.cloud.firestore.FirestoreBundle;
import com.google.cloud.firestore.FirestoreException;
import com.google.cloud.firestore.FirestoreOptions;
import com.google.cloud.firestore.ListenerRegistration;
@@ -52,6 +58,7 @@
import com.google.cloud.firestore.LocalFirestoreHelper.SingleField;
import com.google.cloud.firestore.Precondition;
import com.google.cloud.firestore.Query;
+import com.google.cloud.firestore.Query.Direction;
import com.google.cloud.firestore.QueryDocumentSnapshot;
import com.google.cloud.firestore.QueryPartition;
import com.google.cloud.firestore.QuerySnapshot;
@@ -64,6 +71,11 @@
import com.google.common.base.Preconditions;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Lists;
+import com.google.firestore.bundle.BundleElement;
+import com.google.firestore.bundle.BundledDocumentMetadata;
+import com.google.firestore.bundle.BundledQuery.LimitType;
+import com.google.firestore.bundle.NamedQuery;
import com.google.firestore.v1.RunQueryRequest;
import io.grpc.Status;
import io.grpc.Status.Code;
@@ -1526,4 +1538,99 @@ public ApiFuture> consume() {
return done;
}
}
+
+ @Test
+ public void testBuildingBundleWhenDocumentDoesNotExist() throws Exception {
+ FirestoreBundle.Builder bundleBuilder = new FirestoreBundle.Builder("test-bundle");
+ DocumentSnapshot snapshot = randomDoc.get().get();
+ bundleBuilder.add(snapshot);
+
+ // Expected bundle elements are [bundleMetadata, documentMetadata]
+ List elements = toBundleElements(bundleBuilder.build().toByteBuffer());
+ assertEquals(2, elements.size());
+
+ verifyMetadata(
+ elements.get(0).getMetadata(),
+ snapshot.getReadTime().toProto(),
+ /*totalDocuments*/ 1,
+ /*expectEmptyContent*/ false);
+ assertEquals(
+ BundledDocumentMetadata.newBuilder()
+ .setExists(false)
+ .setName(fullPath(randomDoc, firestore.getOptions()))
+ .setReadTime(snapshot.getReadTime().toProto())
+ .build(),
+ elements.get(1).getDocumentMetadata());
+ }
+
+ @Test
+ public void testBuildingBundleWithLimitQuery() throws Exception {
+ setDocument("doc1", Collections.singletonMap("counter", 1));
+ setDocument("doc2", Collections.singletonMap("counter", 2));
+
+ Query limitQuery = randomColl.orderBy("counter", Direction.DESCENDING).limit(1);
+ QuerySnapshot limitQuerySnap = limitQuery.get().get();
+ FirestoreBundle.Builder bundleBuilder = new FirestoreBundle.Builder("test-bundle");
+ bundleBuilder.add("limit", limitQuerySnap);
+
+ // Expected bundle elements are [bundleMetadata, limitQuery,
+ // documentMetadata, document]
+ List elements = toBundleElements(bundleBuilder.build().toByteBuffer());
+ assertEquals(4, elements.size());
+
+ verifyMetadata(elements.get(0).getMetadata(), limitQuerySnap.getReadTime().toProto(), 1, false);
+
+ NamedQuery namedLimitQuery = elements.get(1).getNamedQuery();
+
+ verifyNamedQuery(
+ namedLimitQuery,
+ "limit",
+ limitQuerySnap.getReadTime().toProto(),
+ limitQuery,
+ LimitType.FIRST);
+
+ verifyDocumentAndMeta(
+ elements.get(2).getDocumentMetadata(),
+ elements.get(3).getDocument(),
+ fullPath(randomColl.document("doc2"), firestore.getOptions()),
+ Lists.newArrayList("limit"),
+ randomColl.document("doc2").get().get(),
+ limitQuerySnap.getReadTime().toProto());
+ }
+
+ @Test
+ public void testBuildingBundleWithLimitToLastQuery() throws Exception {
+ setDocument("doc1", Collections.singletonMap("counter", 1));
+ setDocument("doc2", Collections.singletonMap("counter", 2));
+
+ Query limitToLastQuery = randomColl.orderBy("counter").limitToLast(1);
+ QuerySnapshot limitToLastQuerySnap = limitToLastQuery.get().get();
+ FirestoreBundle.Builder bundleBuilder = new FirestoreBundle.Builder("test-bundle");
+ bundleBuilder.add("limitToLast", limitToLastQuerySnap);
+
+ // Expected bundle elements are [bundleMetadata, limitToLastQuery,
+ // documentMetadata, document]
+ List elements = toBundleElements(bundleBuilder.build().toByteBuffer());
+ assertEquals(4, elements.size());
+
+ verifyMetadata(
+ elements.get(0).getMetadata(), limitToLastQuerySnap.getReadTime().toProto(), 1, false);
+
+ NamedQuery namedLimitToLastQuery = elements.get(1).getNamedQuery();
+
+ verifyNamedQuery(
+ namedLimitToLastQuery,
+ "limitToLast",
+ limitToLastQuerySnap.getReadTime().toProto(),
+ randomColl.orderBy("counter").limit(1),
+ LimitType.LAST);
+
+ verifyDocumentAndMeta(
+ elements.get(2).getDocumentMetadata(),
+ elements.get(3).getDocument(),
+ fullPath(randomColl.document("doc2"), firestore.getOptions()),
+ Lists.newArrayList("limitToLast"),
+ randomColl.document("doc2").get().get(),
+ limitToLastQuerySnap.getReadTime().toProto());
+ }
}
diff --git a/pom.xml b/pom.xml
index 1bb721b4f..3f2ca5325 100644
--- a/pom.xml
+++ b/pom.xml
@@ -161,6 +161,11 @@
proto-google-cloud-firestore-admin-v1
2.1.1-SNAPSHOT