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]