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));
+ }
+}