Repository: googleapis/google-cloud-python

Issue:
[Firestore] List collection ids of a document
How do I list subcollection ids of a document? Is it not yet implemented in the python firestore library? It's available for all other google cloud platform languages: [node](https://cloud.google.com/nodejs/docs/reference/firestore/0.14.x/DocumentReference#getCollections), [go](https://godoc.org/cloud.google.com/go/firestore#DocumentRef.Collections), [php](http://googlecloudplatform.github.io/google-cloud-php/#/docs/cloud-firestore/v0.11.0/firestore/documentreference?method=collections), [java](https://googlecloudplatform.github.io/google-cloud-java/google-cloud-clients/apidocs/index.html?com/google/cloud/firestore/package-summary.html), [ruby](http://googlecloudplatform.github.io/google-cloud-ruby/#/docs/google-cloud-firestore/v0.21.1/google/cloud/firestore/documentreference?method=cols-instance), [.NET](http://googlecloudplatform.github.io/google-cloud-dotnet/docs/Google.Cloud.Firestore/api/Google.Cloud.Firestore.DocumentReference.html#Google_Cloud_Firestore_DocumentReference_ListCollectionsAsync). There's no way that the python library is the only library that doesn't support this functionality, right?



Discussion:
@guanzo You are correct that the `DocumentReference` class does not expose a method to return subcollections.
@jba the [docs for listing subcollections](https://cloud.google.com/firestore/docs/query-data/get-data#list_subcollections_of_a_document) specifically exclude the feature for "mobile/web client" libraries.  I wouldn't think Python was one of them, but we don't support the feature.  Do you know if that was intended?
@schmidt-sebastian will know.
This is not intended and should be added to the Python SDK.
@theacodes, @schmidt-sebastian, @jba Can any of you tell me which project we are using to CI firestore?  It can't be done using our normal, datastore-enabled projects.
You have to create a new project and enable Firestore without enabling Datastore. The easiest way to do this is through the Firebase Console.
Presumably Python has such a project.
Right, there is one being used for CI, but the credentials (including the project) are stored encrypted, and decrypted using CircleCI env vars.  I'm not sure who on the team knows the actual project ID. @dhermes, maybe?
Projects to which I have access on https://console.cloud.google.com/:

```
google-cloud-node-no-whitelist | gcloud-node-whitelist-ci-tests
google-cloud-python | precise-truck-742
JJG Cloud Research | jjg-cloud-research
tresseaver-gcloud-research | citric-celerity-697
```

Of those, the main Python CI one (`precise-truck-742`) and @jgeewax's personal project (`jjg-cloud-research`) have the Firestore API enabled, but neither can actually use it, because they also have the Datastore API enabled.
@crwilcox Maybe you have the keys?
@jba Is the desired behavior just to list sub-collection IDs, or should the method return references to the subcollections?  Maybe the better way to ask the question would be, "Is there a conformance test for this feature?"
There's no conformance test, but there is a clear description of what should be returned in [the client API doc](https://docs.google.com/document/d/1PjoomZav75_wevZxTarHWqkAlcLnNBAhLBJ4NN_eCPs/edit#heading=h.gvhbp6ce4qht):
> The getCollections() method returns a list of results of CollectionReferences that are direct subscollections of this document.  
@jba OK, thanks -- I must've been misreading the [snippets on the "Getting Data" doc](https://cloud.google.com/firestore/docs/query-data/get-data#list_subcollections_of_a_document).

Resolving patch:
diff --git a/firestore/google/cloud/firestore_v1beta1/document.py b/firestore/google/cloud/firestore_v1beta1/document.py
--- a/firestore/google/cloud/firestore_v1beta1/document.py
+++ b/firestore/google/cloud/firestore_v1beta1/document.py
@@ -422,6 +422,25 @@ def get(self, field_paths=None, transaction=None):
             [self], field_paths=field_paths, transaction=transaction)
         return _consume_single_get(snapshot_generator)
 
+    def collections(self, page_size=None):
+        """List subcollections of the current document.
+
+        Args:
+            page_size (Optional[int]]): Iterator page size.
+
+        Returns:
+            Sequence[~.firestore_v1beta1.collection.CollectionReference[:
+                iterator of subcollections of the current document. If the
+                document does not exist at the time of `snapshot`, the
+                iterator will be empty
+        """
+        iterator = self._client._firestore_api.list_collection_ids(
+            self._document_path, page_size=page_size,
+            metadata=self._client._rpc_metadata)
+        iterator.document = self
+        iterator.item_to_value = _item_to_collection_ref
+        return iterator
+
 
 class DocumentSnapshot(object):
     """A snapshot of document data in a Firestore database.
@@ -658,3 +677,14 @@ def _first_write_result(write_results):
         raise ValueError('Expected at least one write result')
 
     return write_results[0]
+
+
+def _item_to_collection_ref(iterator, item):
+    """Convert collection ID to collection ref.
+
+    Args:
+        iterator (google.api_core.page_iterator.GRPCIterator):
+            iterator response
+        item (str): ID of the collection
+    """
+    return iterator.document.collection(item)


Test patch:
diff --git a/firestore/tests/system.py b/firestore/tests/system.py
--- a/firestore/tests/system.py
+++ b/firestore/tests/system.py
@@ -104,6 +104,28 @@ def test_create_document(client, cleanup):
     assert stored_data == expected_data
 
 
+def test_create_document_w_subcollection(client, cleanup):
+    document_id = 'shun' + unique_resource_id('-')
+    document = client.document('collek', document_id)
+    # Add to clean-up before API request (in case ``create()`` fails).
+    cleanup(document)
+
+    data = {
+        'now': firestore.SERVER_TIMESTAMP,
+    }
+    document.create(data)
+
+    child_ids = ['child1', 'child2']
+
+    for child_id in child_ids:
+        subcollection = document.collection(child_id)
+        _, subdoc = subcollection.add({'foo': 'bar'})
+        cleanup(subdoc)
+
+    children = document.collections()
+    assert sorted(child.id for child in children) == sorted(child_ids)
+
+
 def test_cannot_use_foreign_key(client, cleanup):
     document_id = 'cannot' + unique_resource_id('-')
     document = client.document('foreign-key', document_id)
diff --git a/firestore/tests/unit/test_document.py b/firestore/tests/unit/test_document.py
--- a/firestore/tests/unit/test_document.py
+++ b/firestore/tests/unit/test_document.py
@@ -511,6 +511,51 @@ def test_get_not_found(self):
         client.get_all.assert_called_once_with(
             [document], field_paths=field_paths, transaction=None)
 
+    def _collections_helper(self, page_size=None):
+        from google.api_core import grpc_helpers
+        from google.cloud.firestore_v1beta1.collection import (
+            CollectionReference)
+        from google.cloud.firestore_v1beta1.gapic.firestore_client import (
+            FirestoreClient)
+        from google.cloud.firestore_v1beta1.proto import firestore_pb2
+
+        collection_ids = ['coll-1', 'coll-2']
+        list_coll_response = firestore_pb2.ListCollectionIdsResponse(
+            collection_ids=collection_ids)
+        channel = grpc_helpers.ChannelStub()
+        api_client = FirestoreClient(channel=channel)
+        channel.ListCollectionIds.response = list_coll_response
+
+        client = _make_client()
+        client._firestore_api_internal = api_client
+
+        # Actually make a document and call delete().
+        document = self._make_one('where', 'we-are', client=client)
+        if page_size is not None:
+            collections = list(document.collections(page_size=page_size))
+        else:
+            collections = list(document.collections())
+
+        # Verify the response and the mocks.
+        self.assertEqual(len(collections), len(collection_ids))
+        for collection, collection_id in zip(collections, collection_ids):
+            self.assertIsInstance(collection, CollectionReference)
+            self.assertEqual(collection.parent, document)
+            self.assertEqual(collection.id, collection_id)
+
+        request, = channel.ListCollectionIds.requests
+        self.assertEqual(request.parent, document._document_path)
+        if page_size is None:
+            self.assertEqual(request.page_size, 0)
+        else:
+            self.assertEqual(request.page_size, page_size)
+
+    def test_collections_wo_page_size(self):
+        self._collections_helper()
+
+    def test_collections_w_page_size(self):
+        self._collections_helper(page_size=10)
+
 
 class TestDocumentSnapshot(unittest.TestCase):