3333from google .cloud .firestore_v1 .types import Cursor
3434from google .cloud .firestore_v1 .types import RunQueryResponse
3535from google .cloud .firestore_v1 .order import Order
36- from typing import Any , Dict , Generator , Iterable , NoReturn , Optional , Tuple , Union
36+ from typing import (
37+ Any ,
38+ Dict ,
39+ Generator ,
40+ Iterable ,
41+ NoReturn ,
42+ Optional ,
43+ Tuple ,
44+ Type ,
45+ Union ,
46+ )
3747
3848# Types needed only for Type Hints
3949from google .cloud .firestore_v1 .base_document import DocumentSnapshot
@@ -144,6 +154,9 @@ class BaseQuery(object):
144154 When false, selects only collections that are immediate children
145155 of the `parent` specified in the containing `RunQueryRequest`.
146156 When true, selects all descendant collections.
157+ recursive (Optional[bool]):
158+ When true, returns all documents and all documents in any subcollections
159+ below them. Defaults to false.
147160 """
148161
149162 ASCENDING = "ASCENDING"
@@ -163,6 +176,7 @@ def __init__(
163176 start_at = None ,
164177 end_at = None ,
165178 all_descendants = False ,
179+ recursive = False ,
166180 ) -> None :
167181 self ._parent = parent
168182 self ._projection = projection
@@ -174,6 +188,7 @@ def __init__(
174188 self ._start_at = start_at
175189 self ._end_at = end_at
176190 self ._all_descendants = all_descendants
191+ self ._recursive = recursive
177192
178193 def __eq__ (self , other ):
179194 if not isinstance (other , self .__class__ ):
@@ -247,6 +262,7 @@ def _copy(
247262 start_at : Optional [Tuple [dict , bool ]] = _not_passed ,
248263 end_at : Optional [Tuple [dict , bool ]] = _not_passed ,
249264 all_descendants : Optional [bool ] = _not_passed ,
265+ recursive : Optional [bool ] = _not_passed ,
250266 ) -> "BaseQuery" :
251267 return self .__class__ (
252268 self ._parent ,
@@ -261,6 +277,7 @@ def _copy(
261277 all_descendants = self ._evaluate_param (
262278 all_descendants , self ._all_descendants
263279 ),
280+ recursive = self ._evaluate_param (recursive , self ._recursive ),
264281 )
265282
266283 def _evaluate_param (self , value , fallback_value ):
@@ -813,6 +830,46 @@ def stream(
813830 def on_snapshot (self , callback ) -> NoReturn :
814831 raise NotImplementedError
815832
833+ def recursive (self ) -> "BaseQuery" :
834+ """Returns a copy of this query whose iterator will yield all matching
835+ documents as well as each of their descendent subcollections and documents.
836+
837+ This differs from the `all_descendents` flag, which only returns descendents
838+ whose subcollection names match the parent collection's name. To return
839+ all descendents, regardless of their subcollection name, use this.
840+ """
841+ copied = self ._copy (recursive = True , all_descendants = True )
842+ if copied ._parent and copied ._parent .id :
843+ original_collection_id = "/" .join (copied ._parent ._path )
844+
845+ # Reset the parent to nothing so we can recurse through the entire
846+ # database. This is required to have
847+ # `CollectionSelector.collection_id` not override
848+ # `CollectionSelector.all_descendants`, which happens if both are
849+ # set.
850+ copied ._parent = copied ._get_collection_reference_class ()("" )
851+ copied ._parent ._client = self ._parent ._client
852+
853+ # But wait! We don't want to load the entire database; only the
854+ # collection the user originally specified. To accomplish that, we
855+ # add the following arcane filters.
856+
857+ REFERENCE_NAME_MIN_ID = "__id-9223372036854775808__"
858+ start_at = f"{ original_collection_id } /{ REFERENCE_NAME_MIN_ID } "
859+
860+ # The backend interprets this null character is flipping the filter
861+ # to mean the end of the range instead of the beginning.
862+ nullChar = "\0 "
863+ end_at = f"{ original_collection_id } { nullChar } /{ REFERENCE_NAME_MIN_ID } "
864+
865+ copied = (
866+ copied .order_by (field_path_module .FieldPath .document_id ())
867+ .start_at ({field_path_module .FieldPath .document_id (): start_at })
868+ .end_at ({field_path_module .FieldPath .document_id (): end_at })
869+ )
870+
871+ return copied
872+
816873 def _comparator (self , doc1 , doc2 ) -> int :
817874 _orders = self ._orders
818875
@@ -1073,6 +1130,7 @@ def __init__(
10731130 start_at = None ,
10741131 end_at = None ,
10751132 all_descendants = True ,
1133+ recursive = False ,
10761134 ) -> None :
10771135 if not all_descendants :
10781136 raise ValueError ("all_descendants must be True for collection group query." )
@@ -1088,6 +1146,7 @@ def __init__(
10881146 start_at = start_at ,
10891147 end_at = end_at ,
10901148 all_descendants = all_descendants ,
1149+ recursive = recursive ,
10911150 )
10921151
10931152 def _validate_partition_query (self ):
@@ -1133,6 +1192,10 @@ def get_partitions(
11331192 ) -> NoReturn :
11341193 raise NotImplementedError
11351194
1195+ @staticmethod
1196+ def _get_collection_reference_class () -> Type ["BaseCollectionGroup" ]:
1197+ raise NotImplementedError
1198+
11361199
11371200class QueryPartition :
11381201 """Represents a bounded partition of a collection group query.
0 commit comments