Skip to content

Commit 57267de

Browse files
committed
feat(firestore): add opt-in BSON document read and decoding support (PR 2)
1 parent 706f038 commit 57267de

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
from typing import (
@@ -47,8 +48,12 @@
4748
from google.cloud.firestore_v1.bson import (
4849
BSONBinary,
4950
BSONDecimal128,
51+
BSONInt32,
52+
BSONMaxKey,
53+
BSONMinKey,
5054
BSONObjectID,
5155
BSONRegex,
56+
BSONTimestamp,
5257
)
5358
from google.cloud.firestore_v1.field_path import FieldPath, parse_field_path
5459
from google.cloud.firestore_v1.types import common, document, write
@@ -419,29 +424,130 @@ def decode_value(
419424
raise ValueError("Unknown ``value_type``", value_type)
420425

421426

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

439541
if res.get("__type__", None) == "__vector__":
440-
# Vector data type is represented as mapping.
441-
# {"__type__":"__vector__", "value": [1.0, 2.0, 3.0]}.
442542
values = cast(Sequence[float], res["value"])
443543
return Vector(values)
444544

545+
if effective_decode and len(res) == 1:
546+
single_key = next(iter(res))
547+
parsed = _parse_bson_mapping(single_key, res[single_key])
548+
if parsed is not None:
549+
return parsed
550+
445551
return res
446552

447553

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)