From bae22e0839de55e11dda604c3034feaedbbc172a Mon Sep 17 00:00:00 2001 From: Sebastian Schmidt Date: Tue, 16 Jun 2020 16:53:53 -0700 Subject: [PATCH] feat: ability to serialize Query to Proto (#241) ### Feature Add new methods `toProto` and `fromProto` to Query, allowing serialization to and deserialization from RunQueryRequest. ### Refactoring FieldFilter in Query has had a `fromProto` defined on it. Properties in FieldFilter and corresponding subclasses have been refactored to use proto types internally rather than previously using high level api types. Add a new interface FirestoreRpcContext which defines all the methods needed by model classes that were only provided by FirestoreImpl. FirestoreImpl now implements FirestoreRpcContext, and the new interface methods are all the same implementation as before. All dependency on FirestoreImpl from the following classes has been replaced with FirestoreRpcContext: * CollectionReference * DocumentReference * DocumentSnapshot * Query * QueryDocumentSnapshot Motivation for this change is due to the fact that we want to expose a new method on Query `fromProto` to allow a Query to be loaded from a proto. However, Query requires additional context & access to be able execute queries and thus has a dependency on a Firestore instance as well as those methods now defined in FirestoreRpcContext. Due to the constraints of Java's type system, the constraint on the instance passed to `fromProto` is runtime checked versus compile time checked. The method signature accepts any `Firestore` instance, but we assert that the instance is also a `FirestoreRpcContext`. This isn't ideal as it is a runtime error when not satisfied. However, Firestore is annotated @InternalExtensionOnly so we have policy control that the instance returned from FirestoreOptions will always also implement FirestoreRpcContext. A test has been added to verify that a proxy instance of FirestoreImpl could satisfy the compile and runtime checks present such that use should allow the code to work with java ee container based approaches using dynamic class proxies for things like instrumentation and dependency injection. As part of the change, the constructors for the above mentioned model classes have had their scope narrowed to package private from protected. The classes themselves are now also annotated with @InternalExtensionOnly along with the note in their Javadoc stating the policy. clirr rules have been added to ignore the constructor changes in the model classes. Co-authored-by: Sebastian Schmidt Co-authored-by: BenWhitehead --- .../clirr-ignored-differences.xml | 13 + .../cloud/firestore/CollectionReference.java | 24 +- .../cloud/firestore/DocumentReference.java | 61 ++-- .../cloud/firestore/DocumentSnapshot.java | 32 +- .../com/google/cloud/firestore/FieldPath.java | 14 +- .../google/cloud/firestore/FirestoreImpl.java | 28 +- .../cloud/firestore/FirestoreRpcContext.java | 53 +++ .../com/google/cloud/firestore/Query.java | 343 +++++++++++++----- .../firestore/QueryDocumentSnapshot.java | 14 +- .../google/cloud/firestore/ResourcePath.java | 2 +- .../com/google/cloud/firestore/QueryTest.java | 122 +++++++ .../cloud/firestore/it/ITSystemTest.java | 9 + 12 files changed, 553 insertions(+), 162 deletions(-) create mode 100644 google-cloud-firestore/src/main/java/com/google/cloud/firestore/FirestoreRpcContext.java diff --git a/google-cloud-firestore/clirr-ignored-differences.xml b/google-cloud-firestore/clirr-ignored-differences.xml index ea94bf5b0..84adffe46 100644 --- a/google-cloud-firestore/clirr-ignored-differences.xml +++ b/google-cloud-firestore/clirr-ignored-differences.xml @@ -23,4 +23,17 @@ com/google/cloud/firestore/Firestore com.google.api.core.ApiFuture runAsyncTransaction(*) + + + 7005 + com/google/cloud/firestore/* + *(com.google.cloud.firestore.FirestoreImpl, *) + *(com.google.cloud.firestore.FirestoreRpcContext, *) + + + 7009 + com/google/cloud/firestore/* + *(com.google.cloud.firestore.FirestoreImpl, *) + + diff --git a/google-cloud-firestore/src/main/java/com/google/cloud/firestore/CollectionReference.java b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/CollectionReference.java index acb0d9691..c2367bed1 100644 --- a/google-cloud-firestore/src/main/java/com/google/cloud/firestore/CollectionReference.java +++ b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/CollectionReference.java @@ -19,8 +19,11 @@ import com.google.api.core.ApiFunction; import com.google.api.core.ApiFuture; import com.google.api.core.ApiFutures; +import com.google.api.core.InternalExtensionOnly; import com.google.api.gax.rpc.ApiException; import com.google.api.gax.rpc.ApiExceptions; +import com.google.api.gax.rpc.UnaryCallable; +import com.google.cloud.firestore.spi.v1.FirestoreRpc; import com.google.cloud.firestore.v1.FirestoreClient.ListDocumentsPagedResponse; import com.google.common.base.Preconditions; import com.google.common.util.concurrent.MoreExecutors; @@ -40,16 +43,17 @@ * test mocks. Subclassing is not supported in production code and new SDK releases may break code * that does so. */ +@InternalExtensionOnly public class CollectionReference extends Query { /** * Creates a CollectionReference from a complete collection path. * - * @param firestore The Firestore client. + * @param rpcContext The Firestore client. * @param collectionPath The Path of this collection. */ - protected CollectionReference(FirestoreImpl firestore, ResourcePath collectionPath) { - super(firestore, collectionPath); + CollectionReference(FirestoreRpcContext rpcContext, ResourcePath collectionPath) { + super(rpcContext, collectionPath); } /** @@ -71,7 +75,7 @@ public String getId() { @Nullable public DocumentReference getParent() { ResourcePath parent = options.getParentPath(); - return parent.isDocument() ? new DocumentReference(firestore, parent) : null; + return parent.isDocument() ? new DocumentReference(rpcContext, parent) : null; } /** @@ -109,7 +113,7 @@ public DocumentReference document(@Nonnull String childPath) { Preconditions.checkArgument( documentPath.isDocument(), String.format("Path should point to a Document Reference: %s", getPath())); - return new DocumentReference(firestore, documentPath); + return new DocumentReference(rpcContext, documentPath); } /** @@ -133,10 +137,12 @@ public Iterable listDocuments() { final ListDocumentsPagedResponse response; try { - response = - ApiExceptions.callAndTranslateApiException( - firestore.sendRequest( - request.build(), firestore.getClient().listDocumentsPagedCallable())); + FirestoreRpc client = rpcContext.getClient(); + UnaryCallable callable = + client.listDocumentsPagedCallable(); + ListDocumentsRequest build = request.build(); + ApiFuture future = rpcContext.sendRequest(build, callable); + response = ApiExceptions.callAndTranslateApiException(future); } catch (ApiException exception) { throw FirestoreException.apiException(exception); } diff --git a/google-cloud-firestore/src/main/java/com/google/cloud/firestore/DocumentReference.java b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/DocumentReference.java index 1025ffe29..e689298e0 100644 --- a/google-cloud-firestore/src/main/java/com/google/cloud/firestore/DocumentReference.java +++ b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/DocumentReference.java @@ -19,6 +19,7 @@ import com.google.api.core.ApiFunction; import com.google.api.core.ApiFuture; import com.google.api.core.ApiFutures; +import com.google.api.core.InternalExtensionOnly; import com.google.api.gax.rpc.ApiException; import com.google.api.gax.rpc.ApiExceptions; import com.google.cloud.firestore.v1.FirestoreClient.ListCollectionIdsPagedResponse; @@ -42,15 +43,16 @@ * test mocks. Subclassing is not supported in production code and new SDK releases may break code * that does so. */ +@InternalExtensionOnly public class DocumentReference { private final ResourcePath path; - private final FirestoreImpl firestore; + private final FirestoreRpcContext rpcContext; - protected DocumentReference( - FirestoreImpl firestore, ResourcePath path) { // Elevated access level for mocking. + DocumentReference( + FirestoreRpcContext rpcContext, ResourcePath path) { // Elevated access level for mocking. this.path = path; - this.firestore = firestore; + this.rpcContext = rpcContext; } /* @@ -60,7 +62,7 @@ protected DocumentReference( */ @Nonnull public Firestore getFirestore() { - return firestore; + return rpcContext.getFirestore(); } /** @@ -102,7 +104,7 @@ String getName() { */ @Nonnull public CollectionReference getParent() { - return new CollectionReference(firestore, path.getParent()); + return new CollectionReference(rpcContext, path.getParent()); } /** @@ -114,7 +116,7 @@ public CollectionReference getParent() { */ @Nonnull public CollectionReference collection(@Nonnull String collectionPath) { - return new CollectionReference(firestore, path.append(collectionPath)); + return new CollectionReference(rpcContext, path.append(collectionPath)); } /** @@ -144,7 +146,7 @@ public T apply(List results) { */ @Nonnull public ApiFuture create(@Nonnull Map fields) { - WriteBatch writeBatch = firestore.batch(); + WriteBatch writeBatch = rpcContext.getFirestore().batch(); return extractFirst(writeBatch.create(this, fields).commit()); } @@ -157,7 +159,7 @@ public ApiFuture create(@Nonnull Map fields) { */ @Nonnull public ApiFuture create(@Nonnull Object pojo) { - WriteBatch writeBatch = firestore.batch(); + WriteBatch writeBatch = rpcContext.getFirestore().batch(); return extractFirst(writeBatch.create(this, pojo).commit()); } @@ -170,7 +172,7 @@ public ApiFuture create(@Nonnull Object pojo) { */ @Nonnull public ApiFuture set(@Nonnull Map fields) { - WriteBatch writeBatch = firestore.batch(); + WriteBatch writeBatch = rpcContext.getFirestore().batch(); return extractFirst(writeBatch.set(this, fields).commit()); } @@ -186,7 +188,7 @@ public ApiFuture set(@Nonnull Map fields) { @Nonnull public ApiFuture set( @Nonnull Map fields, @Nonnull SetOptions options) { - WriteBatch writeBatch = firestore.batch(); + WriteBatch writeBatch = rpcContext.getFirestore().batch(); return extractFirst(writeBatch.set(this, fields, options).commit()); } @@ -199,7 +201,7 @@ public ApiFuture set( */ @Nonnull public ApiFuture set(@Nonnull Object pojo) { - WriteBatch writeBatch = firestore.batch(); + WriteBatch writeBatch = rpcContext.getFirestore().batch(); return extractFirst(writeBatch.set(this, pojo).commit()); } @@ -214,7 +216,7 @@ public ApiFuture set(@Nonnull Object pojo) { */ @Nonnull public ApiFuture set(@Nonnull Object pojo, @Nonnull SetOptions options) { - WriteBatch writeBatch = firestore.batch(); + WriteBatch writeBatch = rpcContext.getFirestore().batch(); return extractFirst(writeBatch.set(this, pojo, options).commit()); } @@ -227,7 +229,7 @@ public ApiFuture set(@Nonnull Object pojo, @Nonnull SetOptions opti */ @Nonnull public ApiFuture update(@Nonnull Map fields) { - WriteBatch writeBatch = firestore.batch(); + WriteBatch writeBatch = rpcContext.getFirestore().batch(); return extractFirst(writeBatch.update(this, fields).commit()); } @@ -241,7 +243,7 @@ public ApiFuture update(@Nonnull Map fields) { */ @Nonnull public ApiFuture update(@Nonnull Map fields, Precondition options) { - WriteBatch writeBatch = firestore.batch(); + WriteBatch writeBatch = rpcContext.getFirestore().batch(); return extractFirst(writeBatch.update(this, fields, options).commit()); } @@ -257,7 +259,7 @@ public ApiFuture update(@Nonnull Map fields, Precon @Nonnull public ApiFuture update( @Nonnull String field, @Nullable Object value, Object... moreFieldsAndValues) { - WriteBatch writeBatch = firestore.batch(); + WriteBatch writeBatch = rpcContext.getFirestore().batch(); return extractFirst(writeBatch.update(this, field, value, moreFieldsAndValues).commit()); } @@ -273,7 +275,7 @@ public ApiFuture update( @Nonnull public ApiFuture update( @Nonnull FieldPath fieldPath, @Nullable Object value, Object... moreFieldsAndValues) { - WriteBatch writeBatch = firestore.batch(); + WriteBatch writeBatch = rpcContext.getFirestore().batch(); return extractFirst(writeBatch.update(this, fieldPath, value, moreFieldsAndValues).commit()); } @@ -293,7 +295,7 @@ public ApiFuture update( @Nonnull String field, @Nullable Object value, Object... moreFieldsAndValues) { - WriteBatch writeBatch = firestore.batch(); + WriteBatch writeBatch = rpcContext.getFirestore().batch(); return extractFirst( writeBatch.update(this, options, field, value, moreFieldsAndValues).commit()); } @@ -314,7 +316,7 @@ public ApiFuture update( @Nonnull FieldPath fieldPath, @Nullable Object value, Object... moreFieldsAndValues) { - WriteBatch writeBatch = firestore.batch(); + WriteBatch writeBatch = rpcContext.getFirestore().batch(); return extractFirst( writeBatch.update(this, options, fieldPath, value, moreFieldsAndValues).commit()); } @@ -327,7 +329,7 @@ public ApiFuture update( */ @Nonnull public ApiFuture delete(@Nonnull Precondition options) { - WriteBatch writeBatch = firestore.batch(); + WriteBatch writeBatch = rpcContext.getFirestore().batch(); return extractFirst(writeBatch.delete(this, options).commit()); } @@ -338,7 +340,7 @@ public ApiFuture delete(@Nonnull Precondition options) { */ @Nonnull public ApiFuture delete() { - WriteBatch writeBatch = firestore.batch(); + WriteBatch writeBatch = rpcContext.getFirestore().batch(); return extractFirst(writeBatch.delete(this).commit()); } @@ -351,7 +353,7 @@ public ApiFuture delete() { */ @Nonnull public ApiFuture get() { - return extractFirst(firestore.getAll(this)); + return extractFirst(rpcContext.getFirestore().getAll(this)); } /** @@ -364,7 +366,8 @@ public ApiFuture get() { */ @Nonnull public ApiFuture get(FieldMask fieldMask) { - return extractFirst(firestore.getAll(new DocumentReference[] {this}, fieldMask)); + return extractFirst( + rpcContext.getFirestore().getAll(new DocumentReference[] {this}, fieldMask)); } /** @@ -382,8 +385,8 @@ public Iterable listCollections() { try { response = ApiExceptions.callAndTranslateApiException( - firestore.sendRequest( - request.build(), firestore.getClient().listCollectionIdsPagedCallable())); + rpcContext.sendRequest( + request.build(), rpcContext.getClient().listCollectionIdsPagedCallable())); } catch (ApiException exception) { throw FirestoreException.apiException(exception); } @@ -456,7 +459,7 @@ public void onEvent( } listener.onEvent( DocumentSnapshot.fromMissing( - firestore, DocumentReference.this, value.getReadTime()), + rpcContext, DocumentReference.this, value.getReadTime()), null); } }); @@ -471,7 +474,7 @@ public void onEvent( @Nonnull public ListenerRegistration addSnapshotListener( @Nonnull EventListener listener) { - return addSnapshotListener(firestore.getClient().getExecutor(), listener); + return addSnapshotListener(rpcContext.getClient().getExecutor(), listener); } ResourcePath getResourcePath() { @@ -498,11 +501,11 @@ public boolean equals(Object obj) { return false; } DocumentReference that = (DocumentReference) obj; - return Objects.equals(path, that.path) && Objects.equals(firestore, that.firestore); + return Objects.equals(path, that.path) && Objects.equals(rpcContext, that.rpcContext); } @Override public int hashCode() { - return Objects.hash(path, firestore); + return Objects.hash(path, rpcContext); } } 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 9133797a0..900807682 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 @@ -16,6 +16,7 @@ package com.google.cloud.firestore; +import com.google.api.core.InternalExtensionOnly; import com.google.cloud.Timestamp; import com.google.cloud.firestore.UserDataConverter.EncodingOptions; import com.google.common.base.Preconditions; @@ -44,23 +45,24 @@ * test mocks. Subclassing is not supported in production code and new SDK releases may break code * that does so. */ +@InternalExtensionOnly public class DocumentSnapshot { - private final FirestoreImpl firestore; + private final FirestoreRpcContext rpcContext; private final DocumentReference docRef; @Nullable private final Map fields; @Nullable private final Timestamp readTime; @Nullable private final Timestamp updateTime; @Nullable private final Timestamp createTime; - protected DocumentSnapshot( - FirestoreImpl firestore, + DocumentSnapshot( + FirestoreRpcContext rpcContext, DocumentReference docRef, @Nullable Map fields, @Nullable Timestamp readTime, @Nullable Timestamp updateTime, @Nullable Timestamp createTime) { // Elevated access level for mocking. - this.firestore = firestore; + this.rpcContext = rpcContext; this.docRef = docRef; this.fields = fields; this.readTime = readTime; @@ -79,7 +81,7 @@ public String getId() { } static DocumentSnapshot fromObject( - FirestoreImpl firestore, + FirestoreRpcContext rpcContext, DocumentReference docRef, Map values, EncodingOptions options) { @@ -94,14 +96,14 @@ static DocumentSnapshot fromObject( fields.put(entry.getKey(), encodedValue); } } - return new DocumentSnapshot(firestore, docRef, fields, null, null, null); + return new DocumentSnapshot(rpcContext, docRef, fields, null, null, null); } static DocumentSnapshot fromDocument( - FirestoreImpl firestore, Timestamp readTime, Document document) { + FirestoreRpcContext rpcContext, Timestamp readTime, Document document) { return new DocumentSnapshot( - firestore, - new DocumentReference(firestore, ResourcePath.create(document.getName())), + rpcContext, + new DocumentReference(rpcContext, ResourcePath.create(document.getName())), document.getFieldsMap(), readTime, Timestamp.fromProto(document.getUpdateTime()), @@ -109,8 +111,8 @@ static DocumentSnapshot fromDocument( } static DocumentSnapshot fromMissing( - FirestoreImpl firestore, DocumentReference documentReference, Timestamp readTime) { - return new DocumentSnapshot(firestore, documentReference, null, readTime, null, null); + FirestoreRpcContext rpcContext, DocumentReference documentReference, Timestamp readTime) { + return new DocumentSnapshot(rpcContext, documentReference, null, readTime, null, null); } private Object decodeValue(Value v) { @@ -132,7 +134,7 @@ private Object decodeValue(Value v) { return Blob.fromByteString(v.getBytesValue()); case REFERENCE_VALUE: String pathName = v.getReferenceValue(); - return new DocumentReference(firestore, ResourcePath.create(pathName)); + return new DocumentReference(rpcContext, ResourcePath.create(pathName)); case GEO_POINT_VALUE: return new GeoPoint( v.getGeoPointValue().getLatitude(), v.getGeoPointValue().getLongitude()); @@ -312,7 +314,7 @@ public T get(@Nonnull FieldPath fieldPath, Class valueType) { private Object convertToDateIfNecessary(Object decodedValue) { if (decodedValue instanceof Timestamp) { - if (!this.firestore.areTimestampsInSnapshotsEnabled()) { + if (!this.rpcContext.areTimestampsInSnapshotsEnabled()) { decodedValue = ((Timestamp) decodedValue).toDate(); } } @@ -487,13 +489,13 @@ public boolean equals(Object obj) { return false; } DocumentSnapshot that = (DocumentSnapshot) obj; - return Objects.equals(firestore, that.firestore) + return Objects.equals(rpcContext, that.rpcContext) && Objects.equals(docRef, that.docRef) && Objects.equals(fields, that.fields); } @Override public int hashCode() { - return Objects.hash(firestore, docRef, fields); + return Objects.hash(rpcContext, docRef, fields); } } diff --git a/google-cloud-firestore/src/main/java/com/google/cloud/firestore/FieldPath.java b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/FieldPath.java index b8b3f47a9..b850f2522 100644 --- a/google-cloud-firestore/src/main/java/com/google/cloud/firestore/FieldPath.java +++ b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/FieldPath.java @@ -19,6 +19,7 @@ import com.google.auto.value.AutoValue; import com.google.common.base.Preconditions; import com.google.common.collect.ImmutableList; +import com.google.firestore.v1.StructuredQuery; import java.util.regex.Pattern; import javax.annotation.Nonnull; @@ -30,11 +31,13 @@ @AutoValue public abstract class FieldPath extends BasePath implements Comparable { + private static final String DOCUMENT_ID_SENTINEL = "__name__"; + /** * A special sentinel FieldPath to refer to the ID of a document. It can be used in queries to * sort or filter by the document ID. */ - static final FieldPath DOCUMENT_ID = FieldPath.of("__name__"); + static final FieldPath DOCUMENT_ID = FieldPath.of(DOCUMENT_ID_SENTINEL); /** Regular expression to verify that dot-separated field paths do not contain ~*[]/. */ private static final Pattern PROHIBITED_CHARACTERS = Pattern.compile(".*[~*/\\[\\]].*"); @@ -67,6 +70,11 @@ public static FieldPath documentId() { return DOCUMENT_ID; } + /** Verifies if the provided path matches the path that is used for the Document ID sentinel. */ + static boolean isDocumentId(String path) { + return DOCUMENT_ID_SENTINEL.equals(path); + } + /** Returns a field path from a dot separated string. Does not support escaping. */ static FieldPath fromDotSeparatedString(String field) { if (PROHIBITED_CHARACTERS.matcher(field).matches()) { @@ -152,4 +160,8 @@ FieldPath createPathWithSegments(ImmutableList segments) { public String toString() { return getEncodedPath(); } + + StructuredQuery.FieldReference toProto() { + return StructuredQuery.FieldReference.newBuilder().setFieldPath(getEncodedPath()).build(); + } } diff --git a/google-cloud-firestore/src/main/java/com/google/cloud/firestore/FirestoreImpl.java b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/FirestoreImpl.java index 7c00b1dbd..4aee5adae 100644 --- a/google-cloud-firestore/src/main/java/com/google/cloud/firestore/FirestoreImpl.java +++ b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/FirestoreImpl.java @@ -48,7 +48,7 @@ * Main implementation of the Firestore client. This is the entry point for all Firestore * operations. */ -class FirestoreImpl implements Firestore { +class FirestoreImpl implements Firestore, FirestoreRpcContext { private static final Random RANDOM = new SecureRandom(); private static final int AUTO_ID_LENGTH = 20; @@ -317,34 +317,40 @@ public ApiFuture runAsyncTransaction( } /** Returns whether the user has opted into receiving dates as com.google.cloud.Timestamp. */ - boolean areTimestampsInSnapshotsEnabled() { + @Override + public boolean areTimestampsInSnapshotsEnabled() { return this.firestoreOptions.areTimestampsInSnapshotsEnabled(); } /** Returns the name of the Firestore project associated with this client. */ - String getDatabaseName() { + @Override + public String getDatabaseName() { return databasePath.getDatabaseName().toString(); } /** Returns a path to the Firestore project associated with this client. */ - ResourcePath getResourcePath() { + @Override + public ResourcePath getResourcePath() { return databasePath; } /** Returns the underlying RPC client. */ - FirestoreRpc getClient() { + @Override + public FirestoreRpc getClient() { return firestoreClient; } /** Request funnel for all read/write requests. */ - ApiFuture sendRequest( + @Override + public ApiFuture sendRequest( RequestT requestT, UnaryCallable callable) { Preconditions.checkState(!closed, "Firestore client has already been closed"); return callable.futureCall(requestT); } /** Request funnel for all unidirectional streaming requests. */ - void streamRequest( + @Override + public void streamRequest( RequestT requestT, ApiStreamObserver responseObserverT, ServerStreamingCallable callable) { @@ -353,13 +359,19 @@ void streamRequest( } /** Request funnel for all bidirectional streaming requests. */ - ApiStreamObserver streamRequest( + @Override + public ApiStreamObserver streamRequest( ApiStreamObserver responseObserverT, BidiStreamingCallable callable) { Preconditions.checkState(!closed, "Firestore client has already been closed"); return callable.bidiStreamingCall(responseObserverT); } + @Override + public FirestoreImpl getFirestore() { + return this; + } + @Override public FirestoreOptions getOptions() { return firestoreOptions; diff --git a/google-cloud-firestore/src/main/java/com/google/cloud/firestore/FirestoreRpcContext.java b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/FirestoreRpcContext.java new file mode 100644 index 000000000..807f327de --- /dev/null +++ b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/FirestoreRpcContext.java @@ -0,0 +1,53 @@ +/* + * 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.api.core.ApiFuture; +import com.google.api.core.InternalApi; +import com.google.api.core.InternalExtensionOnly; +import com.google.api.gax.rpc.ApiStreamObserver; +import com.google.api.gax.rpc.BidiStreamingCallable; +import com.google.api.gax.rpc.ServerStreamingCallable; +import com.google.api.gax.rpc.UnaryCallable; +import com.google.cloud.firestore.spi.v1.FirestoreRpc; + +@InternalApi +@InternalExtensionOnly +interface FirestoreRpcContext { + + FS getFirestore(); + + boolean areTimestampsInSnapshotsEnabled(); + + String getDatabaseName(); + + ResourcePath getResourcePath(); + + FirestoreRpc getClient(); + + ApiFuture sendRequest( + RequestT requestT, UnaryCallable callable); + + void streamRequest( + RequestT requestT, + ApiStreamObserver responseObserverT, + ServerStreamingCallable callable); + + ApiStreamObserver streamRequest( + ApiStreamObserver responseObserverT, + BidiStreamingCallable callable); +} 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 8f1f0f82d..9d17ad00d 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 @@ -27,6 +27,7 @@ import static com.google.firestore.v1.StructuredQuery.FieldFilter.Operator.LESS_THAN_OR_EQUAL; import com.google.api.core.ApiFuture; +import com.google.api.core.InternalExtensionOnly; import com.google.api.core.SettableApiFuture; import com.google.api.gax.rpc.ApiStreamObserver; import com.google.auto.value.AutoValue; @@ -63,9 +64,10 @@ * A Query which you can read or listen to. You can also construct refined Query objects by adding * filters and ordering. */ +@InternalExtensionOnly public class Query { - final FirestoreImpl firestore; + final FirestoreRpcContext rpcContext; final QueryOptions options; /** The direction of a sort. */ @@ -85,23 +87,25 @@ StructuredQuery.Direction getDirection() { } abstract static class FieldFilter { - final FieldPath fieldPath; - final Object value; + protected final FieldReference fieldReference; - FieldFilter(FieldPath fieldPath, Object value) { - this.value = value; - this.fieldPath = fieldPath; + FieldFilter(FieldReference fieldReference) { + this.fieldReference = fieldReference; } - Value encodeValue() { - Object sanitizedObject = CustomClassMapper.serialize(value); - Value encodedValue = - UserDataConverter.encodeValue(fieldPath, sanitizedObject, UserDataConverter.ARGUMENT); + static FieldFilter fromProto(StructuredQuery.Filter filter) { + Preconditions.checkArgument( + !filter.hasCompositeFilter(), "Cannot deserialize nested composite filters"); - if (encodedValue == null) { - throw FirestoreException.invalidState("Cannot use Firestore Sentinels in FieldFilter"); + if (filter.hasFieldFilter()) { + return new ComparisonFilter( + filter.getFieldFilter().getField(), + filter.getFieldFilter().getOp(), + filter.getFieldFilter().getValue()); + } else { + Preconditions.checkState(filter.hasUnaryFilter(), "Expected unary of field filter"); + return new UnaryFilter(filter.getUnaryFilter().getField(), filter.getUnaryFilter().getOp()); } - return encodedValue; } abstract boolean isInequalityFilter(); @@ -110,10 +114,12 @@ Value encodeValue() { } 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); + + private final StructuredQuery.UnaryFilter.Operator operator; + + UnaryFilter(FieldReference fieldReference, StructuredQuery.UnaryFilter.Operator operator) { + super(fieldReference); + this.operator = operator; } @Override @@ -123,27 +129,32 @@ boolean isInequalityFilter() { 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); - + result.getUnaryFilterBuilder().setField(fieldReference).setOp(operator); return result.build(); } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof UnaryFilter)) { + return false; + } + UnaryFilter other = (UnaryFilter) o; + return Objects.equals(fieldReference, other.fieldReference) + && Objects.equals(operator, other.operator); + } } - private static class ComparisonFilter extends FieldFilter { + static class ComparisonFilter extends FieldFilter { final StructuredQuery.FieldFilter.Operator operator; + final Value value; ComparisonFilter( - FieldPath fieldPath, StructuredQuery.FieldFilter.Operator operator, Object value) { - super(fieldPath, value); - Preconditions.checkArgument( - !isUnaryComparison(value), "Cannot use '%s' in field comparison", value); + FieldReference fieldReference, StructuredQuery.FieldFilter.Operator operator, Value value) { + super(fieldReference); + this.value = value; this.operator = operator; } @@ -157,33 +168,51 @@ boolean isInequalityFilter() { Filter toProto() { Filter.Builder result = Filter.newBuilder(); - - Value encodedValue = encodeValue(); - - result - .getFieldFilterBuilder() - .setField(FieldReference.newBuilder().setFieldPath(fieldPath.getEncodedPath())) - .setValue(encodedValue) - .setOp(operator); + result.getFieldFilterBuilder().setField(fieldReference).setValue(value).setOp(operator); return result.build(); } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof ComparisonFilter)) { + return false; + } + ComparisonFilter other = (ComparisonFilter) o; + return Objects.equals(fieldReference, other.fieldReference) + && Objects.equals(operator, other.operator) + && Objects.equals(value, other.value); + } } static final class FieldOrder { - final FieldPath fieldPath; - final Direction direction; + private final FieldReference fieldReference; + private final Direction direction; - FieldOrder(FieldPath fieldPath, Direction direction) { - this.fieldPath = fieldPath; + FieldOrder(FieldReference fieldReference, Direction direction) { + this.fieldReference = fieldReference; this.direction = direction; } Order toProto() { Order.Builder result = Order.newBuilder(); - result.setField(FieldReference.newBuilder().setFieldPath(fieldPath.getEncodedPath())); + result.setField(fieldReference); result.setDirection(direction.getDirection()); return result.build(); } + + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof FieldOrder)) { + return false; + } + FieldOrder filter = (FieldOrder) o; + return Objects.equals(toProto(), filter.toProto()); + } } /** Denotes whether a provided limit is applied to the beginning or the end of the result set. */ @@ -258,9 +287,9 @@ abstract static class Builder { } /** Creates a query for documents in a single collection */ - Query(FirestoreImpl firestore, ResourcePath path) { + Query(FirestoreRpcContext rpcContext, ResourcePath path) { this( - firestore, + rpcContext, QueryOptions.builder() .setParentPath(path.getParent()) .setCollectionId(path.getId()) @@ -271,18 +300,18 @@ abstract static class Builder { * Creates a Collection Group query that matches all documents directly nested under a * specifically named collection */ - Query(FirestoreImpl firestore, String collectionId) { + Query(FirestoreRpcContext rpcContext, String collectionId) { this( - firestore, + rpcContext, QueryOptions.builder() - .setParentPath(firestore.getResourcePath()) + .setParentPath(rpcContext.getResourcePath()) .setCollectionId(collectionId) .setAllDescendants(true) .build()); } - private Query(FirestoreImpl firestore, QueryOptions queryOptions) { - this.firestore = firestore; + private Query(FirestoreRpcContext rpcContext, QueryOptions queryOptions) { + this.rpcContext = rpcContext; this.options = queryOptions; } @@ -293,7 +322,7 @@ private Query(FirestoreImpl firestore, QueryOptions queryOptions) { */ @Nonnull public Firestore getFirestore() { - return firestore; + return rpcContext.getFirestore(); } /** Checks whether the provided object is NULL or NaN. */ @@ -310,13 +339,13 @@ private ImmutableList createImplicitOrderBy() { // If no explicit ordering is specified, use the first inequality to define an implicit order. for (FieldFilter fieldFilter : options.getFieldFilters()) { if (fieldFilter.isInequalityFilter()) { - implicitOrders.add(new FieldOrder(fieldFilter.fieldPath, Direction.ASCENDING)); + implicitOrders.add(new FieldOrder(fieldFilter.fieldReference, Direction.ASCENDING)); break; } } } else { for (FieldOrder fieldOrder : options.getFieldOrders()) { - if (fieldOrder.fieldPath.equals(FieldPath.DOCUMENT_ID)) { + if (FieldPath.isDocumentId(fieldOrder.fieldReference.getFieldPath())) { hasDocumentId = true; } } @@ -329,7 +358,7 @@ private ImmutableList createImplicitOrderBy() { ? Direction.ASCENDING : implicitOrders.get(implicitOrders.size() - 1).direction; - implicitOrders.add(new FieldOrder(FieldPath.documentId(), lastDirection)); + implicitOrders.add(new FieldOrder(FieldPath.documentId().toProto(), lastDirection)); } return ImmutableList.builder().addAll(implicitOrders).build(); @@ -340,13 +369,16 @@ private Cursor createCursor( List fieldValues = new ArrayList<>(); for (FieldOrder fieldOrder : order) { - if (fieldOrder.fieldPath.equals(FieldPath.DOCUMENT_ID)) { + String path = fieldOrder.fieldReference.getFieldPath(); + if (FieldPath.isDocumentId(path)) { fieldValues.add(documentSnapshot.getReference()); } else { + FieldPath fieldPath = FieldPath.fromDotSeparatedString(path); 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)); + documentSnapshot.contains(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(fieldPath)); } } @@ -369,16 +401,15 @@ private Cursor createCursor(List order, Object[] fieldValues, boolea for (Object fieldValue : fieldValues) { Object sanitizedValue; - FieldPath fieldPath = fieldOrderIterator.next().fieldPath; + FieldReference fieldReference = fieldOrderIterator.next().fieldReference; - if (fieldPath.equals(FieldPath.DOCUMENT_ID)) { + if (FieldPath.isDocumentId(fieldReference.getFieldPath())) { sanitizedValue = convertReference(fieldValue); } else { sanitizedValue = CustomClassMapper.serialize(fieldValue); } - Value encodedValue = - UserDataConverter.encodeValue(fieldPath, sanitizedValue, UserDataConverter.ARGUMENT); + Value encodedValue = encodeValue(fieldReference, sanitizedValue); if (encodedValue == null) { throw FirestoreException.invalidState( @@ -405,7 +436,7 @@ private Object convertReference(Object fieldValue) { DocumentReference reference; if (fieldValue instanceof String) { - reference = new DocumentReference(firestore, basePath.append((String) fieldValue)); + reference = new DocumentReference(rpcContext, basePath.append((String) fieldValue)); } else if (fieldValue instanceof DocumentReference) { reference = (DocumentReference) fieldValue; } else { @@ -463,9 +494,13 @@ public Query whereEqualTo(@Nonnull FieldPath fieldPath, @Nullable Object value) if (isUnaryComparison(value)) { Builder newOptions = options.toBuilder(); - UnaryFilter newFieldFilter = new UnaryFilter(fieldPath, value); + StructuredQuery.UnaryFilter.Operator op = + value == null + ? StructuredQuery.UnaryFilter.Operator.IS_NULL + : StructuredQuery.UnaryFilter.Operator.IS_NAN; + UnaryFilter newFieldFilter = new UnaryFilter(fieldPath.toProto(), op); newOptions.setFieldFilters(append(options.getFieldFilters(), newFieldFilter)); - return new Query(firestore, newOptions.build()); + return new Query(rpcContext, newOptions.build()); } else { return whereHelper(fieldPath, EQUAL, value); } @@ -713,6 +748,11 @@ public Query whereIn(@Nonnull FieldPath fieldPath, @Nonnull ListRuntime metadata (as required for `limitToLast()` queries) is not serialized and as such, + * the serialized request will return the results in the original backend order. + * + * @return the serialized RunQueryRequest + */ + public RunQueryRequest toProto() { + RunQueryRequest.Builder request = RunQueryRequest.newBuilder(); + request.setStructuredQuery(buildQuery()).setParent(options.getParentPath().toString()); + return request.build(); + } + + /** + * Returns a Query instance that can be used to execute the provided {@link RunQueryRequest}. + * + *

Only RunQueryRequests that pertain to the same project as the Firestore instance can be + * deserialized. + * + *

Runtime metadata (as required for `limitToLast()` queries) is not restored and as such, the + * results for limitToLast() queries will be returned in the original backend order. + * + * @param firestore a Firestore instance to apply the query to + * @param proto the serialized RunQueryRequest + * @return a Query instance that can be used to execute the RunQueryRequest + */ + public static Query fromProto(Firestore firestore, RunQueryRequest proto) { + Preconditions.checkState( + FirestoreRpcContext.class.isAssignableFrom(firestore.getClass()), + "The firestore instance passed to this method must also implement FirestoreRpcContext."); + return fromProto((FirestoreRpcContext) firestore, proto); + } + + private static Query fromProto(FirestoreRpcContext rpcContext, RunQueryRequest proto) { + QueryOptions.Builder queryOptions = QueryOptions.builder(); + StructuredQuery structuredQuery = proto.getStructuredQuery(); + + ResourcePath parentPath = ResourcePath.create(proto.getParent()); + if (!rpcContext.getDatabaseName().equals(parentPath.getDatabaseName().toString())) { + throw new IllegalArgumentException( + String.format( + "Cannot deserialize query from different Firestore project (\"%s\" vs \"%s\")", + rpcContext.getDatabaseName(), parentPath.getDatabaseName())); + } + queryOptions.setParentPath(parentPath); + + Preconditions.checkArgument( + structuredQuery.getFromCount() == 1, + "Can only deserialize query with exactly one collection selector."); + queryOptions.setCollectionId(structuredQuery.getFrom(0).getCollectionId()); + queryOptions.setAllDescendants(structuredQuery.getFrom(0).getAllDescendants()); + + if (structuredQuery.hasWhere()) { + Filter where = structuredQuery.getWhere(); + if (where.hasCompositeFilter()) { + CompositeFilter compositeFilter = where.getCompositeFilter(); + ImmutableList.Builder fieldFilters = ImmutableList.builder(); + for (Filter filter : compositeFilter.getFiltersList()) { + fieldFilters.add(FieldFilter.fromProto(filter)); + } + queryOptions.setFieldFilters(fieldFilters.build()); + } else { + queryOptions.setFieldFilters(ImmutableList.of(FieldFilter.fromProto(where))); + } + } + + ImmutableList.Builder fieldOrders = ImmutableList.builder(); + for (Order order : structuredQuery.getOrderByList()) { + fieldOrders.add( + new FieldOrder(order.getField(), Direction.valueOf(order.getDirection().name()))); + } + queryOptions.setFieldOrders(fieldOrders.build()); + + if (structuredQuery.hasLimit()) { + queryOptions.setLimit(structuredQuery.getLimit().getValue()); + } + + if (structuredQuery.getOffset() != 0) { + queryOptions.setOffset(structuredQuery.getOffset()); + } + + if (structuredQuery.hasSelect()) { + queryOptions.setFieldProjections( + ImmutableList.copyOf(structuredQuery.getSelect().getFieldsList())); + } + + if (structuredQuery.hasStartAt()) { + queryOptions.setStartCursor(structuredQuery.getStartAt()); + } + + if (structuredQuery.hasEndAt()) { + queryOptions.setEndCursor(structuredQuery.getEndAt()); + } + + return new Query(rpcContext, queryOptions.build()); + } + + private Value encodeValue(FieldReference fieldReference, Object value) { + return encodeValue(FieldPath.fromDotSeparatedString(fieldReference.getFieldPath()), value); + } + + private Value encodeValue(FieldPath fieldPath, Object value) { + Object sanitizedObject = CustomClassMapper.serialize(value); + Value encodedValue = + UserDataConverter.encodeValue(fieldPath, sanitizedObject, UserDataConverter.ARGUMENT); + if (encodedValue == null) { + throw FirestoreException.invalidState( + "Cannot use Firestore sentinels in FieldFilter or cursors"); + } + return encodedValue; + } + /** Stream observer that captures DocumentSnapshots as well as the Query read time. */ private abstract static class QuerySnapshotObserver implements ApiStreamObserver { @@ -1216,7 +1371,7 @@ public void onNext(RunQueryResponse response) { Document document = response.getDocument(); QueryDocumentSnapshot documentSnapshot = QueryDocumentSnapshot.fromDocument( - firestore, Timestamp.fromProto(response.getReadTime()), document); + rpcContext, Timestamp.fromProto(response.getReadTime()), document); documentObserver.onNext(documentSnapshot); } @@ -1243,7 +1398,7 @@ public void onCompleted() { } }; - firestore.streamRequest(request.build(), observer, firestore.getClient().runQueryCallable()); + rpcContext.streamRequest(request.build(), observer, rpcContext.getClient().runQueryCallable()); } /** @@ -1264,7 +1419,7 @@ public ApiFuture get() { */ @Nonnull public ListenerRegistration addSnapshotListener(@Nonnull EventListener listener) { - return addSnapshotListener(firestore.getClient().getExecutor(), listener); + return addSnapshotListener(rpcContext.getClient().getExecutor(), listener); } /** @@ -1327,23 +1482,25 @@ public int compare(QueryDocumentSnapshot doc1, QueryDocumentSnapshot doc2) { : fieldOrders.get(fieldOrders.size() - 1).direction; List orderBys = new ArrayList<>(fieldOrders); - orderBys.add(new FieldOrder(FieldPath.DOCUMENT_ID, lastDirection)); + orderBys.add(new FieldOrder(FieldPath.DOCUMENT_ID.toProto(), lastDirection)); for (FieldOrder orderBy : orderBys) { int comp; - if (orderBy.fieldPath.equals(FieldPath.documentId())) { + String path = orderBy.fieldReference.getFieldPath(); + if (FieldPath.isDocumentId(path)) { comp = doc1.getReference() .getResourcePath() .compareTo(doc2.getReference().getResourcePath()); } else { + FieldPath fieldPath = FieldPath.fromDotSeparatedString(path); Preconditions.checkState( - doc1.contains(orderBy.fieldPath) && doc2.contains(orderBy.fieldPath), + doc1.contains(fieldPath) && doc2.contains(fieldPath), "Can only compare fields that exist in the DocumentSnapshot." + " Please include the fields you are ordering on in your select() call."); - Value v1 = doc1.extractField(orderBy.fieldPath); - Value v2 = doc2.extractField(orderBy.fieldPath); + Value v1 = doc1.extractField(fieldPath); + Value v2 = doc2.extractField(fieldPath); comp = com.google.cloud.firestore.Order.INSTANCE.compare(v1, v2); } @@ -1385,11 +1542,11 @@ public boolean equals(Object obj) { return false; } Query query = (Query) obj; - return Objects.equals(firestore, query.firestore) && Objects.equals(options, query.options); + return Objects.equals(rpcContext, query.rpcContext) && Objects.equals(options, query.options); } @Override public int hashCode() { - return Objects.hash(firestore, options); + return Objects.hash(rpcContext, options); } } diff --git a/google-cloud-firestore/src/main/java/com/google/cloud/firestore/QueryDocumentSnapshot.java b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/QueryDocumentSnapshot.java index 899e5f160..c0575586d 100644 --- a/google-cloud-firestore/src/main/java/com/google/cloud/firestore/QueryDocumentSnapshot.java +++ b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/QueryDocumentSnapshot.java @@ -16,6 +16,7 @@ package com.google.cloud.firestore; +import com.google.api.core.InternalExtensionOnly; import com.google.cloud.Timestamp; import com.google.common.base.Preconditions; import com.google.firestore.v1.Document; @@ -36,22 +37,23 @@ * test mocks. Subclassing is not supported in production code and new SDK releases may break code * that does so. */ +@InternalExtensionOnly public class QueryDocumentSnapshot extends DocumentSnapshot { - protected QueryDocumentSnapshot( - FirestoreImpl firestore, + QueryDocumentSnapshot( + FirestoreRpcContext rpcContext, DocumentReference docRef, Map fields, Timestamp readTime, Timestamp updateTime, Timestamp createTime) { // Elevated access level for mocking. - super(firestore, docRef, fields, readTime, updateTime, createTime); + super(rpcContext, docRef, fields, readTime, updateTime, createTime); } static QueryDocumentSnapshot fromDocument( - FirestoreImpl firestore, Timestamp readTime, Document document) { + FirestoreRpcContext rpcContext, Timestamp readTime, Document document) { return new QueryDocumentSnapshot( - firestore, - new DocumentReference(firestore, ResourcePath.create(document.getName())), + rpcContext, + new DocumentReference(rpcContext, ResourcePath.create(document.getName())), document.getFieldsMap(), readTime, Timestamp.fromProto(document.getUpdateTime()), diff --git a/google-cloud-firestore/src/main/java/com/google/cloud/firestore/ResourcePath.java b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/ResourcePath.java index bc96e89bd..7f305475f 100644 --- a/google-cloud-firestore/src/main/java/com/google/cloud/firestore/ResourcePath.java +++ b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/ResourcePath.java @@ -55,7 +55,7 @@ static ResourcePath create(DatabaseRootName databaseName) { static ResourcePath create(String resourceName) { String[] parts = resourceName.split("/"); - if (parts.length >= 6 && parts[0].equals("projects") && parts[2].equals("databases")) { + if (parts.length >= 5 && parts[0].equals("projects") && parts[2].equals("databases")) { String[] path = Arrays.copyOfRange(parts, 5, parts.length); return create( DatabaseRootName.of(parts[1], parts[3]), 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 a3315ca6d..c71297a65 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 @@ -33,19 +33,27 @@ import static com.google.cloud.firestore.LocalFirestoreHelper.unaryFilter; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; import static org.mockito.Mockito.doAnswer; import com.google.api.gax.rpc.ApiStreamObserver; import com.google.api.gax.rpc.ServerStreamingCallable; import com.google.cloud.Timestamp; +import com.google.cloud.firestore.Query.ComparisonFilter; +import com.google.cloud.firestore.Query.FieldFilter; import com.google.cloud.firestore.spi.v1.FirestoreRpc; +import com.google.common.io.BaseEncoding; import com.google.firestore.v1.ArrayValue; import com.google.firestore.v1.RunQueryRequest; import com.google.firestore.v1.StructuredQuery; import com.google.firestore.v1.StructuredQuery.Direction; import com.google.firestore.v1.StructuredQuery.FieldFilter.Operator; import com.google.firestore.v1.Value; +import com.google.protobuf.InvalidProtocolBufferException; +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; import java.util.Arrays; import java.util.Collections; import java.util.Iterator; @@ -835,4 +843,118 @@ public void equalsTest() { assertEquals(query.limit(42).offset(1337), query.offset(1337).limit(42)); assertEquals(query.limit(42).offset(1337).hashCode(), query.offset(1337).limit(42).hashCode()); } + + @Test + public void serializationTest() { + assertSerialization(query); + query = query.whereEqualTo("a", null); + assertSerialization(query); + query = query.whereEqualTo("b", Double.NaN); + assertSerialization(query); + query = query.whereGreaterThan("c", 1); + assertSerialization(query); + query = query.whereGreaterThanOrEqualTo(FieldPath.of("d", ".e."), 2); + assertSerialization(query); + query = query.whereLessThan("f", 3); + assertSerialization(query); + query = query.whereLessThanOrEqualTo(FieldPath.of("g", ".h."), 4); + assertSerialization(query); + query = query.whereIn("i", Collections.singletonList(5)); + assertSerialization(query); + query = query.whereArrayContains("j", Collections.singletonList(6)); + assertSerialization(query); + query = query.whereArrayContainsAny("k", Collections.singletonList(7)); + assertSerialization(query); + query = query.orderBy("l"); + assertSerialization(query); + query = query.orderBy(FieldPath.of("m", ".n."), Query.Direction.DESCENDING); + assertSerialization(query); + query = query.startAt("o"); + assertSerialization(query); + query = query.startAfter("p"); + assertSerialization(query); + query = query.endBefore("q"); + assertSerialization(query); + query = query.endAt("r"); + assertSerialization(query); + query = query.limit(8); + assertSerialization(query); + query = query.offset(9); + assertSerialization(query); + } + + private void assertSerialization(Query query) { + RunQueryRequest runQueryRequest = query.toProto(); + Query deserializedQuery = Query.fromProto(firestoreMock, runQueryRequest); + assertEquals(runQueryRequest, deserializedQuery.toProto()); + assertEquals(deserializedQuery, query); + } + + @Test + public void serializationVerifiesDatabaseName() { + RunQueryRequest runQueryRequest = query.toProto(); + runQueryRequest = + runQueryRequest.toBuilder().setParent("projects/foo/databases/(default)/documents").build(); + + try { + Query.fromProto(firestoreMock, runQueryRequest); + fail("Expected serializtion error"); + } catch (IllegalArgumentException e) { + assertEquals( + "Cannot deserialize query from different Firestore project " + + "(\"projects/test-project/databases/(default)\" vs " + + "\"projects/foo/databases/(default)\")", + e.getMessage()); + } + } + + @Test + public void ensureFromProtoWorksWithAProxy() throws InvalidProtocolBufferException { + Object o = + Proxy.newProxyInstance( + QueryTest.class.getClassLoader(), + new Class[] {Firestore.class, FirestoreRpcContext.class}, + new InvocationHandler() { + @Override + public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { + // use the reflection lookup of the method name so intellij will refactor it along + // with the method name if it ever happens. + Method getDatabaseNameMethod = + FirestoreRpcContext.class.getDeclaredMethod("getDatabaseName"); + if (method.equals(getDatabaseNameMethod)) { + return "projects/test-project/databases/(default)"; + } else { + return null; + } + } + }); + + assertTrue(o instanceof Firestore); + assertTrue(o instanceof FirestoreRpcContext); + + // Code used to generate the below base64 encoded RunQueryRequest + // RunQueryRequest proto = firestoreMock.collection("testing-collection") + // .whereEqualTo("enabled", true).toProto(); + // String base64String = BaseEncoding.base64().encode(proto.toByteArray()); + String base64Proto = + "CjNwcm9qZWN0cy90ZXN0LXByb2plY3QvZGF0YWJhc2VzLyhkZWZhdWx0KS9kb2N1bWVudHMSKxIUEhJ0ZXN0aW5nLWNvbGxlY3Rpb24aExIRCgkSB2VuYWJsZWQQBRoCCAE="; + + byte[] bytes = BaseEncoding.base64().decode(base64Proto); + RunQueryRequest runQueryRequest = RunQueryRequest.parseFrom(bytes); + + Query query = Query.fromProto((Firestore) o, runQueryRequest); + ResourcePath path = query.options.getParentPath(); + assertEquals("projects/test-project/databases/(default)/documents", path.getName()); + assertEquals("testing-collection", query.options.getCollectionId()); + FieldFilter next = query.options.getFieldFilters().iterator().next(); + assertEquals("enabled", next.fieldReference.getFieldPath()); + + if (next instanceof ComparisonFilter) { + ComparisonFilter comparisonFilter = (ComparisonFilter) next; + assertFalse(comparisonFilter.isInequalityFilter()); + assertEquals(Value.newBuilder().setBooleanValue(true).build(), comparisonFilter.value); + } else { + fail("expect filter to be a comparison filter"); + } + } } 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 e3b54526c..6ac8d3d1d 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 @@ -58,6 +58,7 @@ import com.google.common.base.Preconditions; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; +import com.google.firestore.v1.RunQueryRequest; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; @@ -1260,4 +1261,12 @@ public void onCompleted() { assertEquals(ref3.getId(), documentSnapshots.get(2).getId()); assertEquals(3, documentSnapshots.size()); } + + @Test + public void testInstanceReturnedByGetServiceCanBeUsedToDeserializeAQuery() throws Exception { + Firestore fs = FirestoreOptions.getDefaultInstance().getService(); + RunQueryRequest proto = fs.collection("coll").whereEqualTo("bob", "alice").toProto(); + fs.close(); + Query.fromProto(fs, proto); + } }