Skip to content
Draft
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 @@ -19,6 +19,17 @@

__version__ = package_version.__version__

from google.cloud.firestore_v1.bson import (
BSONBinary,
BSONDecimal128,
BSONInt32,
BSONMaxKey,
BSONMinKey,
BSONObjectID,
BSONRegex,
BSONTimestamp,
)

from typing import List

from google.cloud.firestore_v1 import (
Expand Down Expand Up @@ -92,6 +103,14 @@
"async_transactional",
"AsyncTransaction",
"AsyncWriteBatch",
"BSONBinary",
"BSONDecimal128",
"BSONInt32",
"BSONMaxKey",
"BSONMinKey",
"BSONObjectID",
"BSONRegex",
"BSONTimestamp",
"Client",
"CountAggregation",
"CollectionGroup",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,16 @@
from google.cloud.firestore_v1.base_pipeline import SubPipeline
from google.cloud.firestore_v1.base_query import And, FieldFilter, Or
from google.cloud.firestore_v1.batch import WriteBatch
from google.cloud.firestore_v1.bson import (
BSONBinary,
BSONDecimal128,
BSONInt32,
BSONMaxKey,
BSONMinKey,
BSONObjectID,
BSONRegex,
BSONTimestamp,
)
from google.cloud.firestore_v1.client import Client
from google.cloud.firestore_v1.collection import CollectionReference
from google.cloud.firestore_v1.document import DocumentReference
Expand Down Expand Up @@ -147,6 +157,14 @@
"async_transactional",
"AsyncTransaction",
"AsyncWriteBatch",
"BSONBinary",
"BSONDecimal128",
"BSONInt32",
"BSONMaxKey",
"BSONMinKey",
"BSONObjectID",
"BSONRegex",
"BSONTimestamp",
"Client",
"CountAggregation",
"CollectionGroup",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,17 @@
from google.cloud.firestore_v1.types.write import DocumentTransform
from google.cloud.firestore_v1.vector import Vector

from google.cloud.firestore_v1.bson import (
BSONBinary,
BSONDecimal128,
BSONInt32,
BSONMaxKey,
BSONMinKey,
BSONObjectID,
BSONRegex,
BSONTimestamp,
)

if TYPE_CHECKING: # pragma: NO COVER
from google.cloud.firestore_v1 import DocumentSnapshot

Expand Down Expand Up @@ -218,7 +229,7 @@ def encode_value(value) -> types.document.Value:
value_pb = document.ArrayValue(values=value_list)
return document.Value(array_value=value_pb)

if isinstance(value, Vector):
if hasattr(value, "to_map_value") and callable(getattr(value, "to_map_value")):
return encode_value(value.to_map_value())

if isinstance(value, dict):
Expand Down Expand Up @@ -415,11 +426,33 @@ def decode_dict(value_fields, client) -> Union[dict, Vector]:
value_fields_pb = getattr(value_fields, "_pb", value_fields)
res = {key: decode_value(value, client) for key, value in value_fields_pb.items()}

if res.get("__type__", None) == "__vector__":
type_tag = res.get("__type__", None)
if type_tag == "__vector__":
# Vector data type is represented as mapping.
# {"__type__":"__vector__", "value": [1.0, 2.0, 3.0]}.
values = cast(Sequence[float], res["value"])
return Vector(values)
elif "__oid__" in res:
return BSONObjectID(res["__oid__"])
elif "__decimal128__" in res:
return BSONDecimal128(res["__decimal128__"])
elif "__int__" in res:
return BSONInt32(res["__int__"])
elif "__regex__" in res:
val = res["__regex__"]
return BSONRegex(pattern=val["pattern"], flags=val.get("options", ""))
elif "__request_timestamp__" in res:
val = res["__request_timestamp__"]
return BSONTimestamp(seconds=val["seconds"], increment=val["increment"])
Comment on lines +441 to +446

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.

medium

According to the Repository Style Guide (Rule 2: Input Validation / Defensive Programming), any data loaded from external sources is untrusted and its structure must be type-validated before indexing or calling dictionary lookup keys to avoid TypeError exceptions.

If res["__regex__"] or res["__request_timestamp__"] is not a dictionary (e.g., if the database payload is malformed), accessing them via subscripting or calling .get() will raise a TypeError or AttributeError.

We should verify that val is a dictionary using isinstance(val, dict) before indexing or calling .get(). If it is not, we can gracefully fall back to returning the original decoded dictionary res.

Suggested change
elif "__regex__" in res:
val = res["__regex__"]
return BSONRegex(pattern=val["pattern"], flags=val.get("options", ""))
elif "__request_timestamp__" in res:
val = res["__request_timestamp__"]
return BSONTimestamp(seconds=val["seconds"], increment=val["increment"])
elif "__regex__" in res:
val = res["__regex__"]
if isinstance(val, dict):
return BSONRegex(pattern=val.get("pattern", ""), flags=val.get("options", ""))
elif "__request_timestamp__" in res:
val = res["__request_timestamp__"]
if isinstance(val, dict):
return BSONTimestamp(seconds=val.get("seconds", 0), increment=val.get("increment", 0))
References
  1. Rule 2: Input Validation (Defensive Programming) - Any data loaded from external configuration files/sources is untrusted. Always type-validate structure (e.g. check isinstance(data, dict)) before indexing or calling dictionary lookup keys, avoiding TypeError exceptions. (link)

elif "__binary__" in res:
raw = res["__binary__"]
if isinstance(raw, bytes) and len(raw) > 0:
return BSONBinary(sub_type=raw[0], data=raw[1:])
return BSONBinary(sub_type=0, data=b"")
elif "__min__" in res:
return BSONMinKey()
elif "__max__" in res:
return BSONMaxKey()

return res

Expand Down
201 changes: 201 additions & 0 deletions packages/google-cloud-firestore/google/cloud/firestore_v1/bson.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
"""BSON data type support for Cloud Firestore MongoDB compatibility workloads."""

from __future__ import annotations

from typing import Any, Dict, Union


class BSONObjectID:
"""A class representing BSON ObjectID in Python.

Stored as a 24-character hexadecimal string.
"""

def __init__(self, value: str):
if not isinstance(value, str) or len(value) != 24 or not all(c in "0123456789abcdefABCDEF" for c in value):
raise ValueError("BSONObjectID must be a 24-character hexadecimal string.")
self._value = value.lower()

@property
def value(self) -> str:
return self._value

def __eq__(self, other: object) -> bool:
if not isinstance(other, BSONObjectID):
return False
return self._value == other._value

def __repr__(self) -> str:
return f"BSONObjectID('{self._value}')"

def to_map_value(self) -> Dict[str, Any]:
return {"__oid__": self._value}


class BSONDecimal128:
"""A class representing BSON 128-bit Decimal in Python."""

def __init__(self, value: Union[str, int, float]):
self._value = str(value)

@property
def value(self) -> str:
return self._value

def __eq__(self, other: object) -> bool:
if not isinstance(other, BSONDecimal128):
return False
return self._value == other._value

def __repr__(self) -> str:
return f"BSONDecimal128('{self._value}')"

def to_map_value(self) -> Dict[str, Any]:
return {"__decimal128__": self._value}


class BSONInt32:
"""A class representing BSON 32-bit Integer in Python."""

def __init__(self, value: int):
self._value = int(value)
Comment on lines +75 to +76

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.

medium

The BSONInt32 class represents a 32-bit signed integer. However, there is currently no range validation in __init__. If a value outside the 32-bit signed integer range ([-2147483648, 2147483647]) is passed, it will be accepted but may cause serialization errors or silent truncation on the backend.

We should validate that the value fits within the 32-bit signed integer range during initialization.

Suggested change
def __init__(self, value: int):
self._value = int(value)
def __init__(self, value: int):
val_int = int(value)
if not -2147483648 <= val_int <= 2147483647:
raise ValueError("BSONInt32 value must be a 32-bit signed integer.")
self._value = val_int


@property
def value(self) -> int:
return self._value

def __eq__(self, other: object) -> bool:
if not isinstance(other, BSONInt32):
return False
return self._value == other._value

def __repr__(self) -> str:
return f"BSONInt32({self._value})"

def to_map_value(self) -> Dict[str, Any]:
return {"__int__": self._value}


class BSONRegex:
"""A class representing BSON Regular Expression in Python."""

def __init__(self, pattern: str, flags: str = ""):
self._pattern = str(pattern)
self._flags = str(flags) if flags is not None else ""

@property
def pattern(self) -> str:
return self._pattern

@property
def flags(self) -> str:
return self._flags

def __eq__(self, other: object) -> bool:
if not isinstance(other, BSONRegex):
return False
return self._pattern == other._pattern and self._flags == other._flags

def __repr__(self) -> str:
return f"BSONRegex(pattern='{self._pattern}', flags='{self._flags}')"

def to_map_value(self) -> Dict[str, Any]:
return {"__regex__": {"pattern": self._pattern, "options": self._flags}}


class BSONTimestamp:
"""A class representing BSON Timestamp in Python."""

def __init__(self, seconds: int, increment: int):
self._seconds = int(seconds)
self._increment = int(increment)

@property
def seconds(self) -> int:
return self._seconds

@property
def increment(self) -> int:
return self._increment

def __eq__(self, other: object) -> bool:
if not isinstance(other, BSONTimestamp):
return False
return self._seconds == other._seconds and self._increment == other._increment

def __repr__(self) -> str:
return f"BSONTimestamp(seconds={self._seconds}, increment={self._increment})"

def to_map_value(self) -> Dict[str, Any]:
return {"__request_timestamp__": {"seconds": self._seconds, "increment": self._increment}}


class BSONBinary:
"""A class representing BSON Binary data in Python."""

def __init__(self, sub_type: int, data: bytes):
self._sub_type = int(sub_type)
self._data = bytes(data)
Comment on lines +151 to +153

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.

medium

The sub_type parameter represents a BSON binary subtype, which must be a single byte (an integer between 0 and 255). If sub_type is outside this range, calling to_map_value() will raise a ValueError: bytes must be in range(0, 256) when attempting bytes([self._sub_type]).

To fail fast and ensure correctness, we should validate that sub_type is within the valid range [0, 255] during initialization.

Suggested change
def __init__(self, sub_type: int, data: bytes):
self._sub_type = int(sub_type)
self._data = bytes(data)
def __init__(self, sub_type: int, data: bytes):
sub_type_int = int(sub_type)
if not 0 <= sub_type_int <= 255:
raise ValueError("BSONBinary sub_type must be an integer between 0 and 255.")
self._sub_type = sub_type_int
self._data = bytes(data)


@property
def sub_type(self) -> int:
return self._sub_type

@property
def data(self) -> bytes:
return self._data

def __eq__(self, other: object) -> bool:
if not isinstance(other, BSONBinary):
return False
return self._sub_type == other._sub_type and self._data == other._data

def __repr__(self) -> str:
return f"BSONBinary(sub_type={self._sub_type}, data={self._data!r})"

def to_map_value(self) -> Dict[str, Any]:
return {
"__binary__": bytes([self._sub_type]) + self._data,
}


class BSONMinKey:
"""A class representing BSON MinKey in Python."""

def __eq__(self, other: object) -> bool:
return isinstance(other, BSONMinKey)

def __repr__(self) -> str:
return "BSONMinKey()"

def to_map_value(self) -> Dict[str, Any]:
return {"__min__": None}


class BSONMaxKey:
"""A class representing BSON MaxKey in Python."""

def __eq__(self, other: object) -> bool:
return isinstance(other, BSONMaxKey)

def __repr__(self) -> str:
return "BSONMaxKey()"

def to_map_value(self) -> Dict[str, Any]:
return {"__max__": None}

Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
EMULATOR_CREDS = EmulatorCreds()
FIRESTORE_EMULATOR = os.environ.get(_FIRESTORE_EMULATOR_HOST) is not None
FIRESTORE_OTHER_DB = os.environ.get("SYSTEM_TESTS_DATABASE", "system-tests-named-db")
FIRESTORE_ENTERPRISE_DB = os.environ.get("ENTERPRISE_DATABASE", "enterprise-db-native")
FIRESTORE_ENTERPRISE_DB = os.environ.get("ENTERPRISE_DATABASE", "enterprise-db-native-2")

# To eliminate test duplication, we use the default database for the
# core test suites. The named database is ONLY tested explicitly in dedicated
Expand Down
36 changes: 36 additions & 0 deletions packages/google-cloud-firestore/tests/system/test_system.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,16 @@
)
from google.cloud._helpers import _datetime_to_pb_timestamp
from google.oauth2 import service_account
from google.cloud.firestore import (
BSONBinary,
BSONDecimal128,
BSONInt32,
BSONMaxKey,
BSONMinKey,
BSONObjectID,
BSONRegex,
BSONTimestamp,
)
from test__helpers import (
EMULATOR_CREDS,
ENTERPRISE_MODE_ERROR,
Expand Down Expand Up @@ -3872,3 +3882,29 @@ def test_large_document_pipeline(client, cleanup, database, method):

results = list(method_under_test())
assert [doc.data() for doc in results] == [{"payload": large_payload}]

#@pytest.mark.skip(reason="Temporarily skipped. Requires backend BSON / MongoDB feature flag.")
@pytest.mark.parametrize("database", [FIRESTORE_ENTERPRISE_DB], indirect=True)
def test_bson_document_writes_and_reads(client, cleanup, database):
"""Test standard write and read operations for BSON types on Enterprise DB."""
collection_id = "bson_docs_" + UNIQUE_RESOURCE_ID
doc_ref = client.collection(collection_id).document("bson_doc")
cleanup(doc_ref.delete)

bson_payload = {
"_id": BSONObjectID("507f191e810c19729de860ea"),
"price": BSONDecimal128("199.99"),
"qty": BSONInt32(50),
"pattern": BSONRegex(pattern="^prod.*", flags="i"),
"ts": BSONTimestamp(seconds=1710000000, increment=2),
"binary_data": BSONBinary(sub_type=1, data=b"binary_payload"),
"min_key": BSONMinKey(),
"max_key": BSONMaxKey(),
}

doc_ref.set(bson_payload)

snapshot = doc_ref.get()
assert snapshot.exists
assert snapshot.to_dict() == bson_payload

Loading
Loading