Repository: googleapis/google-cloud-python

Issue:
[Firestore] Add list documents method to CollectionReference
CollectionReference.listDocuments() is missing.
This is new feature request and only available in Node, Java and Go so far.



Discussion:
How is this different than `CollectionReference.get`, which returns an iterable of `DocumentSnapshots` in the collection?
listDocuments() should use the ListDocumentRpc, which `showMissing` set to true. This will include missing documents (documents that don't themselves exist but have subcollections). These documents are not returned by the Query API, which makes traversing  the full set of data in a database impossible if there are such missing documents.
Furthermore, we only want to return DocumentReferences.

Resolving patch:
diff --git a/firestore/google/cloud/firestore_v1beta1/collection.py b/firestore/google/cloud/firestore_v1beta1/collection.py
--- a/firestore/google/cloud/firestore_v1beta1/collection.py
+++ b/firestore/google/cloud/firestore_v1beta1/collection.py
@@ -180,6 +180,33 @@ def add(self, document_data, document_id=None):
             write_result = document_ref.create(document_data)
             return write_result.update_time, document_ref
 
+    def list_documents(self, page_size=None):
+        """List all subdocuments of the current collection.
+
+        Args:
+            page_size (Optional[int]]): The maximum number of documents
+            in each page of results from this request. Non-positive values
+            are ignored. Defaults to a sensible value set by the API.
+
+        Returns:
+            Sequence[~.firestore_v1beta1.collection.DocumentReference]:
+                iterator of subdocuments of the current collection. If the
+                collection does not exist at the time of `snapshot`, the
+                iterator will be empty
+        """
+        parent, _ = self._parent_info()
+
+        iterator = self._client._firestore_api.list_documents(
+            parent,
+            self.id,
+            page_size=page_size,
+            show_missing=True,
+            metadata=self._client._rpc_metadata,
+        )
+        iterator.collection = self
+        iterator.item_to_value = _item_to_document_ref
+        return iterator
+
     def select(self, field_paths):
         """Create a "select" query with this collection as parent.
 
@@ -428,3 +455,15 @@ def _auto_id():
         lowercase and letters.
     """
     return "".join(random.choice(_AUTO_ID_CHARS) for _ in six.moves.xrange(20))
+
+
+def _item_to_document_ref(iterator, item):
+    """Convert Document resource to document ref.
+
+    Args:
+        iterator (google.api_core.page_iterator.GRPCIterator):
+            iterator response
+        item (dict): document resource
+    """
+    document_id = item.name.split(_helpers.DOCUMENT_PATH_DELIMITER)[-1]
+    return iterator.collection.document(document_id)


Test patch:
diff --git a/firestore/tests/system.py b/firestore/tests/system.py
--- a/firestore/tests/system.py
+++ b/firestore/tests/system.py
@@ -401,12 +401,20 @@ def test_document_delete(client, cleanup):
 def test_collection_add(client, cleanup):
     collection1 = client.collection("collek")
     collection2 = client.collection("collek", "shun", "child")
+    collection3 = client.collection("collek", "table", "child")
     explicit_doc_id = "hula" + unique_resource_id("-")
 
+    assert set(collection1.list_documents()) == set()
+    assert set(collection2.list_documents()) == set()
+    assert set(collection3.list_documents()) == set()
+
     # Auto-ID at top-level.
     data1 = {"foo": "bar"}
     update_time1, document_ref1 = collection1.add(data1)
     cleanup(document_ref1)
+    assert set(collection1.list_documents()) == {document_ref1}
+    assert set(collection2.list_documents()) == set()
+    assert set(collection3.list_documents()) == set()
     snapshot1 = document_ref1.get()
     assert snapshot1.to_dict() == data1
     assert snapshot1.update_time == update_time1
@@ -416,16 +424,28 @@ def test_collection_add(client, cleanup):
     data2 = {"baz": 999}
     update_time2, document_ref2 = collection1.add(data2, document_id=explicit_doc_id)
     cleanup(document_ref2)
+    assert set(collection1.list_documents()) == {document_ref1, document_ref2}
+    assert set(collection2.list_documents()) == set()
+    assert set(collection3.list_documents()) == set()
     snapshot2 = document_ref2.get()
     assert snapshot2.to_dict() == data2
     assert snapshot2.create_time == update_time2
     assert snapshot2.update_time == update_time2
     assert document_ref2.id == explicit_doc_id
 
+    nested_ref = collection1.document("shun")
+
     # Auto-ID for nested collection.
     data3 = {"quux": b"\x00\x01\x02\x03"}
     update_time3, document_ref3 = collection2.add(data3)
     cleanup(document_ref3)
+    assert set(collection1.list_documents()) == {
+        document_ref1,
+        document_ref2,
+        nested_ref,
+    }
+    assert set(collection2.list_documents()) == {document_ref3}
+    assert set(collection3.list_documents()) == set()
     snapshot3 = document_ref3.get()
     assert snapshot3.to_dict() == data3
     assert snapshot3.update_time == update_time3
@@ -435,12 +455,33 @@ def test_collection_add(client, cleanup):
     data4 = {"kazaam": None, "bad": False}
     update_time4, document_ref4 = collection2.add(data4, document_id=explicit_doc_id)
     cleanup(document_ref4)
+    assert set(collection1.list_documents()) == {
+        document_ref1,
+        document_ref2,
+        nested_ref,
+    }
+    assert set(collection2.list_documents()) == {document_ref3, document_ref4}
+    assert set(collection3.list_documents()) == set()
     snapshot4 = document_ref4.get()
     assert snapshot4.to_dict() == data4
     assert snapshot4.create_time == update_time4
     assert snapshot4.update_time == update_time4
     assert document_ref4.id == explicit_doc_id
 
+    # Exercise "missing" document (no doc, but subcollection).
+    data5 = {"bam": 123, "folyk": False}
+    update_time5, document_ref5 = collection3.add(data5)
+    cleanup(document_ref5)
+    missing_ref = collection1.document("table")
+    assert set(collection1.list_documents()) == {
+        document_ref1,
+        document_ref2,
+        nested_ref,
+        missing_ref,
+    }
+    assert set(collection2.list_documents()) == {document_ref3, document_ref4}
+    assert set(collection3.list_documents()) == {document_ref5}
+
 
 def test_query_get(client, cleanup):
     sub_collection = "child" + unique_resource_id("-")
diff --git a/firestore/tests/unit/test_collection.py b/firestore/tests/unit/test_collection.py
--- a/firestore/tests/unit/test_collection.py
+++ b/firestore/tests/unit/test_collection.py
@@ -437,6 +437,64 @@ def test_end_at(self):
         self.assertIs(query._parent, collection)
         self.assertEqual(query._end_at, (doc_fields, False))
 
+    def _list_documents_helper(self, page_size=None):
+        from google.api_core.page_iterator import Iterator
+        from google.api_core.page_iterator import Page
+        from google.cloud.firestore_v1beta1.document import DocumentReference
+        from google.cloud.firestore_v1beta1.gapic.firestore_client import (
+            FirestoreClient,
+        )
+        from google.cloud.firestore_v1beta1.proto.document_pb2 import Document
+
+        class _Iterator(Iterator):
+            def __init__(self, pages):
+                super(_Iterator, self).__init__(client=None)
+                self._pages = pages
+
+            def _next_page(self):
+                if self._pages:
+                    page, self._pages = self._pages[0], self._pages[1:]
+                    return Page(self, page, self.item_to_value)
+
+        client = _make_client()
+        template = client._database_string + "/documents/{}"
+        document_ids = ["doc-1", "doc-2"]
+        documents = [
+            Document(name=template.format(document_id)) for document_id in document_ids
+        ]
+        iterator = _Iterator(pages=[documents])
+        api_client = mock.create_autospec(FirestoreClient)
+        api_client.list_documents.return_value = iterator
+        client._firestore_api_internal = api_client
+        collection = self._make_one("collection", client=client)
+
+        if page_size is not None:
+            documents = list(collection.list_documents(page_size=page_size))
+        else:
+            documents = list(collection.list_documents())
+
+        # Verify the response and the mocks.
+        self.assertEqual(len(documents), len(document_ids))
+        for document, document_id in zip(documents, document_ids):
+            self.assertIsInstance(document, DocumentReference)
+            self.assertEqual(document.parent, collection)
+            self.assertEqual(document.id, document_id)
+
+        parent, _ = collection._parent_info()
+        api_client.list_documents.assert_called_once_with(
+            parent,
+            collection.id,
+            page_size=page_size,
+            show_missing=True,
+            metadata=client._rpc_metadata,
+        )
+
+    def test_list_documents_wo_page_size(self):
+        self._list_documents_helper()
+
+    def test_list_documents_w_page_size(self):
+        self._list_documents_helper(page_size=25)
+
     @mock.patch("google.cloud.firestore_v1beta1.query.Query", autospec=True)
     def test_get(self, query_class):
         collection = self._make_one("collection")