Skip to content

Commit e507507

Browse files
committed
feat(firestore): add opt-in BSON document read and decoding support (PR 2)
1 parent f56e558 commit e507507

8 files changed

Lines changed: 317 additions & 84 deletions

File tree

packages/google-cloud-firestore/google/cloud/firestore_v1/_helpers.py

Lines changed: 95 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from __future__ import annotations
1818

1919
import datetime
20+
import collections.abc
2021
import json
2122
from typing import (
2223
TYPE_CHECKING,
@@ -50,8 +51,12 @@
5051
from google.cloud.firestore_v1.bson import (
5152
BSONBinary,
5253
BSONDecimal128,
54+
BSONInt32,
55+
BSONMaxKey,
56+
BSONMinKey,
5357
BSONObjectID,
5458
BSONRegex,
59+
BSONTimestamp,
5560
)
5661
from google.cloud.firestore_v1.vector import Vector
5762

@@ -415,29 +420,113 @@ def decode_value(
415420
raise ValueError("Unknown ``value_type``", value_type)
416421

417422

418-
def decode_dict(value_fields, client) -> Union[dict, Vector]:
423+
def _parse_oid(val: Any) -> BSONObjectID:
424+
if not isinstance(val, str):
425+
raise ValueError(f"Invalid BSONObjectID map value, expected str: {val!r}")
426+
return BSONObjectID(val)
427+
428+
429+
def _parse_decimal128(val: Any) -> BSONDecimal128:
430+
if not isinstance(val, str):
431+
raise ValueError(f"Invalid BSONDecimal128 map value, expected str: {val!r}")
432+
return BSONDecimal128(val)
433+
434+
435+
def _parse_int32(val: Any) -> BSONInt32:
436+
if type(val) is not int or isinstance(val, bool):
437+
raise ValueError(f"Invalid BSONInt32 map value, expected int: {val!r}")
438+
return BSONInt32(val)
439+
440+
441+
def _parse_minkey(val: Any) -> BSONMinKey:
442+
if type(val) is not int or isinstance(val, bool):
443+
raise ValueError(f"Invalid BSONMinKey map value, expected int: {val!r}")
444+
return BSONMinKey()
445+
446+
447+
def _parse_maxkey(val: Any) -> BSONMaxKey:
448+
if type(val) is not int or isinstance(val, bool):
449+
raise ValueError(f"Invalid BSONMaxKey map value, expected int: {val!r}")
450+
return BSONMaxKey()
451+
452+
453+
def _parse_timestamp(val: Any) -> BSONTimestamp:
454+
if not isinstance(val, collections.abc.Mapping):
455+
raise ValueError(f"Invalid BSONTimestamp map value, expected mapping: {val!r}")
456+
sec = val.get("seconds")
457+
inc = val.get("increment")
458+
if type(sec) is not int or type(inc) is not int or isinstance(sec, bool) or isinstance(inc, bool) or len(val) != 2:
459+
raise ValueError(f"Invalid BSONTimestamp fields: {val!r}")
460+
return BSONTimestamp(sec, inc)
461+
462+
463+
def _parse_regex(val: Any) -> BSONRegex:
464+
if not isinstance(val, collections.abc.Mapping):
465+
raise ValueError(f"Invalid BSONRegex map value, expected mapping: {val!r}")
466+
pat = val.get("pattern")
467+
opt = val.get("options", "")
468+
if not isinstance(pat, str) or not isinstance(opt, str) or len(val) not in (1, 2):
469+
raise ValueError(f"Invalid BSONRegex fields: {val!r}")
470+
return BSONRegex(pat, opt)
471+
472+
473+
def _parse_binary(val: Any) -> BSONBinary:
474+
if not isinstance(val, collections.abc.Mapping):
475+
raise ValueError(f"Invalid BSONBinary map value, expected mapping: {val!r}")
476+
sub = val.get("sub_type")
477+
bdata = val.get("bytes")
478+
if type(sub) is not int or isinstance(sub, bool) or not isinstance(bdata, (bytes, bytearray, memoryview)) or len(val) != 2:
479+
raise ValueError(f"Invalid BSONBinary fields: {val!r}")
480+
return BSONBinary(bdata, subtype=sub)
481+
482+
483+
_BSON_MAP_PARSERS = {
484+
"__oid__": _parse_oid,
485+
"__decimal128__": _parse_decimal128,
486+
"__int__": _parse_int32,
487+
"__minkey__": _parse_minkey,
488+
"__maxkey__": _parse_maxkey,
489+
"__timestamp__": _parse_timestamp,
490+
"__regex__": _parse_regex,
491+
"__binary__": _parse_binary,
492+
}
493+
494+
495+
def _parse_bson_mapping(key: str, val: Any) -> Optional[Any]:
496+
"""Converts legacy BSON map value representations to native BSON instances."""
497+
parser = _BSON_MAP_PARSERS.get(key)
498+
if parser is not None:
499+
return parser(val)
500+
return None
501+
502+
503+
def decode_dict(value_fields, client, decode_bson: Optional[bool] = None) -> Union[dict, Vector]:
419504
"""Converts a protobuf map of Firestore ``Value``-s.
420505
421506
Args:
422507
value_fields (google.protobuf.pyext._message.MessageMapContainer): A
423508
protobuf map of Firestore ``Value``-s.
424509
client (:class:`~google.cloud.firestore_v1.client.Client`):
425510
A client that has a document factory.
511+
decode_bson (Optional[bool]): Flag indicating whether to decode BSON map representations.
426512
427513
Returns:
428-
Dict[str, Union[NoneType, bool, int, float, datetime.datetime, \
429-
str, bytes, dict, ~google.cloud.Firestore.GeoPoint]]: A dictionary
430-
of native Python values converted from the ``value_fields``.
514+
Dict[str, Any]: A dictionary converted from ``value_fields``.
431515
"""
516+
effective_decode = decode_bson if decode_bson is not None else getattr(client, "decode_bson", False)
432517
value_fields_pb = getattr(value_fields, "_pb", value_fields)
433518
res = {key: decode_value(value, client) for key, value in value_fields_pb.items()}
434519

435520
if res.get("__type__", None) == "__vector__":
436-
# Vector data type is represented as mapping.
437-
# {"__type__":"__vector__", "value": [1.0, 2.0, 3.0]}.
438521
values = cast(Sequence[float], res["value"])
439522
return Vector(values)
440523

524+
if effective_decode and len(res) == 1:
525+
single_key = next(iter(res))
526+
parsed = _parse_bson_mapping(single_key, res[single_key])
527+
if parsed is not None:
528+
return parsed
529+
441530
return res
442531

443532

packages/google-cloud-firestore/google/cloud/firestore_v1/async_client.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,13 +105,15 @@ def __init__(
105105
database=None,
106106
client_info=_CLIENT_INFO,
107107
client_options=None,
108+
decode_bson: bool = False,
108109
) -> None:
109110
super(AsyncClient, self).__init__(
110111
project=project,
111112
credentials=credentials,
112113
database=database,
113114
client_info=client_info,
114115
client_options=client_options,
116+
decode_bson=decode_bson,
115117
)
116118

117119
def _to_sync_copy(self):
@@ -124,6 +126,7 @@ def _to_sync_copy(self):
124126
database=self._database,
125127
client_info=self._client_info,
126128
client_options=self._client_options,
129+
decode_bson=self.decode_bson,
127130
)
128131
return self._sync_copy
129132

packages/google-cloud-firestore/google/cloud/firestore_v1/base_client.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@ def __init__(
132132
database=None,
133133
client_info=_CLIENT_INFO,
134134
client_options=None,
135+
decode_bson: bool = False,
135136
) -> None:
136137
database = database or DEFAULT_DATABASE
137138
# NOTE: This API has no use for the _http argument, but sending it
@@ -165,6 +166,7 @@ def __init__(
165166
self._client_options = client_options
166167

167168
self._database = database
169+
self.decode_bson = decode_bson
168170

169171
def _firestore_api_helper(self, transport, client_class, client_module) -> Any:
170172
"""Lazy-loading getter GAPIC Firestore API.
@@ -610,14 +612,16 @@ def _parse_batch_get(
610612
result_type = get_doc_response._pb.WhichOneof("result")
611613
if result_type == "found":
612614
reference = _get_reference(get_doc_response.found.name, reference_map)
613-
data = _helpers.decode_dict(get_doc_response.found.fields, client)
615+
fields = get_doc_response.found.fields
616+
data = _helpers.decode_dict(fields, client)
614617
snapshot = DocumentSnapshot(
615618
reference,
616619
data,
617620
exists=True,
618621
read_time=get_doc_response.read_time,
619622
create_time=get_doc_response.found.create_time,
620623
update_time=get_doc_response.found.update_time,
624+
raw_fields=fields,
621625
)
622626
elif result_type == "missing":
623627
reference = _get_reference(get_doc_response.missing, reference_map)

packages/google-cloud-firestore/google/cloud/firestore_v1/base_document.py

Lines changed: 80 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -388,21 +388,88 @@ class DocumentSnapshot(object):
388388
"""
389389

390390
def __init__(
391-
self, reference, data, exists, read_time, create_time, update_time
391+
self,
392+
reference,
393+
data,
394+
exists,
395+
read_time,
396+
create_time,
397+
update_time,
398+
raw_fields=None,
399+
decode_bson: Optional[bool] = None,
392400
) -> None:
393401
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)
397402
self._exists = exists
398403
self.read_time = read_time
399404
self.create_time = create_time
400405
self.update_time = update_time
406+
self._raw_fields = raw_fields
407+
408+
client = getattr(reference, "_client", None) if reference else None
409+
self._decode_bson = (
410+
decode_bson
411+
if decode_bson is not None
412+
else (getattr(client, "decode_bson", False) if client else False)
413+
)
414+
self._data_raw = None
415+
self._data_bson = None
416+
417+
if raw_fields is not None:
418+
if self._decode_bson:
419+
self._data_bson = copy.deepcopy(data)
420+
else:
421+
self._data_raw = copy.deepcopy(data)
422+
else:
423+
self._data_raw = copy.deepcopy(data) if data is not None else None
424+
425+
def _get_data(self, decode_bson: Optional[bool] = None) -> Optional[Dict[str, Any]]:
426+
effective_decode = (
427+
decode_bson
428+
if decode_bson is not None
429+
else (
430+
self._decode_bson
431+
if hasattr(self, "_decode_bson") and self._decode_bson is not None
432+
else (
433+
self._reference._client.decode_bson
434+
if (self._reference and hasattr(self._reference, "_client") and self._reference._client)
435+
else False
436+
)
437+
)
438+
)
439+
440+
if effective_decode:
441+
if self._data_bson is None:
442+
if self._raw_fields is not None:
443+
client = self._reference._client if self._reference else None
444+
self._data_bson = _helpers.decode_dict(
445+
self._raw_fields, client, decode_bson=True
446+
)
447+
elif self._data_raw is not None:
448+
self._data_bson = self._data_raw
449+
return self._data_bson
450+
else:
451+
if self._data_raw is None:
452+
if self._raw_fields is not None:
453+
client = self._reference._client if self._reference else None
454+
self._data_raw = _helpers.decode_dict(
455+
self._raw_fields, client, decode_bson=False
456+
)
457+
elif self._data_bson is not None:
458+
self._data_raw = self._data_bson
459+
return self._data_raw
460+
461+
@property
462+
def _data(self) -> Optional[Dict[str, Any]]:
463+
return self._get_data()
401464

402465
def __eq__(self, other):
403466
if not isinstance(other, self.__class__):
404467
return NotImplemented
405-
return self._reference == other._reference and self._data == other._data
468+
return (
469+
self._reference == other._reference
470+
and self.read_time == other.read_time
471+
and self._get_data(decode_bson=False) == other._get_data(decode_bson=False)
472+
)
406473

407474
def __hash__(self):
408475
return hash(self._reference) + hash(self.update_time)
@@ -448,84 +515,20 @@ def reference(self) -> BaseDocumentReference:
448515
"""
449516
return self._reference
450517

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-
"""
518+
def get(self, field_path: str, decode_bson: Optional[bool] = None) -> Any:
519+
"""Get a value from the snapshot data."""
510520
if not self._exists:
511521
return None
512-
nested_data = field_path_module.get_nested_value(field_path, self._data)
522+
data = self._get_data(decode_bson=decode_bson)
523+
nested_data = field_path_module.get_nested_value(field_path, data)
513524
return copy.deepcopy(nested_data)
514525

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+
def to_dict(self, decode_bson: Optional[bool] = None) -> Union[Dict[str, Any], None]:
527+
"""Retrieve the data contained in this snapshot."""
526528
if not self._exists:
527529
return None
528-
return copy.deepcopy(self._data)
530+
data = self._get_data(decode_bson=decode_bson)
531+
return copy.deepcopy(data)
529532

530533
def _to_protobuf(self) -> Optional[Document]:
531534
return _helpers.document_snapshot_to_protobuf(self)

0 commit comments

Comments
 (0)