Skip to content
This repository was archived by the owner on Mar 2, 2026. It is now read-only.

Commit eb45a36

Browse files
craiglabenzgcf-owl-bot[bot]crwilcox
authored
feat: add support for recursive queries (#407)
* refactor: added BaseQuery.copy method * 🦉 Updates from OwlBot See https://github.com/googleapis/repo-automation-bots/blob/master/packages/owl-bot/README.md * responded to code review * feat: added recursive query * tidied up * 🦉 Updates from OwlBot See https://github.com/googleapis/repo-automation-bots/blob/master/packages/owl-bot/README.md * more tidying up * fixed error with path compilation * fixed async handling in system tests * 🦉 Updates from OwlBot See https://github.com/googleapis/repo-automation-bots/blob/master/packages/owl-bot/README.md * Update google/cloud/firestore_v1/base_collection.py Co-authored-by: Christopher Wilcox <crwilcox@google.com> * reverted error message changes * 🦉 Updates from OwlBot See https://github.com/googleapis/repo-automation-bots/blob/main/packages/owl-bot/README.md * comment updates Co-authored-by: Owl Bot <gcf-owl-bot[bot]@users.noreply.github.com> Co-authored-by: Christopher Wilcox <crwilcox@google.com>
1 parent 0176cc7 commit eb45a36

10 files changed

Lines changed: 367 additions & 4 deletions

File tree

google/cloud/firestore_v1/_helpers.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,7 @@ def verify_path(path, is_collection) -> None:
144144
if is_collection:
145145
if num_elements % 2 == 0:
146146
raise ValueError("A collection must have an odd number of path elements")
147+
147148
else:
148149
if num_elements % 2 == 1:
149150
raise ValueError("A document must have an even number of path elements")

google/cloud/firestore_v1/async_query.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
from google.api_core import gapic_v1 # type: ignore
2323
from google.api_core import retry as retries # type: ignore
2424

25+
from google.cloud import firestore_v1
2526
from google.cloud.firestore_v1.base_query import (
2627
BaseCollectionGroup,
2728
BaseQuery,
@@ -32,7 +33,7 @@
3233
)
3334

3435
from google.cloud.firestore_v1 import async_document
35-
from typing import AsyncGenerator
36+
from typing import AsyncGenerator, Type
3637

3738
# Types needed only for Type Hints
3839
from google.cloud.firestore_v1.transaction import Transaction
@@ -92,6 +93,9 @@ class AsyncQuery(BaseQuery):
9293
When false, selects only collections that are immediate children
9394
of the `parent` specified in the containing `RunQueryRequest`.
9495
When true, selects all descendant collections.
96+
recursive (Optional[bool]):
97+
When true, returns all documents and all documents in any subcollections
98+
below them. Defaults to false.
9599
"""
96100

97101
def __init__(
@@ -106,6 +110,7 @@ def __init__(
106110
start_at=None,
107111
end_at=None,
108112
all_descendants=False,
113+
recursive=False,
109114
) -> None:
110115
super(AsyncQuery, self).__init__(
111116
parent=parent,
@@ -118,6 +123,7 @@ def __init__(
118123
start_at=start_at,
119124
end_at=end_at,
120125
all_descendants=all_descendants,
126+
recursive=recursive,
121127
)
122128

123129
async def get(
@@ -224,6 +230,14 @@ async def stream(
224230
if snapshot is not None:
225231
yield snapshot
226232

233+
@staticmethod
234+
def _get_collection_reference_class() -> Type[
235+
"firestore_v1.async_collection.AsyncCollectionReference"
236+
]:
237+
from google.cloud.firestore_v1.async_collection import AsyncCollectionReference
238+
239+
return AsyncCollectionReference
240+
227241

228242
class AsyncCollectionGroup(AsyncQuery, BaseCollectionGroup):
229243
"""Represents a Collection Group in the Firestore API.
@@ -249,6 +263,7 @@ def __init__(
249263
start_at=None,
250264
end_at=None,
251265
all_descendants=True,
266+
recursive=False,
252267
) -> None:
253268
super(AsyncCollectionGroup, self).__init__(
254269
parent=parent,
@@ -261,6 +276,7 @@ def __init__(
261276
start_at=start_at,
262277
end_at=end_at,
263278
all_descendants=all_descendants,
279+
recursive=recursive,
264280
)
265281

266282
@staticmethod

google/cloud/firestore_v1/base_collection.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,10 @@ def document(self, document_id: str = None) -> DocumentReference:
124124
if document_id is None:
125125
document_id = _auto_id()
126126

127-
child_path = self._path + (document_id,)
127+
# Append `self._path` and the passed document's ID as long as the first
128+
# element in the path is not an empty string, which comes from setting the
129+
# parent to "" for recursive queries.
130+
child_path = self._path + (document_id,) if self._path[0] else (document_id,)
128131
return self._client.document(*child_path)
129132

130133
def _parent_info(self) -> Tuple[Any, str]:
@@ -200,6 +203,9 @@ def list_documents(
200203
]:
201204
raise NotImplementedError
202205

206+
def recursive(self) -> "BaseQuery":
207+
return self._query().recursive()
208+
203209
def select(self, field_paths: Iterable[str]) -> BaseQuery:
204210
"""Create a "select" query with this collection as parent.
205211

google/cloud/firestore_v1/base_query.py

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,17 @@
3333
from google.cloud.firestore_v1.types import Cursor
3434
from google.cloud.firestore_v1.types import RunQueryResponse
3535
from 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
3949
from 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

11371200
class QueryPartition:
11381201
"""Represents a bounded partition of a collection group query.

google/cloud/firestore_v1/query.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
a more common way to create a query than direct usage of the constructor.
2020
"""
2121

22+
from google.cloud import firestore_v1
2223
from google.cloud.firestore_v1.base_document import DocumentSnapshot
2324
from google.api_core import gapic_v1 # type: ignore
2425
from google.api_core import retry as retries # type: ignore
@@ -34,7 +35,7 @@
3435

3536
from google.cloud.firestore_v1 import document
3637
from google.cloud.firestore_v1.watch import Watch
37-
from typing import Any, Callable, Generator, List
38+
from typing import Any, Callable, Generator, List, Type
3839

3940

4041
class Query(BaseQuery):
@@ -105,6 +106,7 @@ def __init__(
105106
start_at=None,
106107
end_at=None,
107108
all_descendants=False,
109+
recursive=False,
108110
) -> None:
109111
super(Query, self).__init__(
110112
parent=parent,
@@ -117,6 +119,7 @@ def __init__(
117119
start_at=start_at,
118120
end_at=end_at,
119121
all_descendants=all_descendants,
122+
recursive=recursive,
120123
)
121124

122125
def get(
@@ -254,6 +257,14 @@ def on_snapshot(docs, changes, read_time):
254257
self, callback, document.DocumentSnapshot, document.DocumentReference
255258
)
256259

260+
@staticmethod
261+
def _get_collection_reference_class() -> Type[
262+
"firestore_v1.collection.CollectionReference"
263+
]:
264+
from google.cloud.firestore_v1.collection import CollectionReference
265+
266+
return CollectionReference
267+
257268

258269
class CollectionGroup(Query, BaseCollectionGroup):
259270
"""Represents a Collection Group in the Firestore API.
@@ -279,6 +290,7 @@ def __init__(
279290
start_at=None,
280291
end_at=None,
281292
all_descendants=True,
293+
recursive=False,
282294
) -> None:
283295
super(CollectionGroup, self).__init__(
284296
parent=parent,
@@ -291,6 +303,7 @@ def __init__(
291303
start_at=start_at,
292304
end_at=end_at,
293305
all_descendants=all_descendants,
306+
recursive=recursive,
294307
)
295308

296309
@staticmethod

0 commit comments

Comments
 (0)