Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@

import datetime
import json
import re
from typing import (
TYPE_CHECKING,
Any,
Expand All @@ -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
Expand Down Expand Up @@ -170,15 +171,19 @@ 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
value encoded as a Firestore protobuf.

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)
Expand All @@ -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
Expand Down Expand Up @@ -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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can opts here be non-string? Gemini flagged this as a potential issue

(I know I started a thread suggesting to not support int, but I don't feel too strongly one way or the other if it would make things easier here. We just need to make sure we're communicating what flags we accept and what we reject clearly)

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.

Expand Down
106 changes: 106 additions & 0 deletions packages/google-cloud-firestore/tests/unit/v1/test__helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading