diff --git a/google-cloud-firestore-bom/pom.xml b/google-cloud-firestore-bom/pom.xml index c8f4a5e77..27a92cdc2 100644 --- a/google-cloud-firestore-bom/pom.xml +++ b/google-cloud-firestore-bom/pom.xml @@ -70,6 +70,11 @@ proto-google-cloud-firestore-admin-v1 2.1.1-SNAPSHOT + + com.google.cloud + proto-google-cloud-firestore-bundle-v1 + 2.1.1-SNAPSHOT + com.google.api.grpc proto-google-cloud-firestore-v1 diff --git a/google-cloud-firestore/pom.xml b/google-cloud-firestore/pom.xml index 9a257bbdd..7d41275f7 100644 --- a/google-cloud-firestore/pom.xml +++ b/google-cloud-firestore/pom.xml @@ -28,6 +28,10 @@ com.google.api.grpc proto-google-cloud-firestore-v1 + + com.google.cloud + proto-google-cloud-firestore-bundle-v1 + com.google.auto.value auto-value-annotations diff --git a/google-cloud-firestore/src/main/java/com/google/cloud/firestore/DocumentSnapshot.java b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/DocumentSnapshot.java index 6b24ae958..9edff880b 100644 --- a/google-cloud-firestore/src/main/java/com/google/cloud/firestore/DocumentSnapshot.java +++ b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/DocumentSnapshot.java @@ -412,6 +412,16 @@ Write.Builder toPb() { return write; } + Document.Builder toDocumentPb() { + Preconditions.checkState(exists(), "Can't call toDocument() on a document that doesn't exist"); + Document.Builder document = Document.newBuilder(); + return document + .setName(docRef.getName()) + .putAllFields(fields) + .setCreateTime(createTime.toProto()) + .setUpdateTime(updateTime.toProto()); + } + /** * Returns true if the document's data and path in this DocumentSnapshot equals the provided * snapshot. diff --git a/google-cloud-firestore/src/main/java/com/google/cloud/firestore/FirestoreBundle.java b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/FirestoreBundle.java new file mode 100644 index 000000000..20272b867 --- /dev/null +++ b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/FirestoreBundle.java @@ -0,0 +1,232 @@ +/* + * 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 com.google.cloud.Timestamp; +import com.google.common.base.Optional; +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.NamedQuery; +import com.google.firestore.v1.Document; +import com.google.protobuf.InvalidProtocolBufferException; +import com.google.protobuf.util.JsonFormat; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** Represents a Firestore data bundle with results from the given document and query snapshots. */ +public final class FirestoreBundle { + + static final int BUNDLE_SCHEMA_VERSION = 1; + // Printer to encode protobuf objects into JSON string. + private static final JsonFormat.Printer PRINTER = JsonFormat.printer(); + + // Raw byte array to hold the content of the bundle. + private byte[] bundleData; + + /** Builds a Firestore data bundle with results from the given document and query snapshots. */ + public static final class Builder { + // Id of the bundle. + private String id; + // Resulting documents for the bundle, keyed by full document path. + private Map documents = new HashMap<>(); + // Named queries saved in the bundle, keyed by query name. + private Map namedQueries = new HashMap<>(); + // The latest read time among all bundled documents and queries. + private Timestamp latestReadTime = Timestamp.MIN_VALUE; + + public Builder(String id) { + this.id = id; + } + + /** + * Adds a Firestore document snapshot to the bundle. Both the documents data and the document + * read time will be included in the bundle. + * + * @param documentSnapshot A document snapshot to add. + * @returns This instance. + */ + public Builder add(DocumentSnapshot documentSnapshot) { + return add(documentSnapshot, Optional.absent()); + } + + private Builder add(DocumentSnapshot documentSnapshot, Optional queryName) { + String documentName = documentSnapshot.getReference().getName(); + BundledDocument originalDocument = documents.get(documentSnapshot.getReference().getName()); + List queries = + originalDocument == null + ? Lists.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 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 + + com.google.cloud + proto-google-cloud-firestore-bundle-v1 + 2.1.1-SNAPSHOT + com.google.api.grpc proto-google-cloud-firestore-v1