Skip to content

Commit 5ec6976

Browse files
authored
feat(firestore): add BSON read deserialization support
1 parent fe17e56 commit 5ec6976

10 files changed

Lines changed: 145 additions & 70 deletions

File tree

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

Lines changed: 55 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@
4444
import google
4545
from google.cloud import exceptions # type: ignore
4646
from google.cloud.firestore_v1 import transforms, types
47-
from google.cloud.firestore_v1.bson import _BSONType
47+
from google.cloud.firestore_v1.bson import _BSON_DECODERS, _BSONType
4848
from google.cloud.firestore_v1.field_path import FieldPath, parse_field_path
4949
from google.cloud.firestore_v1.types import common, document, write
5050
from google.cloud.firestore_v1.types.write import DocumentTransform
@@ -347,27 +347,18 @@ def reference_value_to_document(reference_value, client) -> Any:
347347
return document
348348

349349

350-
def decode_value(
351-
value, client
352-
) -> Union[
353-
None, bool, int, float, list, datetime.datetime, str, bytes, dict, GeoPoint, Vector
354-
]:
350+
def decode_value(value, client=None, decode_bson: Optional[bool] = None) -> Any:
355351
"""Converts a Firestore protobuf ``Value`` to a native Python value.
356352
357353
Args:
358354
value (google.cloud.firestore_v1.types.Value): A
359355
Firestore protobuf to be decoded / parsed / converted.
360356
client (:class:`~google.cloud.firestore_v1.client.Client`):
361357
A client that has a document factory.
358+
decode_bson (Optional[bool]): Whether to decode BSON extended types.
362359
363360
Returns:
364-
Union[NoneType, bool, int, float, datetime.datetime, \
365-
str, bytes, dict, ~google.cloud.Firestore.GeoPoint]: A native
366-
Python value converted from the ``value``.
367-
368-
Raises:
369-
NotImplementedError: If the ``value_type`` is ``reference_value``.
370-
ValueError: If the ``value_type`` is unknown.
361+
Any: A native Python value converted from the ``value``.
371362
"""
372363
value_pb = getattr(value, "_pb", value)
373364
value_type = value_pb.WhichOneof("value_type")
@@ -394,37 +385,81 @@ def decode_value(
394385
)
395386
elif value_type == "array_value":
396387
return [
397-
decode_value(element, client) for element in value_pb.array_value.values
388+
decode_value(element, client, decode_bson=decode_bson)
389+
for element in value_pb.array_value.values
398390
]
399391
elif value_type == "map_value":
400-
return decode_dict(value_pb.map_value.fields, client)
392+
return decode_dict(value_pb.map_value.fields, client, decode_bson=decode_bson)
401393
else:
402394
raise ValueError("Unknown ``value_type``", value_type)
403395

404396

405-
def decode_dict(value_fields, client) -> Union[dict, Vector]:
397+
def _decode_bson_dict(data: dict) -> Optional[_BSONType]:
398+
"""Decode a single-key wire map dictionary if registered."""
399+
if len(data) == 1:
400+
key, val = next(iter(data.items()))
401+
decoder = _BSON_DECODERS.get(key)
402+
if decoder is not None:
403+
try:
404+
return decoder(val)
405+
except Exception:
406+
pass
407+
return None
408+
409+
410+
def _decode_bson_dict_recursive(data: Any) -> Any:
411+
"""Recursively decodes BSON wire map dictionaries."""
412+
if isinstance(data, dict):
413+
decoded = _decode_bson_dict(data)
414+
if decoded is not None:
415+
return decoded
416+
return {k: _decode_bson_dict_recursive(v) for k, v in data.items()}
417+
elif isinstance(data, list):
418+
return [_decode_bson_dict_recursive(item) for item in data]
419+
return data
420+
421+
422+
def decode_dict(
423+
value_fields,
424+
client=None,
425+
decode_bson: Optional[bool] = None,
426+
) -> Union[dict, Vector, _BSONType]:
406427
"""Converts a protobuf map of Firestore ``Value``-s.
407428
408429
Args:
409430
value_fields (google.protobuf.pyext._message.MessageMapContainer): A
410431
protobuf map of Firestore ``Value``-s.
411432
client (:class:`~google.cloud.firestore_v1.client.Client`):
412433
A client that has a document factory.
434+
decode_bson (Optional[bool]): Whether to decode BSON extended types.
413435
414436
Returns:
415-
Dict[str, Union[NoneType, bool, int, float, datetime.datetime, \
416-
str, bytes, dict, ~google.cloud.Firestore.GeoPoint]]: A dictionary
417-
of native Python values converted from the ``value_fields``.
437+
Union[dict, ~google.cloud.firestore_v1.vector.Vector, \
438+
~google.cloud.firestore_v1.bson._BSONType]: A dictionary of native \
439+
Python values, Vector, or BSON object converted from ``value_fields``.
418440
"""
419441
value_fields_pb = getattr(value_fields, "_pb", value_fields)
420-
res = {key: decode_value(value, client) for key, value in value_fields_pb.items()}
442+
res = {
443+
key: decode_value(value, client, decode_bson=decode_bson)
444+
for key, value in value_fields_pb.items()
445+
}
421446

422447
if res.get("__type__", None) == "__vector__":
423448
# Vector data type is represented as mapping.
424449
# {"__type__":"__vector__", "value": [1.0, 2.0, 3.0]}.
425450
values = cast(Sequence[float], res["value"])
426451
return Vector(values)
427452

453+
should_decode = (
454+
decode_bson
455+
if decode_bson is not None
456+
else getattr(client, "_decode_bson", False)
457+
)
458+
if should_decode:
459+
decoded = _decode_bson_dict(res)
460+
if decoded is not None:
461+
return decoded
462+
428463
return res
429464

430465

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

Lines changed: 2 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):

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

Lines changed: 2 additions & 0 deletions
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: bool = decode_bson
168170

169171
def _firestore_api_helper(self, transport, client_class, client_module) -> Any:
170172
"""Lazy-loading getter GAPIC Firestore API.

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

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -512,20 +512,34 @@ def get(self, field_path: str) -> Any:
512512
nested_data = field_path_module.get_nested_value(field_path, self._data)
513513
return copy.deepcopy(nested_data)
514514

515-
def to_dict(self) -> Union[Dict[str, Any], None]:
515+
def to_dict(
516+
self, decode_bson: Optional[bool] = None
517+
) -> Union[Dict[str, Any], None]:
516518
"""Retrieve the data contained in this snapshot.
517519
518520
A copy is returned since the data may contain mutable values,
519521
but the data stored in the snapshot must remain immutable.
520522
523+
Args:
524+
decode_bson (Optional[bool]): Whether to decode BSON extended types.
525+
521526
Returns:
522527
Dict[str, Any] or None:
523528
The data in the snapshot. Returns None if reference
524529
does not exist.
525530
"""
526531
if not self._exists:
527532
return None
528-
return copy.deepcopy(self._data)
533+
data = copy.deepcopy(self._data)
534+
client = self._reference._client if self._reference is not None else None
535+
should_decode = (
536+
decode_bson
537+
if decode_bson is not None
538+
else getattr(client, "_decode_bson", False)
539+
)
540+
if should_decode:
541+
return _helpers._decode_bson_dict_recursive(data)
542+
return data
529543

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

‎packages/google-cloud-firestore/google/cloud/firestore_v1/bson.py‎

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
import abc
2828
import decimal
2929
import re
30-
from typing import Any, Dict, Union
30+
from typing import Any, Callable, Dict, Union
3131

3232
__all__ = [
3333
"BSONObjectId",
@@ -508,3 +508,21 @@ def __hash__(self) -> int:
508508
return hash(self.to_decimal)
509509
except decimal.InvalidOperation:
510510
return hash((type(self), self._value))
511+
512+
513+
_BSON_DECODERS: Dict[str, Callable[[Any], Any]] = {
514+
"__oid__": BSONObjectId,
515+
"__min__": lambda _: BSONMinKey(),
516+
"__max__": lambda _: BSONMaxKey(),
517+
"__int__": BSONInt32,
518+
"__decimal128__": BSONDecimal128,
519+
"__binary__": lambda v: (v[1:] if v[0] == 0 else BSONBinary(v[1:], subtype=v[0]))
520+
if isinstance(v, (bytes, bytearray)) and len(v) >= 1
521+
else None,
522+
"__request_timestamp__": lambda v: BSONTimestamp(v["seconds"], v["increment"])
523+
if isinstance(v, dict) and "seconds" in v and "increment" in v
524+
else None,
525+
"__regex__": lambda v: BSONRegex(v["pattern"], v.get("options", ""))
526+
if isinstance(v, dict) and "pattern" in v
527+
else None,
528+
}

‎packages/google-cloud-firestore/google/cloud/firestore_v1/client.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,13 +94,15 @@ def __init__(
9494
database=None,
9595
client_info=_CLIENT_INFO,
9696
client_options=None,
97+
decode_bson: bool = False,
9798
) -> None:
9899
super(Client, self).__init__(
99100
project=project,
100101
credentials=credentials,
101102
database=database,
102103
client_info=client_info,
103104
client_options=client_options,
105+
decode_bson=decode_bson,
104106
)
105107

106108
@property

‎packages/google-cloud-firestore/google/cloud/firestore_v1/pipeline_result.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
from google.cloud.firestore_v1.async_transaction import AsyncTransaction
4545
from google.cloud.firestore_v1.base_client import BaseClient
4646
from google.cloud.firestore_v1.base_document import BaseDocumentReference
47+
from google.cloud.firestore_v1.bson import _BSONType
4748
from google.cloud.firestore_v1.client import Client
4849
from google.cloud.firestore_v1.pipeline import Pipeline
4950
from google.cloud.firestore_v1.pipeline_expressions import Constant
@@ -138,7 +139,7 @@ def __eq__(self, other: object) -> bool:
138139
return NotImplemented
139140
return (self._ref == other._ref) and (self._fields_pb == other._fields_pb)
140141

141-
def data(self) -> dict | "Vector" | None:
142+
def data(self) -> dict | "Vector" | "_BSONType" | None:
142143
"""
143144
Retrieves all fields in the result.
144145

‎packages/google-cloud-firestore/tests/system/test_system.py‎

Lines changed: 5 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1285,9 +1285,9 @@ def test_unicode_doc(client, cleanup, database):
12851285

12861286

12871287
@pytest.mark.parametrize("database", [FIRESTORE_ENTERPRISE_DB], indirect=True)
1288-
def test_bson_document_writes(client, cleanup, database):
1289-
"""Test write operations for BSON types on Enterprise DB."""
1290-
collection_id = "bson_type_writes_" + UNIQUE_RESOURCE_ID
1288+
def test_bson_document_read_and_write(client, cleanup, database):
1289+
"""Test read and write operations for BSON types on Enterprise DB."""
1290+
collection_id = "bson_type_read_write_" + UNIQUE_RESOURCE_ID
12911291
doc_ref = client.collection(collection_id).document("bson_doc")
12921292
cleanup(doc_ref.delete)
12931293

@@ -1296,6 +1296,7 @@ def test_bson_document_writes(client, cleanup, database):
12961296
"min_key": BSONMinKey(),
12971297
"max_key": BSONMaxKey(),
12981298
"int32_val": BSONInt32(42),
1299+
"binary_val_sub0": b"hello",
12991300
"binary_val_sub128": BSONBinary(b"world", subtype=128),
13001301
"timestamp_val": BSONTimestamp(1700000000, 1),
13011302
"regex_val": BSONRegex("^hello.*$", options="i"),
@@ -1306,26 +1307,7 @@ def test_bson_document_writes(client, cleanup, database):
13061307

13071308
snapshot = doc_ref.get()
13081309
assert snapshot.exists
1309-
assert snapshot.to_dict() == {
1310-
"user_id": {"__oid__": "507f191e810c19729de860ea"},
1311-
"min_key": {"__min__": None},
1312-
"max_key": {"__max__": None},
1313-
"int32_val": {"__int__": 42},
1314-
"binary_val_sub128": {"__binary__": b"\x80world"},
1315-
"timestamp_val": {
1316-
"__request_timestamp__": {
1317-
"seconds": 1700000000,
1318-
"increment": 1,
1319-
}
1320-
},
1321-
"regex_val": {
1322-
"__regex__": {
1323-
"pattern": "^hello.*$",
1324-
"options": "i",
1325-
}
1326-
},
1327-
"decimal128_val": {"__decimal128__": "123.45"},
1328-
}
1310+
assert snapshot.to_dict(decode_bson=True) == bson_payload
13291311

13301312

13311313
@pytest.fixture(scope="module")

‎packages/google-cloud-firestore/tests/system/test_system_async.py‎

Lines changed: 5 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1258,9 +1258,9 @@ async def test_list_collections_with_read_time(client, cleanup, database):
12581258

12591259
@pytest.mark.asyncio
12601260
@pytest.mark.parametrize("database", [FIRESTORE_ENTERPRISE_DB], indirect=True)
1261-
async def test_async_bson_document_writes(client, cleanup, database):
1262-
"""Test async write operations for BSON types on Enterprise DB."""
1263-
collection_id = "async_bson_type_writes_" + UNIQUE_RESOURCE_ID
1261+
async def test_async_bson_document_read_and_write(client, cleanup, database):
1262+
"""Test async read and write operations for BSON types on Enterprise DB."""
1263+
collection_id = "async_bson_type_read_write_" + UNIQUE_RESOURCE_ID
12641264
doc_ref = client.collection(collection_id).document("bson_doc")
12651265
cleanup(doc_ref.delete)
12661266

@@ -1269,6 +1269,7 @@ async def test_async_bson_document_writes(client, cleanup, database):
12691269
"min_key": BSONMinKey(),
12701270
"max_key": BSONMaxKey(),
12711271
"int32_val": BSONInt32(42),
1272+
"binary_val_sub0": b"hello",
12721273
"binary_val_sub128": BSONBinary(b"world", subtype=128),
12731274
"timestamp_val": BSONTimestamp(1700000000, 1),
12741275
"regex_val": BSONRegex("^hello.*$", options="i"),
@@ -1279,26 +1280,7 @@ async def test_async_bson_document_writes(client, cleanup, database):
12791280

12801281
snapshot = await doc_ref.get()
12811282
assert snapshot.exists
1282-
assert snapshot.to_dict() == {
1283-
"user_id": {"__oid__": "507f191e810c19729de860ea"},
1284-
"min_key": {"__min__": None},
1285-
"max_key": {"__max__": None},
1286-
"int32_val": {"__int__": 42},
1287-
"binary_val_sub128": {"__binary__": b"\x80world"},
1288-
"timestamp_val": {
1289-
"__request_timestamp__": {
1290-
"seconds": 1700000000,
1291-
"increment": 1,
1292-
}
1293-
},
1294-
"regex_val": {
1295-
"__regex__": {
1296-
"pattern": "^hello.*$",
1297-
"options": "i",
1298-
}
1299-
},
1300-
"decimal128_val": {"__decimal128__": "123.45"},
1301-
}
1283+
assert snapshot.to_dict(decode_bson=True) == bson_payload
13021284

13031285

13041286
@pytest_asyncio.fixture(scope="module")

0 commit comments

Comments
 (0)