diff --git a/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/CountTest.java b/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/CountTest.java new file mode 100644 index 00000000000..5dffb4a70c4 --- /dev/null +++ b/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/CountTest.java @@ -0,0 +1,272 @@ +// Copyright 2022 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.firebase.firestore; + +import static com.google.firebase.firestore.testutil.IntegrationTestUtil.testCollection; +import static com.google.firebase.firestore.testutil.IntegrationTestUtil.testCollectionWithDocs; +import static com.google.firebase.firestore.testutil.IntegrationTestUtil.testFirestore; +import static com.google.firebase.firestore.testutil.IntegrationTestUtil.waitFor; +import static com.google.firebase.firestore.testutil.IntegrationTestUtil.waitForException; +import static com.google.firebase.firestore.testutil.TestUtil.map; +import static org.hamcrest.CoreMatchers.instanceOf; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertTrue; + +import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.firebase.firestore.testutil.IntegrationTestUtil; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +@RunWith(AndroidJUnit4.class) +public class CountTest { + + @Before + public void setUp() { + // TODO(b/243368243): Remove this once backend is ready to support count. + org.junit.Assume.assumeTrue(BuildConfig.USE_EMULATOR_FOR_TESTS); + } + + @After + public void tearDown() { + IntegrationTestUtil.tearDown(); + } + + @Test + public void testCountQueryEquals() { + CollectionReference coll1 = testCollection("foo"); + CollectionReference coll1_same = coll1.firestore.collection(coll1.getPath()); + AggregateQuery query1 = coll1.count(); + AggregateQuery query1_same = coll1_same.count(); + AggregateQuery query2 = + coll1.document("bar").collection("baz").whereEqualTo("a", 1).limit(100).count(); + AggregateQuery query2_same = + coll1.document("bar").collection("baz").whereEqualTo("a", 1).limit(100).count(); + AggregateQuery query3 = + coll1.document("bar").collection("baz").whereEqualTo("b", 1).orderBy("c").count(); + AggregateQuery query3_same = + coll1.document("bar").collection("baz").whereEqualTo("b", 1).orderBy("c").count(); + + assertTrue(query1.equals(query1_same)); + assertTrue(query2.equals(query2_same)); + assertTrue(query3.equals(query3_same)); + + assertEquals(query1.hashCode(), query1_same.hashCode()); + assertEquals(query2.hashCode(), query2_same.hashCode()); + assertEquals(query3.hashCode(), query3_same.hashCode()); + + assertFalse(query1.equals(null)); + assertFalse(query1.equals("string")); + assertFalse(query1.equals(query2)); + assertFalse(query2.equals(query3)); + assertNotEquals(query1.hashCode(), query2.hashCode()); + assertNotEquals(query2.hashCode(), query3.hashCode()); + } + + @Test + public void testCanRunCount() { + CollectionReference collection = + testCollectionWithDocs( + map( + "a", map("k", "a"), + "b", map("k", "b"), + "c", map("k", "c"))); + + AggregateQuerySnapshot snapshot = + waitFor(collection.count().get(AggregateSource.SERVER_DIRECT)); + assertEquals(Long.valueOf(3), snapshot.getCount()); + } + + @Test + public void testCanRunCountWithFilters() { + CollectionReference collection = + testCollectionWithDocs( + map( + "a", map("k", "a"), + "b", map("k", "b"), + "c", map("k", "c"))); + + AggregateQuerySnapshot snapshot = + waitFor(collection.whereEqualTo("k", "b").count().get(AggregateSource.SERVER_DIRECT)); + assertEquals(Long.valueOf(1), snapshot.getCount()); + } + + @Test + public void testCanRunCountWithOrderBy() { + CollectionReference collection = + testCollectionWithDocs( + map( + "a", map("k", "a"), + "b", map("k", "b"), + "c", map("k", "c"), + "d", map("absent", "d"))); + + AggregateQuerySnapshot snapshot = + waitFor(collection.orderBy("k").count().get(AggregateSource.SERVER_DIRECT)); + // "d" is filtered out because it is ordered by "k". + assertEquals(Long.valueOf(3), snapshot.getCount()); + } + + @Test + public void testTerminateDoesNotCrashWithFlyingCountQuery() { + CollectionReference collection = + testCollectionWithDocs( + map( + "a", map("k", "a"), + "b", map("k", "b"), + "c", map("k", "c"))); + + collection.orderBy("k").count().get(AggregateSource.SERVER_DIRECT); + waitFor(collection.firestore.terminate()); + } + + @Test + public void testSnapshotEquals() { + CollectionReference collection = + testCollectionWithDocs( + map( + "a", map("k", "a"), + "b", map("k", "b"), + "c", map("k", "c"))); + + AggregateQuerySnapshot snapshot1 = + waitFor(collection.whereEqualTo("k", "b").count().get(AggregateSource.SERVER_DIRECT)); + AggregateQuerySnapshot snapshot1_same = + waitFor(collection.whereEqualTo("k", "b").count().get(AggregateSource.SERVER_DIRECT)); + + AggregateQuerySnapshot snapshot2 = + waitFor(collection.whereEqualTo("k", "a").count().get(AggregateSource.SERVER_DIRECT)); + waitFor(collection.document("d").set(map("k", "a"))); + AggregateQuerySnapshot snapshot2_different = + waitFor(collection.whereEqualTo("k", "a").count().get(AggregateSource.SERVER_DIRECT)); + + assertTrue(snapshot1.equals(snapshot1_same)); + assertEquals(snapshot1.hashCode(), snapshot1_same.hashCode()); + assertTrue(snapshot1.getQuery().equals(collection.whereEqualTo("k", "b").count())); + + assertFalse(snapshot1.equals(null)); + assertFalse(snapshot1.equals("string")); + assertFalse(snapshot1.equals(snapshot2)); + assertNotEquals(snapshot1.hashCode(), snapshot2.hashCode()); + assertFalse(snapshot2.equals(snapshot2_different)); + assertNotEquals(snapshot2.hashCode(), snapshot2_different.hashCode()); + } + + @Test + public void testCanRunCollectionGroupQuery() { + FirebaseFirestore db = testFirestore(); + // Use .document() to get a random collection group name to use but ensure it starts with 'b' + // for predictable ordering. + String collectionGroup = "b" + db.collection("foo").document().getId(); + + String[] docPaths = + new String[] { + "abc/123/${collectionGroup}/cg-doc1", + "abc/123/${collectionGroup}/cg-doc2", + "${collectionGroup}/cg-doc3", + "${collectionGroup}/cg-doc4", + "def/456/${collectionGroup}/cg-doc5", + "${collectionGroup}/virtual-doc/nested-coll/not-cg-doc", + "x${collectionGroup}/not-cg-doc", + "${collectionGroup}x/not-cg-doc", + "abc/123/${collectionGroup}x/not-cg-doc", + "abc/123/x${collectionGroup}/not-cg-doc", + "abc/${collectionGroup}" + }; + WriteBatch batch = db.batch(); + for (String path : docPaths) { + batch.set(db.document(path.replace("${collectionGroup}", collectionGroup)), map("x", 1)); + } + waitFor(batch.commit()); + + AggregateQuerySnapshot snapshot = + waitFor(db.collectionGroup(collectionGroup).count().get(AggregateSource.SERVER_DIRECT)); + assertEquals( + Long.valueOf(5), // "cg-doc1", "cg-doc2", "cg-doc3", "cg-doc4", "cg-doc5", + snapshot.getCount()); + } + + @Test + public void testCanRunCountWithFiltersAndLimits() { + CollectionReference collection = + testCollectionWithDocs( + map( + "a", map("k", "a"), + "b", map("k", "a"), + "c", map("k", "a"), + "d", map("k", "d"))); + + AggregateQuerySnapshot snapshot = + waitFor( + collection.whereEqualTo("k", "a").limit(2).count().get(AggregateSource.SERVER_DIRECT)); + assertEquals(Long.valueOf(2), snapshot.getCount()); + + snapshot = + waitFor( + collection + .whereEqualTo("k", "a") + .limitToLast(2) + .count() + .get(AggregateSource.SERVER_DIRECT)); + assertEquals(Long.valueOf(2), snapshot.getCount()); + + snapshot = + waitFor( + collection + .whereEqualTo("k", "d") + .limitToLast(1000) + .count() + .get(AggregateSource.SERVER_DIRECT)); + assertEquals(Long.valueOf(1), snapshot.getCount()); + } + + @Test + public void testCanRunCountOnNonExistentCollection() { + CollectionReference collection = testFirestore().collection("random-coll"); + + AggregateQuerySnapshot snapshot = + waitFor(collection.count().get(AggregateSource.SERVER_DIRECT)); + assertEquals(Long.valueOf(0), snapshot.getCount()); + + snapshot = + waitFor(collection.whereEqualTo("k", 100).count().get(AggregateSource.SERVER_DIRECT)); + assertEquals(Long.valueOf(0), snapshot.getCount()); + } + + @Test + public void testFailWithoutNetwork() { + CollectionReference collection = + testCollectionWithDocs( + map( + "a", map("k", "a"), + "b", map("k", "b"), + "c", map("k", "c"))); + waitFor(collection.getFirestore().disableNetwork()); + + Exception e = waitForException(collection.count().get(AggregateSource.SERVER_DIRECT)); + assertThat(e, instanceOf(FirebaseFirestoreException.class)); + assertEquals( + FirebaseFirestoreException.Code.UNAVAILABLE, ((FirebaseFirestoreException) e).getCode()); + + waitFor(collection.getFirestore().enableNetwork()); + AggregateQuerySnapshot snapshot = + waitFor(collection.count().get(AggregateSource.SERVER_DIRECT)); + assertEquals(Long.valueOf(3), snapshot.getCount()); + } +} diff --git a/firebase-firestore/src/main/java/com/google/firebase/firestore/AggregateQuery.java b/firebase-firestore/src/main/java/com/google/firebase/firestore/AggregateQuery.java new file mode 100644 index 00000000000..179bc6e1b2d --- /dev/null +++ b/firebase-firestore/src/main/java/com/google/firebase/firestore/AggregateQuery.java @@ -0,0 +1,85 @@ +// Copyright 2022 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.firebase.firestore; + +import androidx.annotation.NonNull; +import com.google.android.gms.tasks.Task; +import com.google.android.gms.tasks.TaskCompletionSource; +import com.google.firebase.firestore.util.Executors; +import com.google.firebase.firestore.util.Preconditions; + +/** + * A {@code AggregateQuery} computes some aggregation statistics from the result set of a base + * {@link Query}. + * + *

Subclassing Note: Cloud Firestore classes are not meant to be subclassed except for use + * in test mocks. Subclassing is not supported in production code and new SDK releases may break + * code that does so. + */ +class AggregateQuery { + // The base query. + private final Query query; + + AggregateQuery(@NonNull Query query) { + this.query = query; + } + + /** Returns the base {@link Query} for this aggregate query. */ + @NonNull + public Query getQuery() { + return query; + } + + /** + * Executes the aggregate query and returns the results as a {@code AggregateQuerySnapshot}. + * + * @param source A value to configure the get behavior. + * @return A Task that will be resolved with the results of the {@code AggregateQuery}. + */ + @NonNull + public Task get(@NonNull AggregateSource source) { + Preconditions.checkNotNull(source, "AggregateSource must not be null"); + TaskCompletionSource tcs = new TaskCompletionSource<>(); + query + .firestore + .getClient() + .runCountQuery(query.query) + .continueWith( + Executors.DIRECT_EXECUTOR, + (task) -> { + if (task.isSuccessful()) { + tcs.setResult(new AggregateQuerySnapshot(this, task.getResult())); + } else { + tcs.setException(task.getException()); + } + return null; + }); + + return tcs.getTask(); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof AggregateQuery)) return false; + AggregateQuery that = (AggregateQuery) o; + return query.equals(that.query); + } + + @Override + public int hashCode() { + return query.hashCode(); + } +} diff --git a/firebase-firestore/src/main/java/com/google/firebase/firestore/AggregateQuerySnapshot.java b/firebase-firestore/src/main/java/com/google/firebase/firestore/AggregateQuerySnapshot.java new file mode 100644 index 00000000000..0ec69134471 --- /dev/null +++ b/firebase-firestore/src/main/java/com/google/firebase/firestore/AggregateQuerySnapshot.java @@ -0,0 +1,68 @@ +// Copyright 2022 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.firebase.firestore; + +import static com.google.firebase.firestore.util.Preconditions.checkNotNull; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import java.util.Objects; + +/** + * A {@code AggregateQuerySnapshot} contains results of a {@link AggregateQuery}. + * + *

Subclassing Note: Cloud Firestore classes are not meant to be subclassed except for use + * in test mocks. Subclassing is not supported in production code and new SDK releases may break + * code that does so. + */ +class AggregateQuerySnapshot { + + private final long count; + private final AggregateQuery query; + + AggregateQuerySnapshot(@NonNull AggregateQuery query, long count) { + checkNotNull(query); + this.query = query; + this.count = count; + } + + /** @return The original {@link AggregateQuery} this snapshot is a result of. */ + @NonNull + public AggregateQuery getQuery() { + return query; + } + + /** + * @return The result of a document count aggregation. Returns null if no count aggregation is + * available in the result. + */ + @Nullable + public Long getCount() { + return count; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof AggregateQuerySnapshot)) return false; + AggregateQuerySnapshot snapshot = (AggregateQuerySnapshot) o; + return count == snapshot.count && query.equals(snapshot.query); + } + + @Override + public int hashCode() { + return Objects.hash(count, query); + } +} diff --git a/firebase-firestore/src/main/java/com/google/firebase/firestore/AggregateSource.java b/firebase-firestore/src/main/java/com/google/firebase/firestore/AggregateSource.java new file mode 100644 index 00000000000..afb235df184 --- /dev/null +++ b/firebase-firestore/src/main/java/com/google/firebase/firestore/AggregateSource.java @@ -0,0 +1,26 @@ +// Copyright 2022 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.firebase.firestore; + +/** Configures the behavior of {@link AggregateQuery#get}. */ +enum AggregateSource { + /** + * Reach to the Firestore backend and surface the result verbatim, that is no local documents or + * mutations in the SDK cache will be included in the surfaced result. + * + *

Requires client to be online. + */ + SERVER_DIRECT, +} diff --git a/firebase-firestore/src/main/java/com/google/firebase/firestore/Query.java b/firebase-firestore/src/main/java/com/google/firebase/firestore/Query.java index 9978ec0b3e5..125b93c53a4 100644 --- a/firebase-firestore/src/main/java/com/google/firebase/firestore/Query.java +++ b/firebase-firestore/src/main/java/com/google/firebase/firestore/Query.java @@ -1223,6 +1223,17 @@ private void validateHasExplicitOrderByForLimitToLast() { } } + /** + * Creates an {@link AggregateQuery} counting the number of documents matching this query. + * + * @return An {@link AggregateQuery} object that can be used to count the number of documents in + * the result set of this query. + */ + @NonNull + AggregateQuery count() { + return new AggregateQuery(this); + } + @Override public boolean equals(Object o) { if (this == o) { diff --git a/firebase-firestore/src/main/java/com/google/firebase/firestore/core/FirestoreClient.java b/firebase-firestore/src/main/java/com/google/firebase/firestore/core/FirestoreClient.java index 464cd5913f9..e75868b0ab6 100644 --- a/firebase-firestore/src/main/java/com/google/firebase/firestore/core/FirestoreClient.java +++ b/firebase-firestore/src/main/java/com/google/firebase/firestore/core/FirestoreClient.java @@ -237,6 +237,18 @@ public Task transaction( () -> syncEngine.transaction(asyncQueue, options, updateFunction)); } + public Task runCountQuery(Query query) { + this.verifyNotTerminated(); + final TaskCompletionSource result = new TaskCompletionSource<>(); + asyncQueue.enqueueAndForget( + () -> + syncEngine + .runCountQuery(query) + .addOnSuccessListener(count -> result.setResult(count)) + .addOnFailureListener(e -> result.setException(e))); + return result.getTask(); + } + /** * Returns a task resolves when all the pending writes at the time when this method is called * received server acknowledgement. An acknowledgement can be either acceptance or rejections. diff --git a/firebase-firestore/src/main/java/com/google/firebase/firestore/core/SyncEngine.java b/firebase-firestore/src/main/java/com/google/firebase/firestore/core/SyncEngine.java index 452d7907c76..7513e2b9f27 100644 --- a/firebase-firestore/src/main/java/com/google/firebase/firestore/core/SyncEngine.java +++ b/firebase-firestore/src/main/java/com/google/firebase/firestore/core/SyncEngine.java @@ -314,6 +314,10 @@ public Task transaction( return new TransactionRunner(asyncQueue, remoteStore, options, updateFunction).run(); } + public Task runCountQuery(Query query) { + return remoteStore.runCountQuery(query); + } + /** Called by FirestoreClient to notify us of a new remote event. */ @Override public void handleRemoteEvent(RemoteEvent event) { diff --git a/firebase-firestore/src/main/java/com/google/firebase/firestore/remote/Datastore.java b/firebase-firestore/src/main/java/com/google/firebase/firestore/remote/Datastore.java index 46323e881b9..110d56a58a3 100644 --- a/firebase-firestore/src/main/java/com/google/firebase/firestore/remote/Datastore.java +++ b/firebase-firestore/src/main/java/com/google/firebase/firestore/remote/Datastore.java @@ -14,6 +14,7 @@ package com.google.firebase.firestore.remote; +import static com.google.firebase.firestore.util.Assert.hardAssert; import static com.google.firebase.firestore.util.Util.exceptionFromStatus; import android.content.Context; @@ -25,17 +26,23 @@ import com.google.firebase.firestore.auth.CredentialsProvider; import com.google.firebase.firestore.auth.User; import com.google.firebase.firestore.core.DatabaseInfo; +import com.google.firebase.firestore.core.Query; import com.google.firebase.firestore.model.DocumentKey; import com.google.firebase.firestore.model.MutableDocument; import com.google.firebase.firestore.model.SnapshotVersion; import com.google.firebase.firestore.model.mutation.Mutation; import com.google.firebase.firestore.model.mutation.MutationResult; import com.google.firebase.firestore.util.AsyncQueue; +import com.google.firestore.v1.AggregationResult; import com.google.firestore.v1.BatchGetDocumentsRequest; import com.google.firestore.v1.BatchGetDocumentsResponse; import com.google.firestore.v1.CommitRequest; import com.google.firestore.v1.CommitResponse; import com.google.firestore.v1.FirestoreGrpc; +import com.google.firestore.v1.RunAggregationQueryRequest; +import com.google.firestore.v1.RunAggregationQueryResponse; +import com.google.firestore.v1.StructuredAggregationQuery; +import com.google.firestore.v1.Value; import io.grpc.Status; import java.util.ArrayList; import java.util.Arrays; @@ -215,6 +222,54 @@ public void onClose(Status status) { return completionSource.getTask(); } + public Task runCountQuery(Query query) { + com.google.firestore.v1.Target.QueryTarget encodedQueryTarget = + serializer.encodeQueryTarget(query.toTarget()); + + StructuredAggregationQuery.Builder structuredAggregationQuery = + StructuredAggregationQuery.newBuilder(); + structuredAggregationQuery.setStructuredQuery(encodedQueryTarget.getStructuredQuery()); + + StructuredAggregationQuery.Aggregation.Builder aggregation = + StructuredAggregationQuery.Aggregation.newBuilder(); + aggregation.setCount(StructuredAggregationQuery.Aggregation.Count.getDefaultInstance()); + aggregation.setAlias("count_alias"); + structuredAggregationQuery.addAggregations(aggregation); + + RunAggregationQueryRequest.Builder request = RunAggregationQueryRequest.newBuilder(); + request.setParent(encodedQueryTarget.getParent()); + request.setStructuredAggregationQuery(structuredAggregationQuery); + + return channel + .runRpc(FirestoreGrpc.getRunAggregationQueryMethod(), request.build()) + .continueWith( + workerQueue.getExecutor(), + task -> { + if (!task.isSuccessful()) { + if (task.getException() instanceof FirebaseFirestoreException + && ((FirebaseFirestoreException) task.getException()).getCode() + == FirebaseFirestoreException.Code.UNAUTHENTICATED) { + channel.invalidateToken(); + } + throw task.getException(); + } + + RunAggregationQueryResponse response = task.getResult(); + + AggregationResult aggregationResult = response.getResult(); + Map aggregateFieldsByAlias = aggregationResult.getAggregateFieldsMap(); + hardAssert( + aggregateFieldsByAlias.size() == 1, + "aggregateFieldsByAlias.size()==" + aggregateFieldsByAlias.size()); + Value countValue = aggregateFieldsByAlias.get("count_alias"); + hardAssert(countValue != null, "countValue == null"); + hardAssert( + countValue.getValueTypeCase() == Value.ValueTypeCase.INTEGER_VALUE, + "countValue.getValueTypeCase() == " + countValue.getValueTypeCase()); + return countValue.getIntegerValue(); + }); + } + /** * Determines whether the given status has an error code that represents a permanent error when * received in response to a non-write operation. diff --git a/firebase-firestore/src/main/java/com/google/firebase/firestore/remote/RemoteStore.java b/firebase-firestore/src/main/java/com/google/firebase/firestore/remote/RemoteStore.java index 2af8508957d..3999f22a939 100644 --- a/firebase-firestore/src/main/java/com/google/firebase/firestore/remote/RemoteStore.java +++ b/firebase-firestore/src/main/java/com/google/firebase/firestore/remote/RemoteStore.java @@ -18,8 +18,12 @@ import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; +import com.google.android.gms.tasks.Task; +import com.google.android.gms.tasks.Tasks; import com.google.firebase.database.collection.ImmutableSortedSet; +import com.google.firebase.firestore.FirebaseFirestoreException; import com.google.firebase.firestore.core.OnlineState; +import com.google.firebase.firestore.core.Query; import com.google.firebase.firestore.core.Transaction; import com.google.firebase.firestore.local.LocalStore; import com.google.firebase.firestore.local.QueryPurpose; @@ -747,4 +751,14 @@ public ImmutableSortedSet getRemoteKeysForTarget(int targetId) { public TargetData getTargetDataForTarget(int targetId) { return this.listenTargets.get(targetId); } + + public Task runCountQuery(Query query) { + if (canUseNetwork()) { + return datastore.runCountQuery(query); + } else { + return Tasks.forException( + new FirebaseFirestoreException( + "Failed to get result from server.", FirebaseFirestoreException.Code.UNAVAILABLE)); + } + } } diff --git a/firebase-firestore/src/proto/google/firestore/v1/aggregation_result.proto b/firebase-firestore/src/proto/google/firestore/v1/aggregation_result.proto new file mode 100644 index 00000000000..538e3fef5e4 --- /dev/null +++ b/firebase-firestore/src/proto/google/firestore/v1/aggregation_result.proto @@ -0,0 +1,42 @@ +// Copyright 2022 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. + +syntax = "proto3"; + +package google.firestore.v1; + +import "google/firestore/v1/document.proto"; + +option csharp_namespace = "Google.Cloud.Firestore.V1"; +option go_package = "google.golang.org/genproto/googleapis/firestore/v1;firestore"; +option java_multiple_files = true; +option java_outer_classname = "AggregationResultProto"; +option java_package = "com.google.firestore.v1"; +option objc_class_prefix = "GCFS"; +option php_namespace = "Google\\Cloud\\Firestore\\V1"; +option ruby_package = "Google::Cloud::Firestore::V1"; + +// The result of a single bucket from a Firestore aggregation query. +// +// The keys of `aggregate_fields` are the same for all results in an aggregation +// query, unlike document queries which can have different fields present for +// each result. +message AggregationResult { + // The result of the aggregation functions, ex: `COUNT(*) AS total_docs`. + // + // The key is the [alias][google.firestore.v1.StructuredAggregationQuery.Aggregation.alias] + // assigned to the aggregation function on input and the size of this map + // equals the number of aggregation functions in the query. + map aggregate_fields = 2; +} diff --git a/firebase-firestore/src/proto/google/firestore/v1/firestore.proto b/firebase-firestore/src/proto/google/firestore/v1/firestore.proto index d425edf9e0f..dda4721596c 100644 --- a/firebase-firestore/src/proto/google/firestore/v1/firestore.proto +++ b/firebase-firestore/src/proto/google/firestore/v1/firestore.proto @@ -18,6 +18,7 @@ syntax = "proto3"; package google.firestore.v1; import "google/api/annotations.proto"; +import "google/firestore/v1/aggregation_result.proto"; import "google/firestore/v1/common.proto"; import "google/firestore/v1/document.proto"; import "google/firestore/v1/query.proto"; @@ -136,6 +137,29 @@ service Firestore { }; } + // Runs an aggregation query. + // + // Rather than producing [Document][google.firestore.v1.Document] results like [Firestore.RunQuery][google.firestore.v1.Firestore.RunQuery], + // this API allows running an aggregation to produce a series of + // [AggregationResult][google.firestore.v1.AggregationResult] server-side. + // + // High-Level Example: + // + // ``` + // -- Return the number of documents in table given a filter. + // SELECT COUNT(*) FROM ( SELECT * FROM k where a = true ); + // ``` + rpc RunAggregationQuery(RunAggregationQueryRequest) returns (stream RunAggregationQueryResponse) { + option (google.api.http) = { + post: "/v1/{parent=projects/*/databases/*/documents}:runAggregationQuery" + body: "*" + additional_bindings { + post: "/v1/{parent=projects/*/databases/*/documents/*/**}:runAggregationQuery" + body: "*" + } + }; + } + // Streams batches of document updates and deletes, in order. rpc Write(stream WriteRequest) returns (stream WriteResponse) { option (google.api.http) = { @@ -485,6 +509,62 @@ message RunQueryResponse { int32 skipped_results = 4; } +// The request for [Firestore.RunAggregationQuery][google.firestore.v1.Firestore.RunAggregationQuery]. +message RunAggregationQueryRequest { + // Required. The parent resource name. In the format: + // `projects/{project_id}/databases/{database_id}/documents` or + // `projects/{project_id}/databases/{database_id}/documents/{document_path}`. + // For example: + // `projects/my-project/databases/my-database/documents` or + // `projects/my-project/databases/my-database/documents/chatrooms/my-chatroom` + string parent = 1; + + // The query to run. + oneof query_type { + // An aggregation query. + StructuredAggregationQuery structured_aggregation_query = 2; + } + + // The consistency mode for the query, defaults to strong consistency. + oneof consistency_selector { + // Run the aggregation within an already active transaction. + // + // The value here is the opaque transaction ID to execute the query in. + bytes transaction = 4; + + // Starts a new transaction as part of the query, defaulting to read-only. + // + // The new transaction ID will be returned as the first response in the + // stream. + TransactionOptions new_transaction = 5; + + // Executes the query at the given timestamp. + // + // Requires: + // + // * Cannot be more than 270 seconds in the past. + google.protobuf.Timestamp read_time = 6; + } +} + +// The response for [Firestore.RunAggregationQuery][google.firestore.v1.Firestore.RunAggregationQuery]. +message RunAggregationQueryResponse { + // A single aggregation result. + // + // Not present when reporting partial progress or when the query produced + // zero results. + AggregationResult result = 1; + + // The transaction that was started as part of this request. + // + // Only present on the first response when the request requested to start + // a new transaction. + bytes transaction = 2; + + // The time at which the aggregate value is valid for. + google.protobuf.Timestamp read_time = 3; +} + // The request for [Firestore.Write][google.firestore.v1.Firestore.Write]. // // The first request creates a stream, or resumes an existing one from a token. diff --git a/firebase-firestore/src/proto/google/firestore/v1/query.proto b/firebase-firestore/src/proto/google/firestore/v1/query.proto index e1e7b30cec1..92e56fe9f33 100644 --- a/firebase-firestore/src/proto/google/firestore/v1/query.proto +++ b/firebase-firestore/src/proto/google/firestore/v1/query.proto @@ -291,6 +291,57 @@ message StructuredQuery { google.protobuf.Int32Value limit = 5; } +message StructuredAggregationQuery { + // Defines a aggregation that produces a single result. + message Aggregation { + // Count of documents that match the query. + // + // The `COUNT(*)` aggregation function operates on the entire document + // so it does not require a field reference. + message Count { + // Optional. Optional constraint on the maximum number of documents to count. + // + // This provides a way to set an upper bound on the number of documents + // to scan, limiting latency and cost. + // + // High-Level Example: + // + // ``` + // SELECT COUNT_UP_TO(1000) FROM ( SELECT * FROM k ); + // ``` + // + // Requires: + // + // * Must be greater than zero when present. + int32 up_to = 1; + } + + // The type of aggregation to perform, required. + oneof operator { + // Count aggregator. + Count count = 1; + } + + // Required. The name of the field to store the result of the aggregation into. + // + // Requires: + // + // * Must be present. + // * Must be unique across all aggregation aliases. + // * Conform to existing [document field name][google.firestore.v1.Document.fields] limitations. + string alias = 7; + } + + // The base query to aggregate over. + oneof query_type { + // Nested structured query. + StructuredQuery structured_query = 1; + } + + // Optional. Series of aggregations to apply on top of the `structured_query`. + repeated Aggregation aggregations = 3; +} + // A position in a query result set. message Cursor { // The values that represent a position, in the order they appear in diff --git a/firebase-firestore/src/test/java/com/google/firebase/firestore/AggregateQueryTest.java b/firebase-firestore/src/test/java/com/google/firebase/firestore/AggregateQueryTest.java new file mode 100644 index 00000000000..fd7c9764517 --- /dev/null +++ b/firebase-firestore/src/test/java/com/google/firebase/firestore/AggregateQueryTest.java @@ -0,0 +1,34 @@ +// Copyright 2022 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.firebase.firestore; + +import static com.google.firebase.firestore.testutil.Assert.assertThrows; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +@RunWith(RobolectricTestRunner.class) +@Config(manifest = Config.NONE) +public class AggregateQueryTest { + + @Test + public void testSourceMustNotBeNull() { + assertThrows( + NullPointerException.class, + () -> TestUtil.collectionReference("foo/bar/baz").count().get(null)); + } +}