From f8d99db8f02fa35f05c3fceb1d50332d393c0361 Mon Sep 17 00:00:00 2001 From: ohmayr Date: Wed, 16 Sep 2026 22:56:59 +0000 Subject: [PATCH 1/3] feat(firestore): add PyMongo duck-typing serialization support --- .../google/cloud/firestore_v1/_helpers.py | 26 ++++++++++++++++ .../tests/unit/v1/test__helpers.py | 30 +++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/packages/google-cloud-firestore/google/cloud/firestore_v1/_helpers.py b/packages/google-cloud-firestore/google/cloud/firestore_v1/_helpers.py index afed5b2a703c..e35b51dc7872 100644 --- a/packages/google-cloud-firestore/google/cloud/firestore_v1/_helpers.py +++ b/packages/google-cloud-firestore/google/cloud/firestore_v1/_helpers.py @@ -214,6 +214,32 @@ def encode_value(value) -> types.document.Value: if isinstance(value, BSONType): return encode_value(value._to_map_value()) + # Duck-type native PyMongo / third-party BSON objects + if hasattr(value, "__class__"): + cls_name = value.__class__.__name__ + if cls_name == "ObjectId" and hasattr(value, "binary"): + return encode_value({"__oid__": str(value).lower()}) + if cls_name == "Decimal128" and hasattr(value, "to_decimal"): + return encode_value({"__decimal128__": str(value)}) + if cls_name == "Regex" and hasattr(value, "pattern"): + opts = getattr(value, "flags", "") or getattr(value, "options", "") + return encode_value( + {"__regex__": {"pattern": value.pattern, "options": str(opts)}} + ) + if cls_name == "Timestamp" and hasattr(value, "time") and hasattr(value, "inc"): + return encode_value( + { + "__request_timestamp__": { + "seconds": value.time, + "increment": value.inc, + } + } + ) + if cls_name == "MinKey": + return encode_value({"__min__": None}) + if cls_name == "MaxKey": + return encode_value({"__max__": None}) + if isinstance(value, GeoPoint): return document.Value(geo_point_value=value.to_protobuf()) diff --git a/packages/google-cloud-firestore/tests/unit/v1/test__helpers.py b/packages/google-cloud-firestore/tests/unit/v1/test__helpers.py index 88fe361eee31..1c755d5b4c6c 100644 --- a/packages/google-cloud-firestore/tests/unit/v1/test__helpers.py +++ b/packages/google-cloud-firestore/tests/unit/v1/test__helpers.py @@ -46,6 +46,36 @@ def test_geopoint_to_protobuf(): assert result == geo_pt_pb +def test_encode_value_pymongo_duck_typing(): + from google.cloud.firestore_v1._helpers import encode_value + + class ObjectId: + def __init__(self, val): + self.val = val + self.binary = b"12bytes_raw_" + + def __str__(self): + return self.val + + class Decimal128: + def __init__(self, val): + self.val = val + + def to_decimal(self): + return self.val + + def __str__(self): + return self.val + + oid_obj = ObjectId("507f191e810c19729de860ea") + oid_pb = encode_value(oid_obj) + assert oid_pb.map_value.fields["__oid__"].string_value == "507f191e810c19729de860ea" + + dec_obj = Decimal128("123.45") + dec_pb = encode_value(dec_obj) + assert dec_pb.map_value.fields["__decimal128__"].string_value == "123.45" + + def test_geopoint___eq__w_same_value(): lat = 0.015625 lng = 20.03125 From 6e38ba673fa7905f0086a40d554635702b028a43 Mon Sep 17 00:00:00 2001 From: ohmayr Date: Mon, 21 Sep 2026 20:00:24 +0000 Subject: [PATCH 2/3] feat(firestore): optimize PyMongo duck typing serialization and delegate to BSON types Move duck-typing checks in encode_value() to a fallback position before raising TypeError, preserving hot-path performance for standard types. Delegate conversion of duck-typed objects to Firestore BSON classes rather than manually creating wire dictionaries, and document supported BSON types in the encode_value docstring. --- .../google/cloud/firestore_v1/_helpers.py | 58 +++++++++---------- .../tests/unit/v1/test__helpers.py | 50 ++++++++++++++++ 2 files changed, 79 insertions(+), 29 deletions(-) diff --git a/packages/google-cloud-firestore/google/cloud/firestore_v1/_helpers.py b/packages/google-cloud-firestore/google/cloud/firestore_v1/_helpers.py index e35b51dc7872..462525da04ab 100644 --- a/packages/google-cloud-firestore/google/cloud/firestore_v1/_helpers.py +++ b/packages/google-cloud-firestore/google/cloud/firestore_v1/_helpers.py @@ -43,7 +43,7 @@ import google from google.cloud import exceptions # type: ignore -from google.cloud.firestore_v1 import transforms, types +from google.cloud.firestore_v1 import bson, transforms, types from google.cloud.firestore_v1.bson import BSONType from google.cloud.firestore_v1.field_path import FieldPath, parse_field_path from google.cloud.firestore_v1.types import common, document, write @@ -170,8 +170,10 @@ def encode_value(value) -> types.document.Value: Args: value (Union[NoneType, bool, int, float, datetime.datetime, \ str, bytes, dict, ~google.cloud.Firestore.GeoPoint, \ - ~google.cloud.firestore_v1.vector.Vector]): A native - Python value to convert to a protobuf field. + ~google.cloud.firestore_v1.vector.Vector, \ + ~google.cloud.firestore_v1.bson._BSONType]): A native \ + Python value or supported BSON / PyMongo-compatible value to \ + convert to a protobuf field. Returns: ~google.cloud.firestore_v1.types.Value: A @@ -214,32 +216,6 @@ def encode_value(value) -> types.document.Value: if isinstance(value, BSONType): return encode_value(value._to_map_value()) - # Duck-type native PyMongo / third-party BSON objects - if hasattr(value, "__class__"): - cls_name = value.__class__.__name__ - if cls_name == "ObjectId" and hasattr(value, "binary"): - return encode_value({"__oid__": str(value).lower()}) - if cls_name == "Decimal128" and hasattr(value, "to_decimal"): - return encode_value({"__decimal128__": str(value)}) - if cls_name == "Regex" and hasattr(value, "pattern"): - opts = getattr(value, "flags", "") or getattr(value, "options", "") - return encode_value( - {"__regex__": {"pattern": value.pattern, "options": str(opts)}} - ) - if cls_name == "Timestamp" and hasattr(value, "time") and hasattr(value, "inc"): - return encode_value( - { - "__request_timestamp__": { - "seconds": value.time, - "increment": value.inc, - } - } - ) - if cls_name == "MinKey": - return encode_value({"__min__": None}) - if cls_name == "MaxKey": - return encode_value({"__max__": None}) - if isinstance(value, GeoPoint): return document.Value(geo_point_value=value.to_protobuf()) @@ -256,11 +232,35 @@ def encode_value(value) -> types.document.Value: value_pb = document.MapValue(fields=value_dict) return document.Value(map_value=value_pb) + # Fallback: Coerce third-party BSON objects (e.g. PyMongo) to Firestore BSON types + bson_val = _try_duck_type_bson(value) + if bson_val is not None: + return encode_value(bson_val._to_map_value()) + raise TypeError( "Cannot convert to a Firestore Value", value, "Invalid type", type(value) ) +def _try_duck_type_bson(value) -> Optional[bson._BSONType]: + """Coerce third-party BSON objects (e.g. PyMongo) to Firestore BSON types.""" + cls_name = getattr(value.__class__, "__name__", "") + if cls_name == "ObjectId" and hasattr(value, "binary"): + return bson.BSONObjectId(str(value).lower()) + if cls_name == "Decimal128" and hasattr(value, "to_decimal"): + return bson.BSONDecimal128(value.to_decimal()) + if cls_name == "Regex" and hasattr(value, "pattern"): + opts = getattr(value, "flags", "") or getattr(value, "options", "") + return bson.BSONRegex(value.pattern, opts) + if cls_name == "Timestamp" and hasattr(value, "time") and hasattr(value, "inc"): + return bson.BSONTimestamp(value.time, value.inc) + if cls_name == "MinKey": + return bson.BSONMinKey() + if cls_name == "MaxKey": + return bson.BSONMaxKey() + return None + + def encode_dict(values_dict) -> dict: """Encode a dictionary into protobuf ``Value``-s. diff --git a/packages/google-cloud-firestore/tests/unit/v1/test__helpers.py b/packages/google-cloud-firestore/tests/unit/v1/test__helpers.py index 1c755d5b4c6c..012ea0b64829 100644 --- a/packages/google-cloud-firestore/tests/unit/v1/test__helpers.py +++ b/packages/google-cloud-firestore/tests/unit/v1/test__helpers.py @@ -67,6 +67,22 @@ def to_decimal(self): def __str__(self): return self.val + class Regex: + def __init__(self, pattern, flags="i"): + self.pattern = pattern + self.flags = flags + + class Timestamp: + def __init__(self, time, inc): + self.time = time + self.inc = inc + + class MinKey: + pass + + class MaxKey: + pass + oid_obj = ObjectId("507f191e810c19729de860ea") oid_pb = encode_value(oid_obj) assert oid_pb.map_value.fields["__oid__"].string_value == "507f191e810c19729de860ea" @@ -75,6 +91,40 @@ def __str__(self): dec_pb = encode_value(dec_obj) assert dec_pb.map_value.fields["__decimal128__"].string_value == "123.45" + regex_obj = Regex("^[a-z]+$", "i") + regex_pb = encode_value(regex_obj) + assert ( + regex_pb.map_value.fields["__regex__"].map_value.fields["pattern"].string_value + == "^[a-z]+$" + ) + assert ( + regex_pb.map_value.fields["__regex__"].map_value.fields["options"].string_value + == "i" + ) + + ts_obj = Timestamp(1700000000, 42) + ts_pb = encode_value(ts_obj) + assert ( + ts_pb.map_value.fields["__request_timestamp__"] + .map_value.fields["seconds"] + .integer_value + == 1700000000 + ) + assert ( + ts_pb.map_value.fields["__request_timestamp__"] + .map_value.fields["increment"] + .integer_value + == 42 + ) + + min_obj = MinKey() + min_pb = encode_value(min_obj) + assert "__min__" in min_pb.map_value.fields + + max_obj = MaxKey() + max_pb = encode_value(max_obj) + assert "__max__" in max_pb.map_value.fields + def test_geopoint___eq__w_same_value(): lat = 0.015625 From 8b6c670d200753ecea6c7c4f3a21dad22a5b4e1e Mon Sep 17 00:00:00 2001 From: ohmayr Date: Wed, 23 Sep 2026 20:17:51 +0000 Subject: [PATCH 3/3] fix(firestore): preserve Binary subtype and support int flags in duck typing Support PyMongo Binary subclasses without losing subtype tags by inspecting subtype attribute in bytes check. Support integer regex bitmasks in duck-typed Regex by mapping standard re flags to BSON options characters. Document ValueError in encode_value. --- .../google/cloud/firestore_v1/_helpers.py | 48 ++++++++++++++++++- .../tests/unit/v1/test__helpers.py | 26 ++++++++++ 2 files changed, 72 insertions(+), 2 deletions(-) diff --git a/packages/google-cloud-firestore/google/cloud/firestore_v1/_helpers.py b/packages/google-cloud-firestore/google/cloud/firestore_v1/_helpers.py index 462525da04ab..53fc98573814 100644 --- a/packages/google-cloud-firestore/google/cloud/firestore_v1/_helpers.py +++ b/packages/google-cloud-firestore/google/cloud/firestore_v1/_helpers.py @@ -18,6 +18,7 @@ import datetime import json +import re from typing import ( TYPE_CHECKING, Any, @@ -181,6 +182,8 @@ def encode_value(value) -> types.document.Value: Raises: TypeError: If the ``value`` is not one of the accepted types. + ValueError: If a BSON or duck-typed BSON value has an invalid value + or representation (e.g. invalid ObjectId hex or binary subtype). """ if value is None: return document.Value(null_value=struct_pb2.NULL_VALUE) @@ -205,6 +208,9 @@ def encode_value(value) -> types.document.Value: return document.Value(string_value=value) if isinstance(value, bytes): + subtype = getattr(value, "subtype", None) + if subtype is not None: + return encode_value(bson.BSONBinary(value, subtype=subtype)._to_map_value()) return document.Value(bytes_value=value) # NOTE: We avoid doing an isinstance() check for a Document @@ -242,7 +248,40 @@ def encode_value(value) -> types.document.Value: ) -def _try_duck_type_bson(value) -> Optional[bson._BSONType]: +# Mapping of Python standard library regex flags to their canonical BSON regex +# option characters per the BSON specification (https://bsonspec.org/spec.html, type 0x0B). +# Stored in alphabetical order of option characters to produce normalized output. +_REGEX_FLAG_TO_BSON_CHAR: Tuple[Tuple[int, str], ...] = ( + (re.IGNORECASE, "i"), # Case-insensitive matching + (re.LOCALE, "l"), # Locale-dependent matching + (re.MULTILINE, "m"), # Multi-line matching + (re.DOTALL, "s"), # Dot matches all (including newline) + (re.UNICODE, "u"), # Unicode matching + (re.VERBOSE, "x"), # Verbose / whitespace-ignored matching +) + + +def _flags_to_options_string(flags: Any) -> str: + """Convert regex flags to a normalized BSON options string. + + Supports string options directly (e.g. ``"i"``), integer bitmasks from + the standard library ``re`` module (e.g. ``re.IGNORECASE | re.MULTILINE``), + or third-party driver types like PyMongo's ``Regex.flags``. + + Args: + flags (Any): A string of flag characters or an integer bitmask of regex flags. + + Returns: + str: The corresponding BSON regex options string. + """ + if isinstance(flags, str): + return flags + if isinstance(flags, int): + return "".join(char for flag, char in _REGEX_FLAG_TO_BSON_CHAR if flags & flag) + return str(flags) + + +def _try_duck_type_bson(value) -> Optional[BSONType]: """Coerce third-party BSON objects (e.g. PyMongo) to Firestore BSON types.""" cls_name = getattr(value.__class__, "__name__", "") if cls_name == "ObjectId" and hasattr(value, "binary"): @@ -250,7 +289,10 @@ def _try_duck_type_bson(value) -> Optional[bson._BSONType]: if cls_name == "Decimal128" and hasattr(value, "to_decimal"): return bson.BSONDecimal128(value.to_decimal()) if cls_name == "Regex" and hasattr(value, "pattern"): - opts = getattr(value, "flags", "") or getattr(value, "options", "") + raw_opts = getattr(value, "flags", None) + if raw_opts is None or raw_opts == "": + raw_opts = getattr(value, "options", "") + opts = _flags_to_options_string(raw_opts) return bson.BSONRegex(value.pattern, opts) if cls_name == "Timestamp" and hasattr(value, "time") and hasattr(value, "inc"): return bson.BSONTimestamp(value.time, value.inc) @@ -258,6 +300,8 @@ def _try_duck_type_bson(value) -> Optional[bson._BSONType]: return bson.BSONMinKey() if cls_name == "MaxKey": return bson.BSONMaxKey() + if cls_name == "Binary" and hasattr(value, "subtype"): + return bson.BSONBinary(value, subtype=value.subtype) return None diff --git a/packages/google-cloud-firestore/tests/unit/v1/test__helpers.py b/packages/google-cloud-firestore/tests/unit/v1/test__helpers.py index 012ea0b64829..fb2ac42a0b5d 100644 --- a/packages/google-cloud-firestore/tests/unit/v1/test__helpers.py +++ b/packages/google-cloud-firestore/tests/unit/v1/test__helpers.py @@ -83,6 +83,12 @@ class MinKey: class MaxKey: pass + class Binary(bytes): + def __new__(cls, data, subtype=0): + obj = super().__new__(cls, data) + obj.subtype = subtype + return obj + oid_obj = ObjectId("507f191e810c19729de860ea") oid_pb = encode_value(oid_obj) assert oid_pb.map_value.fields["__oid__"].string_value == "507f191e810c19729de860ea" @@ -102,6 +108,21 @@ class MaxKey: == "i" ) + import re + + regex_int_flags = Regex("^[a-z]+$", re.IGNORECASE | re.MULTILINE) + regex_int_pb = encode_value(regex_int_flags) + assert ( + regex_int_pb.map_value.fields["__regex__"] + .map_value.fields["options"] + .string_value + == "im" + ) + + bin_obj = Binary(b"\x01\x02\x03", subtype=128) + bin_pb = encode_value(bin_obj) + assert bin_pb.map_value.fields["__binary__"].bytes_value == b"\x80\x01\x02\x03" + ts_obj = Timestamp(1700000000, 42) ts_pb = encode_value(ts_obj) assert ( @@ -125,6 +146,11 @@ class MaxKey: max_pb = encode_value(max_obj) assert "__max__" in max_pb.map_value.fields + import pytest + + with pytest.raises(ValueError): + encode_value(ObjectId("invalid_hex")) + def test_geopoint___eq__w_same_value(): lat = 0.015625