firestore: allow StartAt/EndBefore on direct children at any depth

In firestore, it is legal to construct a query with a StartAt or EndBefore
filter. These accept document snapshots (snaps) as input parameters. If a snap
is passed, that snap must be a direct child of the location being queried.

However, the current implementation has a bug that only allows children of
documents at depth=1 to be specified: an extra /documents/ gets added to the
collectionPath of each collection: e.g. querying /c1/d1/c2 with StartAt at
/c1/d1/c2/d2 resulted in an incorrect path
/database/some-db/c1/d1/c2/documents/d2. It should be
/database/some-db/documents/c1/d1/c2/d2.

So, despite being a valid StartAt value, the user experiences an error. This CL
fixes that issue.

Finally, some documentation was updated to make all this behavior more clear.

Fixes #1225

Change-Id: I7f467701d5ccf4b5a77bb3c4b1960e6ef173dfb3
Reviewed-on: https://code-review.googlesource.com/c/35790
Reviewed-by: Eno Compton <enocom@google.com>
diff --git a/firestore/client_test.go b/firestore/client_test.go
index 92ff877..332c1b2 100644
--- a/firestore/client_test.go
+++ b/firestore/client_test.go
@@ -35,19 +35,26 @@
 	wantc1 := &CollectionRef{
 		c:          testClient,
 		parentPath: db,
+		selfPath:   "X",
 		Parent:     nil,
 		ID:         "X",
 		Path:       "projects/projectID/databases/(default)/documents/X",
-		Query:      Query{c: testClient, collectionID: "X", parentPath: db},
+		Query: Query{
+			c:            testClient,
+			collectionID: "X",
+			path:         "projects/projectID/databases/(default)/documents/X",
+			parentPath:   db,
+		},
 	}
 	if !testEqual(coll1, wantc1) {
 		t.Fatalf("got\n%+v\nwant\n%+v", coll1, wantc1)
 	}
 	doc1 := testClient.Doc("X/a")
 	wantd1 := &DocumentRef{
-		Parent: coll1,
-		ID:     "a",
-		Path:   "projects/projectID/databases/(default)/documents/X/a",
+		Parent:    coll1,
+		ID:        "a",
+		Path:      "projects/projectID/databases/(default)/documents/X/a",
+		shortPath: "X/a",
 	}
 
 	if !testEqual(doc1, wantd1) {
@@ -58,19 +65,26 @@
 	wantc2 := &CollectionRef{
 		c:          testClient,
 		parentPath: parentPath,
+		selfPath:   "X/a/Y",
 		Parent:     doc1,
 		ID:         "Y",
 		Path:       "projects/projectID/databases/(default)/documents/X/a/Y",
-		Query:      Query{c: testClient, collectionID: "Y", parentPath: parentPath},
+		Query: Query{
+			c:            testClient,
+			collectionID: "Y",
+			parentPath:   parentPath,
+			path:         "projects/projectID/databases/(default)/documents/X/a/Y",
+		},
 	}
 	if !testEqual(coll2, wantc2) {
 		t.Fatalf("\ngot  %+v\nwant %+v", coll2, wantc2)
 	}
 	doc2 := testClient.Doc("X/a/Y/b")
 	wantd2 := &DocumentRef{
-		Parent: coll2,
-		ID:     "b",
-		Path:   "projects/projectID/databases/(default)/documents/X/a/Y/b",
+		Parent:    coll2,
+		ID:        "b",
+		Path:      "projects/projectID/databases/(default)/documents/X/a/Y/b",
+		shortPath: "X/a/Y/b",
 	}
 	if !testEqual(doc2, wantd2) {
 		t.Fatalf("got %+v, want %+v", doc2, wantd2)
diff --git a/firestore/collref.go b/firestore/collref.go
index ff445d1..4f9f365 100644
--- a/firestore/collref.go
+++ b/firestore/collref.go
@@ -26,11 +26,17 @@
 type CollectionRef struct {
 	c *Client
 
-	// Typically Parent.Path, or c.path if Parent is nil.
-	// May be different if this CollectionRef was created from a stored reference
-	// to a different project/DB.
+	// The full resource path of the collection's parent. Typically Parent.Path,
+	// or c.path if Parent is nil. May be different if this CollectionRef was
+	// created from a stored reference to a different project/DB.
+	//
+	// For example, "projects/P/databases/D/documents/coll-1/doc-1".
 	parentPath string
 
+	// The shorter resource path of the collection. A collection "coll-2" in
+	// document "doc-1" in collection "coll-1" would be: "coll-1/doc-1/coll-2".
+	selfPath string
+
 	// Parent is the document of which this collection is a part. It is
 	// nil for top-level collections.
 	Parent *DocumentRef
@@ -50,19 +56,32 @@
 		c:          c,
 		ID:         id,
 		parentPath: dbPath,
+		selfPath:   id,
 		Path:       dbPath + "/documents/" + id,
-		Query:      Query{c: c, collectionID: id, parentPath: dbPath},
+		Query: Query{
+			c:            c,
+			collectionID: id,
+			path:         dbPath + "/documents/" + id,
+			parentPath:   dbPath,
+		},
 	}
 }
 
 func newCollRefWithParent(c *Client, parent *DocumentRef, id string) *CollectionRef {
+	selfPath := parent.shortPath + "/" + id
 	return &CollectionRef{
 		c:          c,
 		Parent:     parent,
 		ID:         id,
 		parentPath: parent.Path,
+		selfPath:   selfPath,
 		Path:       parent.Path + "/" + id,
-		Query:      Query{c: c, collectionID: id, parentPath: parent.Path},
+		Query: Query{
+			c:            c,
+			collectionID: id,
+			path:         parent.Path + "/" + id,
+			parentPath:   parent.Path,
+		},
 	}
 }
 
diff --git a/firestore/collref_test.go b/firestore/collref_test.go
index e77c0db..b4c0aad 100644
--- a/firestore/collref_test.go
+++ b/firestore/collref_test.go
@@ -26,9 +26,10 @@
 	coll := testClient.Collection("C")
 	got := coll.Doc("d")
 	want := &DocumentRef{
-		Parent: coll,
-		ID:     "d",
-		Path:   "projects/projectID/databases/(default)/documents/C/d",
+		Parent:    coll,
+		ID:        "d",
+		Path:      "projects/projectID/databases/(default)/documents/C/d",
+		shortPath: "C/d",
 	}
 	if !testEqual(got, want) {
 		t.Errorf("got %+v, want %+v", got, want)
diff --git a/firestore/conformance_test.go b/firestore/conformance_test.go
index 1fcbaaa..f7ee7d5 100644
--- a/firestore/conformance_test.go
+++ b/firestore/conformance_test.go
@@ -75,10 +75,10 @@
 func runTest(t *testing.T, msg string, test *pb.Test) {
 	check := func(gotErr error, wantErr bool) bool {
 		if wantErr && gotErr == nil {
-			t.Errorf("%s: got nil, want error", msg)
+			t.Fatalf("%s: got nil, want error", msg)
 			return false
 		} else if !wantErr && gotErr != nil {
-			t.Errorf("%s: %v", msg, gotErr)
+			t.Fatalf("%s: %v", msg, gotErr)
 			return false
 		}
 		return true
@@ -105,7 +105,7 @@
 		ref := docRefFromPath(tt.Get.DocRefPath, c)
 		_, err := ref.Get(ctx)
 		if err != nil {
-			t.Errorf("%s: %v", msg, err)
+			t.Fatalf("%s: %v", msg, err)
 			return
 		}
 		// Checking response would just be testing the function converting a Document
@@ -116,7 +116,7 @@
 		ref := docRefFromPath(tt.Create.DocRefPath, c)
 		data, err := convertData(tt.Create.JsonData)
 		if err != nil {
-			t.Errorf("%s: %v", msg, err)
+			t.Fatalf("%s: %v", msg, err)
 			return
 		}
 		_, err = ref.Create(ctx, data)
@@ -127,7 +127,7 @@
 		ref := docRefFromPath(tt.Set.DocRefPath, c)
 		data, err := convertData(tt.Set.JsonData)
 		if err != nil {
-			t.Errorf("%s: %v", msg, err)
+			t.Fatalf("%s: %v", msg, err)
 			return
 		}
 		var opts []SetOption
@@ -172,7 +172,7 @@
 		got, err := q.toProto()
 		if check(err, tt.Query.IsError) && err == nil {
 			if want := tt.Query.Query; !proto.Equal(got, want) {
-				t.Errorf("%s\ngot:  %s\nwant: %s", msg, proto.MarshalTextString(got), proto.MarshalTextString(want))
+				t.Fatalf("%s\ngot:  %s\nwant: %s", msg, proto.MarshalTextString(got), proto.MarshalTextString(want))
 			}
 		}
 
@@ -190,14 +190,14 @@
 		}, rs)
 		got, err := nSnapshots(iter, len(tt.Listen.Snapshots))
 		if err != nil {
-			t.Errorf("%s: %v", msg, err)
+			t.Fatalf("%s: %v", msg, err)
 		} else if diff := cmp.Diff(got, tt.Listen.Snapshots); diff != "" {
-			t.Errorf("%s:\n%s", msg, diff)
+			t.Fatalf("%s:\n%s", msg, diff)
 		}
 		if tt.Listen.IsError {
 			_, err := iter.Next()
 			if err == nil {
-				t.Errorf("%s: got nil, want error", msg)
+				t.Fatalf("%s: got nil, want error", msg)
 			}
 		}
 
@@ -359,6 +359,7 @@
 	q := Query{
 		parentPath:   strings.Join(parts[:len(parts)-2], "/"),
 		collectionID: parts[len(parts)-1],
+		path:         qt.CollPath,
 	}
 	for _, c := range qt.Clauses {
 		switch c := c.Clause.(type) {
diff --git a/firestore/docref.go b/firestore/docref.go
index 422b6bd..159600d 100644
--- a/firestore/docref.go
+++ b/firestore/docref.go
@@ -36,18 +36,24 @@
 	// The CollectionRef that this document is a part of. Never nil.
 	Parent *CollectionRef
 
-	// The full resource path of the document: "projects/P/databases/D/documents..."
+	// The full resource path of the document. A document "doc-1" in collection
+	// "coll-1" would be: "projects/P/databases/D/documents/coll-1/doc-1".
 	Path string
 
+	// The shorter resource path of the document. A document "doc-1" in
+	// collection "coll-1" would be: "coll-1/doc-1".
+	shortPath string
+
 	// The ID of the document: the last component of the resource path.
 	ID string
 }
 
 func newDocRef(parent *CollectionRef, id string) *DocumentRef {
 	return &DocumentRef{
-		Parent: parent,
-		ID:     id,
-		Path:   parent.Path + "/" + id,
+		Parent:    parent,
+		ID:        id,
+		Path:      parent.Path + "/" + id,
+		shortPath: parent.selfPath + "/" + id,
 	}
 }
 
diff --git a/firestore/from_value_test.go b/firestore/from_value_test.go
index 0093795..bee7a4a 100644
--- a/firestore/from_value_test.go
+++ b/firestore/from_value_test.go
@@ -73,20 +73,26 @@
 		{
 			in: refval("projects/P/databases/D/documents/c/d"),
 			want: &DocumentRef{
-				ID: "d",
+				ID:        "d",
+				Path:      "projects/P/databases/D/documents/c/d",
+				shortPath: "c/d",
 				Parent: &CollectionRef{
 					ID:         "c",
 					parentPath: "projects/P/databases/D",
+					selfPath:   "c",
 					Path:       "projects/P/databases/D/documents/c",
-					Query:      Query{collectionID: "c", parentPath: "projects/P/databases/D"},
+					Query: Query{
+						collectionID: "c",
+						parentPath:   "projects/P/databases/D",
+						path:         "projects/P/databases/D/documents/c",
+					},
 				},
-				Path: "projects/P/databases/D/documents/c/d",
 			},
 		},
 	} {
 		got, err := createFromProtoValue(test.in, nil)
 		if err != nil {
-			t.Errorf("%v: %v", test.in, err)
+			t.Errorf("%+v: %+v", test.in, err)
 			continue
 		}
 		if !testEqual(got, test.want) {
@@ -497,30 +503,49 @@
 		t.Fatal(err)
 	}
 	want := &DocumentRef{
-		ID:   "d2",
-		Path: "projects/P/databases/D/documents/c1/d1/c2/d2",
+		ID:        "d2",
+		Path:      "projects/P/databases/D/documents/c1/d1/c2/d2",
+		shortPath: "c1/d1/c2/d2",
 		Parent: &CollectionRef{
 			ID:         "c2",
 			parentPath: "projects/P/databases/D/documents/c1/d1",
 			Path:       "projects/P/databases/D/documents/c1/d1/c2",
+			selfPath:   "c1/d1/c2",
 			c:          c,
-			Query:      Query{c: c, collectionID: "c2", parentPath: "projects/P/databases/D/documents/c1/d1"},
+			Query: Query{
+				c:            c,
+				collectionID: "c2",
+				parentPath:   "projects/P/databases/D/documents/c1/d1",
+				path:         "projects/P/databases/D/documents/c1/d1/c2",
+			},
 			Parent: &DocumentRef{
-				ID:   "d1",
-				Path: "projects/P/databases/D/documents/c1/d1",
+				ID:        "d1",
+				Path:      "projects/P/databases/D/documents/c1/d1",
+				shortPath: "c1/d1",
 				Parent: &CollectionRef{
 					ID:         "c1",
 					c:          c,
 					parentPath: "projects/P/databases/D",
 					Path:       "projects/P/databases/D/documents/c1",
+					selfPath:   "c1",
 					Parent:     nil,
-					Query:      Query{c: c, collectionID: "c1", parentPath: "projects/P/databases/D"},
+					Query: Query{
+						c:            c,
+						collectionID: "c1",
+						parentPath:   "projects/P/databases/D",
+						path:         "projects/P/databases/D/documents/c1",
+					},
 				},
 			},
 		},
 	}
 	if !testEqual(got, want) {
 		t.Errorf("\ngot  %+v\nwant %+v", got, want)
+		t.Logf("\ngot.Parent  %+v\nwant.Parent %+v", got.Parent, want.Parent)
+		t.Logf("\ngot.Parent.Query  %+v\nwant.Parent.Query %+v", got.Parent.Query, want.Parent.Query)
+		t.Logf("\ngot.Parent.Parent  %+v\nwant.Parent.Parent %+v", got.Parent.Parent, want.Parent.Parent)
+		t.Logf("\ngot.Parent.Parent.Parent  %+v\nwant.Parent.Parent.Parent %+v", got.Parent.Parent.Parent, want.Parent.Parent.Parent)
+		t.Logf("\ngot.Parent.Parent.Parent.Query  %+v\nwant.Parent.Parent.Parent.Query %+v", got.Parent.Parent.Parent.Query, want.Parent.Parent.Parent.Query)
 	}
 }
 
diff --git a/firestore/query.go b/firestore/query.go
index 6470439..c4a638d 100644
--- a/firestore/query.go
+++ b/firestore/query.go
@@ -35,7 +35,8 @@
 // a new Query; it does not modify the old.
 type Query struct {
 	c                      *Client
-	parentPath             string // path of the collection's parent
+	path                   string // path to query (collection)
+	parentPath             string // path of the collection's parent (document)
 	collectionID           string
 	selection              []FieldPath
 	filters                []filter
@@ -48,10 +49,6 @@
 	err                    error
 }
 
-func (q *Query) collectionPath() string {
-	return q.parentPath + "/documents/" + q.collectionID
-}
-
 // DocumentID is the special field name representing the ID of a document
 // in queries.
 const DocumentID = "__name__"
@@ -169,8 +166,10 @@
 // StartAt returns a new Query that specifies that results should start at
 // the document with the given field values.
 //
-// If StartAt is called with a single DocumentSnapshot, its field values are used.
-// The DocumentSnapshot must have all the fields mentioned in the OrderBy clauses.
+// StartAt may be called with a single DocumentSnapshot, representing an
+// existing document within the query. The document must be a direct child of
+// the location being queried (not a parent document, or document in a
+// different collection, or a grandchild document, for example).
 //
 // Otherwise, StartAt should be called with one field value for each OrderBy clause,
 // in the order that they appear. For example, in
@@ -375,7 +374,7 @@
 			if !ok {
 				return nil, fmt.Errorf("firestore: expected doc ID for DocumentID field, got %T", fval)
 			}
-			vals[i] = &pb.Value{ValueType: &pb.Value_ReferenceValue{q.collectionPath() + "/" + docID}}
+			vals[i] = &pb.Value{ValueType: &pb.Value_ReferenceValue{q.path + "/" + docID}}
 		} else {
 			var sawTransform bool
 			vals[i], sawTransform, err = toProtoValue(reflect.ValueOf(fval))
@@ -395,7 +394,7 @@
 	vals := make([]*pb.Value, len(orders))
 	for i, ord := range orders {
 		if ord.isDocumentID() {
-			dp, qp := ds.Ref.Parent.Path, q.collectionPath()
+			dp, qp := ds.Ref.Parent.Path, q.path
 			if dp != qp {
 				return nil, fmt.Errorf("firestore: document snapshot for %s passed to query on %s", dp, qp)
 			}
diff --git a/firestore/query_test.go b/firestore/query_test.go
index 3ed6307..848c647 100644
--- a/firestore/query_test.go
+++ b/firestore/query_test.go
@@ -545,18 +545,19 @@
 }
 
 func TestQueryFromCollectionRef(t *testing.T) {
-	c := &Client{}
+	c := &Client{projectID: "P", databaseID: "D"}
 	coll := c.Collection("C")
 	got := coll.Select("x").Offset(8)
 	want := Query{
 		c:            c,
 		parentPath:   c.path(),
+		path:         "projects/P/databases/D/documents/C",
 		collectionID: "C",
 		selection:    []FieldPath{{"x"}},
 		offset:       8,
 	}
 	if !testEqual(got, want) {
-		t.Fatalf("got %+v, want %+v", got, want)
+		t.Fatalf("\ngot  %+v, \nwant %+v", got, want)
 	}
 }
 
@@ -701,6 +702,138 @@
 	}
 }
 
+func TestQuerySubCollections(t *testing.T) {
+	c := &Client{projectID: "P", databaseID: "DB"}
+
+	/*
+		        parent-collection
+			+---------+  +---------+
+			|                      |
+			|                      |
+		parent-doc			some-other-parent-doc
+			|
+			|
+		sub-collection
+			|
+			|
+		sub-doc
+			|
+			|
+		sub-sub-collection
+			|
+			|
+		sub-sub-doc
+	*/
+	parentColl := c.Collection("parent-collection")
+	parentDoc := parentColl.Doc("parent-doc")
+	someOtherParentDoc := parentColl.Doc("some-other-parent-doc")
+	subColl := parentDoc.Collection("sub-collection")
+	subDoc := subColl.Doc("sub-doc")
+	subSubColl := subDoc.Collection("sub-sub-collection")
+	subSubDoc := subSubColl.Doc("sub-sub-doc")
+
+	testCases := []struct {
+		queryColl      *CollectionRef
+		queryFilterDoc *DocumentRef // startAt or endBefore
+		wantColl       string
+		wantRef        string
+		wantErr        bool
+	}{
+		// Queries are allowed at depth 0.
+		{parentColl, parentDoc, "parent-collection", "projects/P/databases/DB/documents/parent-collection/parent-doc", false},
+		// Queries are allowed at any depth.
+		{subColl, subDoc, "sub-collection", "projects/P/databases/DB/documents/parent-collection/parent-doc/sub-collection/sub-doc", false},
+		// Queries must be on immediate children (not allowed on grandchildren).
+		{subColl, someOtherParentDoc, "", "", true},
+		// Queries must be on immediate children (not allowed on siblings).
+		{subColl, subSubDoc, "", "", true},
+	}
+
+	// startAt
+	for _, testCase := range testCases {
+		// Query a child within the document.
+		q := testCase.queryColl.StartAt(&DocumentSnapshot{
+			Ref: testCase.queryFilterDoc,
+			proto: &pb.Document{
+				Fields: map[string]*pb.Value{"a": intval(7)},
+			},
+		}).OrderBy("a", Asc)
+		got, err := q.toProto()
+		if testCase.wantErr {
+			if err == nil {
+				t.Fatal("expected err, got nil")
+			}
+			continue
+		}
+
+		if err != nil {
+			t.Fatal(err)
+		}
+		want := &pb.StructuredQuery{
+			From: []*pb.StructuredQuery_CollectionSelector{
+				{CollectionId: testCase.wantColl},
+			},
+			OrderBy: []*pb.StructuredQuery_Order{
+				{Field: fref1("a"), Direction: pb.StructuredQuery_ASCENDING},
+				{Field: fref1("__name__"), Direction: pb.StructuredQuery_ASCENDING},
+			},
+			StartAt: &pb.Cursor{
+				Values: []*pb.Value{
+					intval(7),
+					// This is the only part of the assertion we really care about.
+					refval(testCase.wantRef),
+				},
+				Before: true,
+			},
+		}
+		if !testEqual(got, want) {
+			t.Fatalf("got\n%v\nwant\n%v", pretty.Value(got), pretty.Value(want))
+		}
+	}
+
+	// endBefore
+	for _, testCase := range testCases {
+		// Query a child within the document.
+		q := testCase.queryColl.EndBefore(&DocumentSnapshot{
+			Ref: testCase.queryFilterDoc,
+			proto: &pb.Document{
+				Fields: map[string]*pb.Value{"a": intval(7)},
+			},
+		}).OrderBy("a", Asc)
+		got, err := q.toProto()
+		if testCase.wantErr {
+			if err == nil {
+				t.Fatal("expected err, got nil")
+			}
+			continue
+		}
+
+		if err != nil {
+			t.Fatal(err)
+		}
+		want := &pb.StructuredQuery{
+			From: []*pb.StructuredQuery_CollectionSelector{
+				{CollectionId: testCase.wantColl},
+			},
+			OrderBy: []*pb.StructuredQuery_Order{
+				{Field: fref1("a"), Direction: pb.StructuredQuery_ASCENDING},
+				{Field: fref1("__name__"), Direction: pb.StructuredQuery_ASCENDING},
+			},
+			EndAt: &pb.Cursor{
+				Values: []*pb.Value{
+					intval(7),
+					// This is the only part of the assertion we really care about.
+					refval(testCase.wantRef),
+				},
+				Before: true,
+			},
+		}
+		if !testEqual(got, want) {
+			t.Fatalf("got\n%v\nwant\n%v", pretty.Value(got), pretty.Value(want))
+		}
+	}
+}
+
 // Stop should be callable on an uninitialized QuerySnapshotIterator.
 func TestStop_Uninitialized(t *testing.T) {
 	i := &QuerySnapshotIterator{}