Skip to content

Commit eb68d2c

Browse files
committed
feat(firestore): add zero-dependency PyMongo duck-typing write support (PR 1B)
1 parent 35f2aab commit eb68d2c

3 files changed

Lines changed: 134 additions & 5 deletions

File tree

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

Lines changed: 44 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
import datetime
2020
import json
21+
import re
2122
from typing import (
2223
TYPE_CHECKING,
2324
Any,
@@ -44,6 +45,12 @@
4445
import google
4546
from google.cloud import exceptions # type: ignore
4647
from google.cloud.firestore_v1 import transforms, types
48+
from google.cloud.firestore_v1.bson import (
49+
BSONBinary,
50+
BSONDecimal128,
51+
BSONObjectID,
52+
BSONRegex,
53+
)
4754
from google.cloud.firestore_v1.field_path import FieldPath, parse_field_path
4855
from google.cloud.firestore_v1.types import common, document, write
4956
from google.cloud.firestore_v1.types.write import DocumentTransform
@@ -163,13 +170,28 @@ def verify_path(path, is_collection) -> None:
163170
raise ValueError(msg)
164171

165172

166-
def encode_value(value) -> types.document.Value:
167-
"""Converts a native Python value into a Firestore protobuf ``Value``.
173+
_REGEX_FLAG_MAP = (
174+
(re.IGNORECASE, "i"),
175+
(re.MULTILINE, "m"),
176+
(re.DOTALL, "s"),
177+
(re.VERBOSE, "x"),
178+
(re.LOCALE, "l"),
179+
)
180+
181+
182+
def _extract_regex_options(flags: Union[int, str]) -> str:
183+
if isinstance(flags, str):
184+
return flags
185+
if isinstance(flags, int):
186+
return "".join(char for bit, char in _REGEX_FLAG_MAP if flags & bit)
187+
return ""
188+
189+
190+
def encode_value(value: Any) -> document.Value:
191+
"""Convert a Python value into a Value protobuf.
168192
169193
Args:
170-
value (Union[NoneType, bool, int, float, datetime.datetime, \
171-
str, bytes, dict, ~google.cloud.Firestore.GeoPoint, \
172-
~google.cloud.firestore_v1.vector.Vector]): A native
194+
value (Any): The
173195
Python value to convert to a protobuf field.
174196
175197
Returns:
@@ -186,6 +208,23 @@ def encode_value(value) -> types.document.Value:
186208
if callable(to_map):
187209
return encode_value(to_map())
188210

211+
# Duck-typing input bridge for external PyMongo / bson package objects (zero dependency)
212+
binary_attr = getattr(value, "binary", None)
213+
if binary_attr is not None and not isinstance(
214+
value, (bytes, bytearray, BSONBinary)
215+
):
216+
return encode_value(BSONObjectID(binary_attr))
217+
218+
to_decimal_fn = getattr(value, "to_decimal", None)
219+
if callable(to_decimal_fn) and not isinstance(value, BSONDecimal128):
220+
return encode_value(BSONDecimal128(to_decimal_fn()))
221+
222+
pattern_attr = getattr(value, "pattern", None)
223+
if pattern_attr is not None and not isinstance(value, (str, BSONRegex)):
224+
flags_attr = getattr(value, "flags", "")
225+
options_str = _extract_regex_options(flags_attr)
226+
return encode_value(BSONRegex(pattern_attr, options_str))
227+
189228
# Must come before int since ``bool`` is an integer subtype.
190229
if isinstance(value, bool):
191230
return document.Value(boolean_value=value)

packages/google-cloud-firestore/tests/system/test_bson.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,3 +66,40 @@ def test_bson_document_writes(client, cleanup, database):
6666

6767
snapshot = doc_ref.get()
6868
assert snapshot.exists
69+
70+
71+
@pytest.mark.parametrize("database", [FIRESTORE_ENTERPRISE_DB], indirect=True)
72+
def test_pymongo_document_writes(client, cleanup, database):
73+
"""Test write operations using native duck-typed PyMongo objects on Enterprise DB."""
74+
import decimal
75+
76+
class DummyPyMongoObjectId:
77+
def __init__(self, raw: bytes):
78+
self.binary = raw
79+
80+
class DummyPyMongoDecimal128:
81+
def __init__(self, d: decimal.Decimal):
82+
self._d = d
83+
84+
def to_decimal(self):
85+
return self._d
86+
87+
class DummyPyMongoRegex:
88+
def __init__(self, pat: str, flags: str):
89+
self.pattern = pat
90+
self.flags = flags
91+
92+
collection_id = "pymongo_docs_write_" + UNIQUE_RESOURCE_ID
93+
doc_ref = client.collection(collection_id).document("pymongo_doc")
94+
cleanup(doc_ref.delete)
95+
96+
payload = {
97+
"_id": DummyPyMongoObjectId(bytes.fromhex("507f191e810c19729de860ea")),
98+
"price": DummyPyMongoDecimal128(decimal.Decimal("99.99")),
99+
"pattern": DummyPyMongoRegex("^test.*", "i"),
100+
}
101+
102+
doc_ref.set(payload)
103+
104+
snapshot = doc_ref.get()
105+
assert snapshot.exists

packages/google-cloud-firestore/tests/unit/v1/test__helpers.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -354,6 +354,45 @@ def test_encode_dict_w_many_types():
354354
assert encoded_dict == expected_dict
355355

356356

357+
def test_encode_value_duck_typed_pymongo():
358+
import decimal
359+
360+
from google.cloud.firestore_v1._helpers import encode_value
361+
362+
class DummyPyMongoObjectId:
363+
def __init__(self, raw: bytes):
364+
self.binary = raw
365+
366+
class DummyPyMongoDecimal128:
367+
def __init__(self, d: decimal.Decimal):
368+
self._d = d
369+
370+
def to_decimal(self):
371+
return self._d
372+
373+
class DummyPyMongoRegex:
374+
def __init__(self, pat: str, flags: str):
375+
self.pattern = pat
376+
self.flags = flags
377+
378+
dummy_oid = DummyPyMongoObjectId(bytes.fromhex("507f1f77bcf86cd799439011"))
379+
res_oid = encode_value(dummy_oid)
380+
assert (
381+
res_oid.map_value.fields["__oid__"].string_value == "507f1f77bcf86cd799439011"
382+
)
383+
384+
dummy_dec = DummyPyMongoDecimal128(decimal.Decimal("99.99"))
385+
res_dec = encode_value(dummy_dec)
386+
assert res_dec.map_value.fields["__decimal128__"].string_value == "99.99"
387+
388+
dummy_reg = DummyPyMongoRegex("^test$", "i")
389+
res_reg = encode_value(dummy_reg)
390+
assert (
391+
res_reg.map_value.fields["__regex__"].map_value.fields["pattern"].string_value
392+
== "^test$"
393+
)
394+
395+
357396
def test_reference_value_to_document_w_bad_format():
358397
from google.cloud.firestore_v1._helpers import (
359398
BAD_REFERENCE_ERROR,
@@ -2572,3 +2611,17 @@ def _make_field_path(*fields):
25722611
from google.cloud.firestore_v1 import field_path
25732612

25742613
return field_path.FieldPath(*fields)
2614+
2615+
2616+
def test_encode_value_w_compiled_regex_flags():
2617+
import re
2618+
2619+
from google.cloud.firestore_v1._helpers import encode_value
2620+
2621+
compiled_re = re.compile("abc", re.I | re.M)
2622+
encoded = encode_value(compiled_re)
2623+
# Checks that compiled regex integer flags translate to 'im' options
2624+
fields = encoded.map_value.fields
2625+
assert fields["__regex__"].map_value.fields["pattern"].string_value == "abc"
2626+
assert "i" in fields["__regex__"].map_value.fields["options"].string_value
2627+
assert "m" in fields["__regex__"].map_value.fields["options"].string_value

0 commit comments

Comments
 (0)