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..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, @@ -43,7 +44,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 +171,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 @@ -179,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) @@ -203,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 @@ -230,11 +238,73 @@ 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) ) +# 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"): + 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"): + 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) + if cls_name == "MinKey": + 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 + + 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 88fe361eee31..fb2ac42a0b5d 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,112 @@ 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 + + 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 + + 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" + + dec_obj = Decimal128("123.45") + 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" + ) + + 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 ( + 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 + + import pytest + + with pytest.raises(ValueError): + encode_value(ObjectId("invalid_hex")) + + def test_geopoint___eq__w_same_value(): lat = 0.015625 lng = 20.03125