Skip to content

Commit 91ba50f

Browse files
committed
feat(firestore): add opt-in BSON document read and decoding support (PR 2)
1 parent 985c2d3 commit 91ba50f

8 files changed

Lines changed: 341 additions & 84 deletions

File tree

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

Lines changed: 112 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
from __future__ import annotations
1818

19+
import collections.abc
1920
import datetime
2021
import json
2122
import re
@@ -48,8 +49,12 @@
4849
from google.cloud.firestore_v1.bson import (
4950
BSONBinary,
5051
BSONDecimal128,
52+
BSONInt32,
53+
BSONMaxKey,
54+
BSONMinKey,
5155
BSONObjectID,
5256
BSONRegex,
57+
BSONTimestamp,
5358
BSONType,
5459
)
5560
from google.cloud.firestore_v1.field_path import FieldPath, parse_field_path
@@ -453,29 +458,130 @@ def decode_value(
453458
raise ValueError("Unknown ``value_type``", value_type)
454459

455460

456-
def decode_dict(value_fields, client) -> Union[dict, Vector]:
461+
def _parse_oid(val: Any) -> BSONObjectID:
462+
if not isinstance(val, str):
463+
raise ValueError(f"Invalid BSONObjectID map value, expected str: {val!r}")
464+
return BSONObjectID(val)
465+
466+
467+
def _parse_decimal128(val: Any) -> BSONDecimal128:
468+
if not isinstance(val, str):
469+
raise ValueError(f"Invalid BSONDecimal128 map value, expected str: {val!r}")
470+
return BSONDecimal128(val)
471+
472+
473+
def _parse_int32(val: Any) -> BSONInt32:
474+
if type(val) is not int or isinstance(val, bool):
475+
raise ValueError(f"Invalid BSONInt32 map value, expected int: {val!r}")
476+
return BSONInt32(val)
477+
478+
479+
def _parse_minkey(val: Any) -> BSONMinKey:
480+
if type(val) is not int or isinstance(val, bool):
481+
raise ValueError(f"Invalid BSONMinKey map value, expected int: {val!r}")
482+
return BSONMinKey()
483+
484+
485+
def _parse_maxkey(val: Any) -> BSONMaxKey:
486+
if type(val) is not int or isinstance(val, bool):
487+
raise ValueError(f"Invalid BSONMaxKey map value, expected int: {val!r}")
488+
return BSONMaxKey()
489+
490+
491+
def _parse_timestamp(val: Any) -> BSONTimestamp:
492+
if not isinstance(val, collections.abc.Mapping):
493+
raise ValueError(f"Invalid BSONTimestamp map value, expected mapping: {val!r}")
494+
sec = val.get("seconds")
495+
inc = val.get("increment")
496+
if (
497+
type(sec) is not int
498+
or type(inc) is not int
499+
or isinstance(sec, bool)
500+
or isinstance(inc, bool)
501+
or len(val) != 2
502+
):
503+
raise ValueError(f"Invalid BSONTimestamp fields: {val!r}")
504+
return BSONTimestamp(sec, inc)
505+
506+
507+
def _parse_regex(val: Any) -> BSONRegex:
508+
if not isinstance(val, collections.abc.Mapping):
509+
raise ValueError(f"Invalid BSONRegex map value, expected mapping: {val!r}")
510+
pat = val.get("pattern")
511+
opt = val.get("options", "")
512+
if not isinstance(pat, str) or not isinstance(opt, str) or len(val) not in (1, 2):
513+
raise ValueError(f"Invalid BSONRegex fields: {val!r}")
514+
return BSONRegex(pat, opt)
515+
516+
517+
def _parse_binary(val: Any) -> BSONBinary:
518+
if not isinstance(val, collections.abc.Mapping):
519+
raise ValueError(f"Invalid BSONBinary map value, expected mapping: {val!r}")
520+
sub = val.get("sub_type")
521+
bdata = val.get("bytes")
522+
if (
523+
type(sub) is not int
524+
or isinstance(sub, bool)
525+
or not isinstance(bdata, (bytes, bytearray, memoryview))
526+
or len(val) != 2
527+
):
528+
raise ValueError(f"Invalid BSONBinary fields: {val!r}")
529+
return BSONBinary(bdata, subtype=sub)
530+
531+
532+
_BSON_MAP_PARSERS = {
533+
"__oid__": _parse_oid,
534+
"__decimal128__": _parse_decimal128,
535+
"__int__": _parse_int32,
536+
"__minkey__": _parse_minkey,
537+
"__maxkey__": _parse_maxkey,
538+
"__timestamp__": _parse_timestamp,
539+
"__regex__": _parse_regex,
540+
"__binary__": _parse_binary,
541+
}
542+
543+
544+
def _parse_bson_mapping(key: str, val: Any) -> Optional[Any]:
545+
"""Converts legacy BSON map value representations to native BSON instances."""
546+
parser = _BSON_MAP_PARSERS.get(key)
547+
if parser is not None:
548+
return parser(val)
549+
return None
550+
551+
552+
def decode_dict(
553+
value_fields, client, decode_bson: Optional[bool] = None
554+
) -> Union[dict, Vector]:
457555
"""Converts a protobuf map of Firestore ``Value``-s.
458556
459557
Args:
460558
value_fields (google.protobuf.pyext._message.MessageMapContainer): A
461559
protobuf map of Firestore ``Value``-s.
462560
client (:class:`~google.cloud.firestore_v1.client.Client`):
463561
A client that has a document factory.
562+
decode_bson (Optional[bool]): Flag indicating whether to decode BSON map representations.
464563
465564
Returns:
466-
Dict[str, Union[NoneType, bool, int, float, datetime.datetime, \
467-
str, bytes, dict, ~google.cloud.Firestore.GeoPoint]]: A dictionary
468-
of native Python values converted from the ``value_fields``.
565+
Dict[str, Any]: A dictionary converted from ``value_fields``.
469566
"""
567+
effective_decode = (
568+
decode_bson
569+
if decode_bson is not None
570+
else getattr(client, "decode_bson", False)
571+
)
470572
value_fields_pb = getattr(value_fields, "_pb", value_fields)
471573
res = {key: decode_value(value, client) for key, value in value_fields_pb.items()}
472574

473575
if res.get("__type__", None) == "__vector__":
474-
# Vector data type is represented as mapping.
475-
# {"__type__":"__vector__", "value": [1.0, 2.0, 3.0]}.
476576
values = cast(Sequence[float], res["value"])
477577
return Vector(values)
478578

579+
if effective_decode and len(res) == 1:
580+
single_key = next(iter(res))
581+
parsed = _parse_bson_mapping(single_key, res[single_key])
582+
if parsed is not None:
583+
return parsed
584+
479585
return res
480586

481587

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)

0 commit comments

Comments
 (0)