1# Copyright 2017 Google LLC All rights reserved.
2#
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6#
7# http://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
14
15"""Classes for representing documents for the Google Cloud Firestore API."""
16from __future__ import annotations
17
18import copy
19
20from typing import (
21 TYPE_CHECKING,
22 Any,
23 Dict,
24 Iterable,
25 Optional,
26 Tuple,
27 Union,
28 Awaitable,
29)
30
31from google.api_core import retry as retries
32
33from google.cloud.firestore_v1 import _helpers
34from google.cloud.firestore_v1 import field_path as field_path_module
35from google.cloud.firestore_v1.types import common
36
37# Types needed only for Type Hints
38if TYPE_CHECKING: # pragma: NO COVER
39 from google.cloud.firestore_v1.types import Document, firestore, write
40
41 import datetime
42
43
44class BaseDocumentReference(object):
45 """A reference to a document in a Firestore database.
46
47 The document may already exist or can be created by this class.
48
49 Args:
50 path (Tuple[str, ...]): The components in the document path.
51 This is a series of strings representing each collection and
52 sub-collection ID, as well as the document IDs for any documents
53 that contain a sub-collection (as well as the base document).
54 kwargs (dict): The keyword arguments for the constructor. The only
55 supported keyword is ``client`` and it must be a
56 :class:`~google.cloud.firestore_v1.client.Client`. It represents
57 the client that created this document reference.
58
59 Raises:
60 ValueError: if
61
62 * the ``path`` is empty
63 * there are an even number of elements
64 * a collection ID in ``path`` is not a string
65 * a document ID in ``path`` is not a string
66 TypeError: If a keyword other than ``client`` is used.
67 """
68
69 _document_path_internal = None
70
71 def __init__(self, *path, **kwargs) -> None:
72 _helpers.verify_path(path, is_collection=False)
73 self._path = path
74 self._client = kwargs.pop("client", None)
75 if kwargs:
76 raise TypeError(
77 "Received unexpected arguments", kwargs, "Only `client` is supported"
78 )
79
80 def __copy__(self):
81 """Shallow copy the instance.
82
83 We leave the client "as-is" but tuple-unpack the path.
84
85 Returns:
86 .DocumentReference: A copy of the current document.
87 """
88 result = self.__class__(*self._path, client=self._client)
89 result._document_path_internal = self._document_path_internal
90 return result
91
92 def __deepcopy__(self, unused_memo):
93 """Deep copy the instance.
94
95 This isn't a true deep copy, wee leave the client "as-is" but
96 tuple-unpack the path.
97
98 Returns:
99 .DocumentReference: A copy of the current document.
100 """
101 return self.__copy__()
102
103 def __eq__(self, other):
104 """Equality check against another instance.
105
106 Args:
107 other (Any): A value to compare against.
108
109 Returns:
110 Union[bool, NotImplementedType]: Indicating if the values are
111 equal.
112 """
113 if isinstance(other, self.__class__):
114 return self._client == other._client and self._path == other._path
115 else:
116 return NotImplemented
117
118 def __hash__(self):
119 return hash(self._path) + hash(self._client)
120
121 def __ne__(self, other):
122 """Inequality check against another instance.
123
124 Args:
125 other (Any): A value to compare against.
126
127 Returns:
128 Union[bool, NotImplementedType]: Indicating if the values are
129 not equal.
130 """
131 if isinstance(other, self.__class__):
132 return self._client != other._client or self._path != other._path
133 else:
134 return NotImplemented
135
136 @property
137 def path(self):
138 """Database-relative for this document.
139
140 Returns:
141 str: The document's relative path.
142 """
143 return "/".join(self._path)
144
145 @property
146 def _document_path(self):
147 """Create and cache the full path for this document.
148
149 Of the form:
150
151 ``projects/{project_id}/databases/{database_id}/...
152 documents/{document_path}``
153
154 Returns:
155 str: The full document path.
156
157 Raises:
158 ValueError: If the current document reference has no ``client``.
159 """
160 if self._document_path_internal is None:
161 if self._client is None:
162 raise ValueError("A document reference requires a `client`.")
163 self._document_path_internal = _get_document_path(self._client, self._path)
164
165 return self._document_path_internal
166
167 @property
168 def id(self):
169 """The document identifier (within its collection).
170
171 Returns:
172 str: The last component of the path.
173 """
174 return self._path[-1]
175
176 @property
177 def parent(self):
178 """Collection that owns the current document.
179
180 Returns:
181 :class:`~google.cloud.firestore_v1.collection.CollectionReference`:
182 The parent collection.
183 """
184 parent_path = self._path[:-1]
185 return self._client.collection(*parent_path)
186
187 def collection(self, collection_id: str):
188 """Create a sub-collection underneath the current document.
189
190 Args:
191 collection_id (str): The sub-collection identifier (sometimes
192 referred to as the "kind").
193
194 Returns:
195 :class:`~google.cloud.firestore_v1.collection.CollectionReference`:
196 The child collection.
197 """
198 child_path = self._path + (collection_id,)
199 return self._client.collection(*child_path)
200
201 def _prep_create(
202 self,
203 document_data: dict,
204 retry: retries.Retry | retries.AsyncRetry | None | object = None,
205 timeout: float | None = None,
206 ) -> Tuple[Any, dict]:
207 batch = self._client.batch()
208 batch.create(self, document_data)
209 kwargs = _helpers.make_retry_timeout_kwargs(retry, timeout)
210
211 return batch, kwargs
212
213 def create(
214 self,
215 document_data: dict,
216 retry: retries.Retry | retries.AsyncRetry | None | object = None,
217 timeout: float | None = None,
218 ) -> write.WriteResult | Awaitable[write.WriteResult]:
219 raise NotImplementedError
220
221 def _prep_set(
222 self,
223 document_data: dict,
224 merge: bool = False,
225 retry: retries.Retry | retries.AsyncRetry | None | object = None,
226 timeout: float | None = None,
227 ) -> Tuple[Any, dict]:
228 batch = self._client.batch()
229 batch.set(self, document_data, merge=merge)
230 kwargs = _helpers.make_retry_timeout_kwargs(retry, timeout)
231
232 return batch, kwargs
233
234 def set(
235 self,
236 document_data: dict,
237 merge: bool = False,
238 retry: retries.Retry | retries.AsyncRetry | None | object = None,
239 timeout: float | None = None,
240 ):
241 raise NotImplementedError
242
243 def _prep_update(
244 self,
245 field_updates: dict,
246 option: _helpers.WriteOption | None = None,
247 retry: retries.Retry | retries.AsyncRetry | None | object = None,
248 timeout: float | None = None,
249 ) -> Tuple[Any, dict]:
250 batch = self._client.batch()
251 batch.update(self, field_updates, option=option)
252 kwargs = _helpers.make_retry_timeout_kwargs(retry, timeout)
253
254 return batch, kwargs
255
256 def update(
257 self,
258 field_updates: dict,
259 option: _helpers.WriteOption | None = None,
260 retry: retries.Retry | retries.AsyncRetry | None | object = None,
261 timeout: float | None = None,
262 ):
263 raise NotImplementedError
264
265 def _prep_delete(
266 self,
267 option: _helpers.WriteOption | None = None,
268 retry: retries.Retry | retries.AsyncRetry | None | object = None,
269 timeout: float | None = None,
270 ) -> Tuple[dict, dict]:
271 """Shared setup for async/sync :meth:`delete`."""
272 write_pb = _helpers.pb_for_delete(self._document_path, option)
273 request = {
274 "database": self._client._database_string,
275 "writes": [write_pb],
276 "transaction": None,
277 }
278 kwargs = _helpers.make_retry_timeout_kwargs(retry, timeout)
279
280 return request, kwargs
281
282 def delete(
283 self,
284 option: _helpers.WriteOption | None = None,
285 retry: retries.Retry | retries.AsyncRetry | None | object = None,
286 timeout: float | None = None,
287 ):
288 raise NotImplementedError
289
290 def _prep_batch_get(
291 self,
292 field_paths: Iterable[str] | None = None,
293 transaction=None,
294 retry: retries.Retry | retries.AsyncRetry | None | object = None,
295 timeout: float | None = None,
296 read_time: datetime.datetime | None = None,
297 ) -> Tuple[dict, dict]:
298 """Shared setup for async/sync :meth:`get`."""
299 if isinstance(field_paths, str):
300 raise ValueError("'field_paths' must be a sequence of paths, not a string.")
301
302 if field_paths is not None:
303 mask = common.DocumentMask(field_paths=sorted(field_paths))
304 else:
305 mask = None
306
307 request = {
308 "database": self._client._database_string,
309 "documents": [self._document_path],
310 "mask": mask,
311 "transaction": _helpers.get_transaction_id(transaction),
312 }
313 if read_time is not None:
314 request["read_time"] = read_time
315 kwargs = _helpers.make_retry_timeout_kwargs(retry, timeout)
316
317 return request, kwargs
318
319 def get(
320 self,
321 field_paths: Iterable[str] | None = None,
322 transaction=None,
323 retry: retries.Retry | retries.AsyncRetry | None | object = None,
324 timeout: float | None = None,
325 *,
326 read_time: datetime.datetime | None = None,
327 ) -> "DocumentSnapshot" | Awaitable["DocumentSnapshot"]:
328 raise NotImplementedError
329
330 def _prep_collections(
331 self,
332 page_size: int | None = None,
333 retry: retries.Retry | retries.AsyncRetry | None | object = None,
334 timeout: float | None = None,
335 read_time: datetime.datetime | None = None,
336 ) -> Tuple[dict, dict]:
337 """Shared setup for async/sync :meth:`collections`."""
338 request = {
339 "parent": self._document_path,
340 "page_size": page_size,
341 }
342 if read_time is not None:
343 request["read_time"] = read_time
344 kwargs = _helpers.make_retry_timeout_kwargs(retry, timeout)
345
346 return request, kwargs
347
348 def collections(
349 self,
350 page_size: int | None = None,
351 retry: retries.Retry | retries.AsyncRetry | None | object = None,
352 timeout: float | None = None,
353 *,
354 read_time: datetime.datetime | None = None,
355 ):
356 raise NotImplementedError
357
358 def on_snapshot(self, callback):
359 raise NotImplementedError
360
361
362class DocumentSnapshot(object):
363 """A snapshot of document data in a Firestore database.
364
365 This represents data retrieved at a specific time and may not contain
366 all fields stored for the document (i.e. a hand-picked selection of
367 fields may have been retrieved).
368
369 Instances of this class are not intended to be constructed by hand,
370 rather they'll be returned as responses to various methods, such as
371 :meth:`~google.cloud.DocumentReference.get`.
372
373 Args:
374 reference (:class:`~google.cloud.firestore_v1.document.DocumentReference`):
375 A document reference corresponding to the document that contains
376 the data in this snapshot.
377 data (Dict[str, Any]):
378 The data retrieved in the snapshot.
379 exists (bool):
380 Indicates if the document existed at the time the snapshot was
381 retrieved.
382 read_time (:class:`proto.datetime_helpers.DatetimeWithNanoseconds`):
383 The time that this snapshot was read from the server.
384 create_time (:class:`proto.datetime_helpers.DatetimeWithNanoseconds`):
385 The time that this document was created.
386 update_time (:class:`proto.datetime_helpers.DatetimeWithNanoseconds`):
387 The time that this document was last updated.
388 """
389
390 def __init__(
391 self, reference, data, exists, read_time, create_time, update_time
392 ) -> None:
393 self._reference = reference
394 # We want immutable data, so callers can't modify this value
395 # out from under us.
396 self._data = copy.deepcopy(data)
397 self._exists = exists
398 self.read_time = read_time
399 self.create_time = create_time
400 self.update_time = update_time
401
402 def __eq__(self, other):
403 if not isinstance(other, self.__class__):
404 return NotImplemented
405 return self._reference == other._reference and self._data == other._data
406
407 def __hash__(self):
408 return hash(self._reference) + hash(self.update_time)
409
410 @property
411 def _client(self):
412 """The client that owns the document reference for this snapshot.
413
414 Returns:
415 :class:`~google.cloud.firestore_v1.client.Client`:
416 The client that owns this document.
417 """
418 return self._reference._client
419
420 @property
421 def exists(self):
422 """Existence flag.
423
424 Indicates if the document existed at the time this snapshot
425 was retrieved.
426
427 Returns:
428 bool: The existence flag.
429 """
430 return self._exists
431
432 @property
433 def id(self):
434 """The document identifier (within its collection).
435
436 Returns:
437 str: The last component of the path of the document.
438 """
439 return self._reference.id
440
441 @property
442 def reference(self):
443 """Document reference corresponding to document that owns this data.
444
445 Returns:
446 :class:`~google.cloud.firestore_v1.document.DocumentReference`:
447 A document reference corresponding to this document.
448 """
449 return self._reference
450
451 def get(self, field_path: str) -> Any:
452 """Get a value from the snapshot data.
453
454 If the data is nested, for example:
455
456 .. code-block:: python
457
458 >>> snapshot.to_dict()
459 {
460 'top1': {
461 'middle2': {
462 'bottom3': 20,
463 'bottom4': 22,
464 },
465 'middle5': True,
466 },
467 'top6': b'\x00\x01 foo',
468 }
469
470 a **field path** can be used to access the nested data. For
471 example:
472
473 .. code-block:: python
474
475 >>> snapshot.get('top1')
476 {
477 'middle2': {
478 'bottom3': 20,
479 'bottom4': 22,
480 },
481 'middle5': True,
482 }
483 >>> snapshot.get('top1.middle2')
484 {
485 'bottom3': 20,
486 'bottom4': 22,
487 }
488 >>> snapshot.get('top1.middle2.bottom3')
489 20
490
491 See :meth:`~google.cloud.firestore_v1.client.Client.field_path` for
492 more information on **field paths**.
493
494 A copy is returned since the data may contain mutable values,
495 but the data stored in the snapshot must remain immutable.
496
497 Args:
498 field_path (str): A field path (``.``-delimited list of
499 field names).
500
501 Returns:
502 Any or None:
503 (A copy of) the value stored for the ``field_path`` or
504 None if snapshot document does not exist.
505
506 Raises:
507 KeyError: If the ``field_path`` does not match nested data
508 in the snapshot.
509 """
510 if not self._exists:
511 return None
512 nested_data = field_path_module.get_nested_value(field_path, self._data)
513 return copy.deepcopy(nested_data)
514
515 def to_dict(self) -> Union[Dict[str, Any], None]:
516 """Retrieve the data contained in this snapshot.
517
518 A copy is returned since the data may contain mutable values,
519 but the data stored in the snapshot must remain immutable.
520
521 Returns:
522 Dict[str, Any] or None:
523 The data in the snapshot. Returns None if reference
524 does not exist.
525 """
526 if not self._exists:
527 return None
528 return copy.deepcopy(self._data)
529
530 def _to_protobuf(self) -> Optional[Document]:
531 return _helpers.document_snapshot_to_protobuf(self)
532
533
534def _get_document_path(client, path: Tuple[str]) -> str:
535 """Convert a path tuple into a full path string.
536
537 Of the form:
538
539 ``projects/{project_id}/databases/{database_id}/...
540 documents/{document_path}``
541
542 Args:
543 client (:class:`~google.cloud.firestore_v1.client.Client`):
544 The client that holds configuration details and a GAPIC client
545 object.
546 path (Tuple[str, ...]): The components in a document path.
547
548 Returns:
549 str: The fully-qualified document path.
550 """
551 parts = (client._database_string, "documents") + path
552 return _helpers.DOCUMENT_PATH_DELIMITER.join(parts)
553
554
555def _consume_single_get(response_iterator) -> firestore.BatchGetDocumentsResponse:
556 """Consume a gRPC stream that should contain a single response.
557
558 The stream will correspond to a ``BatchGetDocuments`` request made
559 for a single document.
560
561 Args:
562 response_iterator (~google.cloud.exceptions.GrpcRendezvous): A
563 streaming iterator returned from a ``BatchGetDocuments``
564 request.
565
566 Returns:
567 ~google.cloud.firestore_v1.\
568 firestore.BatchGetDocumentsResponse: The single "get"
569 response in the batch.
570
571 Raises:
572 ValueError: If anything other than exactly one response is returned.
573 """
574 # Calling ``list()`` consumes the entire iterator.
575 all_responses = list(response_iterator)
576 if len(all_responses) != 1:
577 raise ValueError(
578 "Unexpected response from `BatchGetDocumentsResponse`",
579 all_responses,
580 "Expected only one result",
581 )
582
583 return all_responses[0]
584
585
586def _first_write_result(write_results: list) -> write.WriteResult:
587 """Get first write result from list.
588
589 For cases where ``len(write_results) > 1``, this assumes the writes
590 occurred at the same time (e.g. if an update and transform are sent
591 at the same time).
592
593 Args:
594 write_results (List[google.cloud.firestore_v1.\
595 write.WriteResult, ...]: The write results from a
596 ``CommitResponse``.
597
598 Returns:
599 google.cloud.firestore_v1.types.WriteResult: The
600 lone write result from ``write_results``.
601
602 Raises:
603 ValueError: If there are zero write results. This is likely to
604 **never** occur, since the backend should be stable.
605 """
606 if not write_results:
607 raise ValueError("Expected at least one write result")
608
609 return write_results[0]