firestore: add CollectionGroup method

Change-Id: I9bae0101f96a7637e0d98bed9f3ec9212ed93cdf
Reviewed-on: https://code-review.googlesource.com/c/gocloud/+/39150
Reviewed-by: kokoro <noreply+kokoro@google.com>
Reviewed-by: Tyler Bui-Palsulich <tbp@google.com>
diff --git a/firestore/client.go b/firestore/client.go
index a3e1243..8a03126 100644
--- a/firestore/client.go
+++ b/firestore/client.go
@@ -133,6 +133,19 @@
 	return doc
 }
 
+// CollectionGroup creates a reference to a group of collections that include
+// the given ID, regardless of parent document.
+//
+// For example, consider:
+// France/Cities/Paris = {population: 100}
+// Canada/Cities/Montreal = {population: 90}
+//
+// CollectionGroup can be used to query across all "Cities" regardless of
+// its parent "Countries". See ExampleCollectionGroup for a complete example.
+func (c *Client) CollectionGroup(collectionID string) *CollectionGroupRef {
+	return newCollectionGroupRef(c, c.path(), collectionID)
+}
+
 func (c *Client) idsToRef(IDs []string, dbPath string) (*CollectionRef, *DocumentRef) {
 	if len(IDs) == 0 {
 		return nil, nil
diff --git a/firestore/collgroupref.go b/firestore/collgroupref.go
new file mode 100644
index 0000000..e43a7e6
--- /dev/null
+++ b/firestore/collgroupref.go
@@ -0,0 +1,38 @@
+// Copyright 2019 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 firestore
+
+// A CollectionGroupRef is a reference to a group of collections sharing the
+// same ID.
+type CollectionGroupRef struct {
+	c *Client
+
+	// Use the methods of Query on a CollectionGroupRef to create and run queries.
+	Query
+}
+
+func newCollectionGroupRef(c *Client, dbPath, collectionID string) *CollectionGroupRef {
+	return &CollectionGroupRef{
+		c: c,
+
+		Query: Query{
+			c:              c,
+			collectionID:   collectionID,
+			path:           dbPath,
+			parentPath:     dbPath + "/documents",
+			allDescendants: true,
+		},
+	}
+}
diff --git a/firestore/examples_test.go b/firestore/examples_test.go
index 234cfc9..ee03496 100644
--- a/firestore/examples_test.go
+++ b/firestore/examples_test.go
@@ -607,3 +607,29 @@
 	}
 	fmt.Println(wr.UpdateTime)
 }
+
+func ExampleCollectionGroup() {
+	// Given:
+	// France/Cities/Paris = {population: 100}
+	// Canada/Cities/Montreal = {population: 95}
+
+	ctx := context.Background()
+	client, err := firestore.NewClient(ctx, "project-id")
+	if err != nil {
+		// TODO: Handle error.
+	}
+	defer client.Close()
+
+	// Query for ANY city with >95 pop, regardless of country.
+	docs, err := client.CollectionGroup("Cities").
+		Where("pop", ">", 95).
+		OrderBy("pop", firestore.Desc).
+		Limit(10).
+		Documents(ctx).
+		GetAll()
+	if err != nil {
+		// TODO: Handle error.
+	}
+
+	_ = docs // TODO: Use docs.
+}
diff --git a/firestore/integration_test.go b/firestore/integration_test.go
index 04f2ae8..ad295f8 100644
--- a/firestore/integration_test.go
+++ b/firestore/integration_test.go
@@ -1262,6 +1262,44 @@
 	}
 }
 
+func TestIntegration_CollectionGroupQueries(t *testing.T) {
+	shouldBeFoundID := collectionIDs.New()
+	shouldNotBeFoundID := collectionIDs.New()
+
+	ctx := context.Background()
+	h := testHelper{t}
+	client := integrationClient(t)
+	cr1 := client.Collection(shouldBeFoundID)
+	dr1 := cr1.Doc("should-be-found-1")
+	h.mustCreate(dr1, map[string]string{"some-key": "should-be-found"})
+	defer h.mustDelete(dr1)
+
+	dr1.Collection(shouldBeFoundID)
+	dr2 := cr1.Doc("should-be-found-2")
+	h.mustCreate(dr2, map[string]string{"some-key": "should-be-found"})
+	defer h.mustDelete(dr2)
+
+	cr3 := client.Collection(shouldNotBeFoundID)
+	dr3 := cr3.Doc("should-not-be-found")
+	h.mustCreate(dr3, map[string]string{"some-key": "should-NOT-be-found"})
+	defer h.mustDelete(dr3)
+
+	cg := client.CollectionGroup(shouldBeFoundID)
+	snaps, err := cg.Documents(ctx).GetAll()
+	if err != nil {
+		t.Fatal(err)
+	}
+	if len(snaps) != 2 {
+		t.Fatalf("expected 2 snapshots but got %d", len(snaps))
+	}
+	if snaps[0].Ref.ID != "should-be-found-1" {
+		t.Fatalf("expected ID 'should-be-found-1', got %s", snaps[0].Ref.ID)
+	}
+	if snaps[1].Ref.ID != "should-be-found-2" {
+		t.Fatalf("expected ID 'should-be-found-2', got %s", snaps[1].Ref.ID)
+	}
+}
+
 func codeEq(t *testing.T, msg string, code codes.Code, err error) {
 	if grpc.Code(err) != code {
 		t.Fatalf("%s:\ngot <%v>\nwant code %s", msg, err, code)
diff --git a/firestore/query.go b/firestore/query.go
index 16e258c..9aa01de 100644
--- a/firestore/query.go
+++ b/firestore/query.go
@@ -47,6 +47,10 @@
 	startDoc, endDoc       *DocumentSnapshot
 	startBefore, endBefore bool
 	err                    error
+
+	// allDescendants indicates whether this query is for all collections
+	// that match the ID under the specified parentPath.
+	allDescendants bool
 }
 
 // DocumentID is the special field name representing the ID of a document
@@ -120,8 +124,8 @@
 )
 
 // OrderBy returns a new Query that specifies the order in which results are
-// returned. A Query can have multiple OrderBy/OrderByPath specifications. OrderBy
-// appends the specification to the list of existing ones.
+// returned. A Query can have multiple OrderBy/OrderByPath specifications.
+// OrderBy appends the specification to the list of existing ones.
 //
 // The path argument can be a single field or a dot-separated sequence of
 // fields, and must not contain any of the runes "˜*/[]".
@@ -251,7 +255,10 @@
 		}
 	}
 	p := &pb.StructuredQuery{
-		From:   []*pb.StructuredQuery_CollectionSelector{{CollectionId: q.collectionID}},
+		From: []*pb.StructuredQuery_CollectionSelector{{
+			CollectionId:   q.collectionID,
+			AllDescendants: q.allDescendants,
+		}},
 		Offset: q.offset,
 		Limit:  q.limit,
 	}