diff --git a/packages/google-cloud-firestore/google/cloud/firestore/__init__.py b/packages/google-cloud-firestore/google/cloud/firestore/__init__.py index 47eac898520d..aa5ed1a19128 100644 --- a/packages/google-cloud-firestore/google/cloud/firestore/__init__.py +++ b/packages/google-cloud-firestore/google/cloud/firestore/__init__.py @@ -35,6 +35,14 @@ AsyncQuery, AsyncTransaction, AsyncWriteBatch, + BSONBinary, + BSONDecimal128, + BSONInt32, + BSONMaxKey, + BSONMinKey, + BSONObjectID, + BSONRegex, + BSONTimestamp, Client, CollectionGroup, CollectionReference, @@ -92,6 +100,14 @@ "async_transactional", "AsyncTransaction", "AsyncWriteBatch", + "BSONBinary", + "BSONDecimal128", + "BSONInt32", + "BSONMaxKey", + "BSONMinKey", + "BSONObjectID", + "BSONRegex", + "BSONTimestamp", "Client", "CountAggregation", "CollectionGroup", diff --git a/packages/google-cloud-firestore/google/cloud/firestore_v1/__init__.py b/packages/google-cloud-firestore/google/cloud/firestore_v1/__init__.py index 38593d61832f..0cac575e2af7 100644 --- a/packages/google-cloud-firestore/google/cloud/firestore_v1/__init__.py +++ b/packages/google-cloud-firestore/google/cloud/firestore_v1/__init__.py @@ -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 @@ -147,6 +157,14 @@ "async_transactional", "AsyncTransaction", "AsyncWriteBatch", + "BSONBinary", + "BSONDecimal128", + "BSONInt32", + "BSONMaxKey", + "BSONMinKey", + "BSONObjectID", + "BSONRegex", + "BSONTimestamp", "Client", "CountAggregation", "CollectionGroup", 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 04cdd05f0c45..fdb12a36e741 100644 --- a/packages/google-cloud-firestore/google/cloud/firestore_v1/_helpers.py +++ b/packages/google-cloud-firestore/google/cloud/firestore_v1/_helpers.py @@ -44,6 +44,7 @@ import google from google.cloud import exceptions # type: ignore from google.cloud.firestore_v1 import 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 from google.cloud.firestore_v1.types.write import DocumentTransform @@ -182,6 +183,9 @@ def encode_value(value) -> types.document.Value: if value is None: return document.Value(null_value=struct_pb2.NULL_VALUE) + if isinstance(value, BSONType): + return encode_value(value.to_map_value()) + # Must come before int since ``bool`` is an integer subtype. if isinstance(value, bool): return document.Value(boolean_value=value) diff --git a/packages/google-cloud-firestore/google/cloud/firestore_v1/bson.py b/packages/google-cloud-firestore/google/cloud/firestore_v1/bson.py new file mode 100644 index 000000000000..7a11ac130ae5 --- /dev/null +++ b/packages/google-cloud-firestore/google/cloud/firestore_v1/bson.py @@ -0,0 +1,350 @@ +# -*- 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 types support for Firestore Python SDK.""" + +import abc +import decimal +import re +from typing import Any, Dict, Union + +__all__ = [ + "BSONType", + "BSONObjectID", + "BSONDecimal128", + "BSONTimestamp", + "BSONRegex", + "BSONBinary", + "BSONInt32", + "BSONMinKey", + "BSONMaxKey", +] + +_HEX_24_REGEX = re.compile(r"^[0-9a-fA-F]{24}$") + + +def _is_int(val: Any) -> bool: + return isinstance(val, int) and not isinstance(val, bool) + + +class BSONType(abc.ABC): + """Abstract base class for all BSON type containers.""" + + __slots__ = () + + @abc.abstractmethod + def to_map_value(self) -> Dict[str, Any]: + """Returns legacy map dictionary representation for wire serialization.""" + + @abc.abstractmethod + def __eq__(self, other: Any) -> bool: + """Value equality comparison.""" + + @abc.abstractmethod + def __hash__(self) -> int: + """Hash representation for set and dict keys.""" + + +class BSONObjectID(BSONType): + """Represents a 12-byte BSON ObjectID.""" + + __slots__ = ("_value",) + + def __init__(self, value: Union[str, bytes]): + if isinstance(value, str): + if not _HEX_24_REGEX.match(value): + raise ValueError( + "BSONObjectID string must be a 24-character hex string." + ) + self._value: str = value.lower() + elif isinstance(value, bytes): + if len(value) != 12: + raise ValueError("BSONObjectID bytes input must be 12 raw bytes.") + self._value = value.hex() + else: + raise TypeError("BSONObjectID requires str or bytes.") + + @property + def value(self) -> str: + return self._value + + def to_map_value(self) -> Dict[str, str]: + """Returns legacy map dictionary representation for wire serialization.""" + return {"__oid__": self._value} + + def __repr__(self) -> str: + return f"BSONObjectID('{self._value}')" + + def __eq__(self, other: Any) -> bool: + if isinstance(other, BSONObjectID): + return self._value == other._value + return False + + def __hash__(self) -> int: + return hash(self._value) + + +class BSONDecimal128(BSONType): + """Represents a BSON IEEE 754-2008 Decimal128 value.""" + + __slots__ = ("_value",) + + def __init__(self, value: Union[str, decimal.Decimal]): + if isinstance(value, bool): + raise TypeError("BSONDecimal128 value cannot be bool.") + + if isinstance(value, decimal.Decimal): + self._value: str = str(value) + elif isinstance(value, str): + try: + decimal.Decimal(value) + except (decimal.DecimalException, ArithmeticError, ValueError) as exc: + raise ValueError( + f"Invalid Decimal128 string format: {value!r}" + ) from exc + self._value = value + else: + raise TypeError("BSONDecimal128 requires str or decimal.Decimal.") + + @property + def value(self) -> str: + return self._value + + def to_decimal(self) -> decimal.Decimal: + """Converts to a native Python decimal.Decimal object.""" + return decimal.Decimal(self._value) + + def to_map_value(self) -> Dict[str, str]: + """Returns legacy map dictionary representation for wire serialization.""" + return {"__decimal128__": self._value} + + def __repr__(self) -> str: + return f"BSONDecimal128('{self._value}')" + + def __eq__(self, other: Any) -> bool: + if isinstance(other, BSONDecimal128): + other_dec = other.to_decimal() + elif isinstance(other, decimal.Decimal): + other_dec = other + else: + return False + + try: + self_dec = self.to_decimal() + if self_dec.is_nan() or other_dec.is_nan(): + return False + return self_dec == other_dec + except (decimal.DecimalException, ArithmeticError): + return False + + def __hash__(self) -> int: + try: + d = self.to_decimal() + if d.is_nan(): + return hash(self._value) + return hash(d) + except (decimal.DecimalException, ArithmeticError): + return hash(self._value) + + +class BSONTimestamp(BSONType): + """Represents a BSON Timestamp (seconds + increment uint32 pair).""" + + __slots__ = ("_seconds", "_increment") + + def __init__(self, seconds: int, increment: int): + if not _is_int(seconds) or not _is_int(increment): + raise TypeError("seconds and increment must be ints.") + if not (0 <= seconds <= 4294967295) or not (0 <= increment <= 4294967295): + raise ValueError("seconds and increment must be uint32 (0 to 4294967295).") + self._seconds: int = int(seconds) + self._increment: int = int(increment) + + @property + def seconds(self) -> int: + return self._seconds + + @property + def increment(self) -> int: + return self._increment + + def to_map_value(self) -> Dict[str, Dict[str, int]]: + """Returns legacy map dictionary representation for wire serialization.""" + return { + "__timestamp__": {"seconds": self._seconds, "increment": self._increment} + } + + def __repr__(self) -> str: + return f"BSONTimestamp(seconds={self._seconds}, increment={self._increment})" + + def __eq__(self, other: Any) -> bool: + if isinstance(other, BSONTimestamp): + return (self._seconds, self._increment) == ( + other._seconds, + other._increment, + ) + return False + + def __hash__(self) -> int: + return hash((self._seconds, self._increment)) + + +class BSONRegex(BSONType): + """Represents a BSON Regular Expression.""" + + __slots__ = ("_pattern", "_options") + + def __init__(self, pattern: str, options: str = ""): + if not isinstance(pattern, str) or not isinstance(options, str): + raise TypeError("pattern and options must be strings.") + self._pattern: str = pattern + self._options: str = "".join(sorted(set(options))) + + @property + def pattern(self) -> str: + return self._pattern + + @property + def options(self) -> str: + return self._options + + def to_map_value(self) -> Dict[str, Dict[str, str]]: + """Returns legacy map dictionary representation for wire serialization.""" + return {"__regex__": {"pattern": self._pattern, "options": self._options}} + + def __repr__(self) -> str: + return f"BSONRegex(pattern={self._pattern!r}, options={self._options!r})" + + def __eq__(self, other: Any) -> bool: + if isinstance(other, BSONRegex): + return (self._pattern, self._options) == (other._pattern, other._options) + return False + + def __hash__(self) -> int: + return hash((self._pattern, self._options)) + + +class BSONBinary(BSONType): + """Represents BSON Binary data with a subtype.""" + + __slots__ = ("_subtype", "_data") + + def __init__(self, data: bytes, subtype: int = 0): + if not _is_int(subtype): + raise TypeError("subtype must be an integer.") + if not (0 <= subtype <= 255): + raise ValueError("subtype must be in range 0..255.") + if not isinstance(data, bytes): + raise TypeError("data must be bytes.") + self._data: bytes = data + self._subtype: int = int(subtype) + + @property + def subtype(self) -> int: + return self._subtype + + @property + def data(self) -> bytes: + return self._data + + def to_map_value(self) -> Dict[str, Dict[str, Any]]: + """Returns legacy map dictionary representation for wire serialization.""" + return {"__binary__": {"sub_type": self._subtype, "bytes": self._data}} + + def __repr__(self) -> str: + return f"BSONBinary(data={self._data!r}, subtype={self._subtype})" + + def __eq__(self, other: Any) -> bool: + if isinstance(other, BSONBinary): + return (self._data, self._subtype) == (other._data, other._subtype) + return False + + def __hash__(self) -> int: + return hash((self._data, self._subtype)) + + +class BSONInt32(BSONType): + """Represents a signed 32-bit integer BSON value.""" + + __slots__ = ("_value",) + + def __init__(self, value: int): + if not _is_int(value): + raise TypeError("BSONInt32 value must be an integer.") + if not (-2147483648 <= value <= 2147483647): + raise ValueError("BSONInt32 out of range [-2147483648, 2147483647].") + self._value: int = int(value) + + @property + def value(self) -> int: + return self._value + + def __int__(self) -> int: + return self._value + + def to_map_value(self) -> Dict[str, int]: + """Returns legacy map dictionary representation for wire serialization.""" + return {"__int__": self._value} + + def __repr__(self) -> str: + return f"BSONInt32({self._value})" + + def __eq__(self, other: Any) -> bool: + if isinstance(other, BSONInt32): + return self._value == other._value + if type(other) is int and not isinstance(other, bool): + return self._value == other + return False + + def __hash__(self) -> int: + return hash(self._value) + + +class BSONMinKey(BSONType): + """Represents a BSON MinKey sentinel.""" + + __slots__ = () + + def to_map_value(self) -> Dict[str, int]: + """Returns legacy map dictionary representation for wire serialization.""" + return {"__minkey__": 1} + + def __repr__(self) -> str: + return "BSONMinKey()" + + def __eq__(self, other: Any) -> bool: + return isinstance(other, BSONMinKey) + + def __hash__(self) -> int: + return hash("BSONMinKey") + + +class BSONMaxKey(BSONType): + """Represents a BSON MaxKey sentinel.""" + + __slots__ = () + + def to_map_value(self) -> Dict[str, int]: + """Returns legacy map dictionary representation for wire serialization.""" + return {"__maxkey__": 1} + + def __repr__(self) -> str: + return "BSONMaxKey()" + + def __eq__(self, other: Any) -> bool: + return isinstance(other, BSONMaxKey) + + def __hash__(self) -> int: + return hash("BSONMaxKey") diff --git a/packages/google-cloud-firestore/google/cloud/firestore_v1/transforms.py b/packages/google-cloud-firestore/google/cloud/firestore_v1/transforms.py index 5ec15b3dc2d3..5aa27d9d0a62 100644 --- a/packages/google-cloud-firestore/google/cloud/firestore_v1/transforms.py +++ b/packages/google-cloud-firestore/google/cloud/firestore_v1/transforms.py @@ -106,6 +106,10 @@ class _NumericValue(object): """ def __init__(self, value) -> None: + if hasattr(value, "to_map_value") or type(value).__name__.startswith("BSON"): + raise TypeError( + "Numeric transforms (Increment, Maximum, Minimum) do not support BSON types." + ) if not isinstance(value, (int, float)): raise ValueError("Pass an integer / float value.") diff --git a/packages/google-cloud-firestore/tests/system/test_bson.py b/packages/google-cloud-firestore/tests/system/test_bson.py new file mode 100644 index 000000000000..b74a87bb3e02 --- /dev/null +++ b/packages/google-cloud-firestore/tests/system/test_bson.py @@ -0,0 +1,68 @@ +# -*- 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. + +import pytest +from test__helpers import FIRESTORE_ENTERPRISE_DB, UNIQUE_RESOURCE_ID + +from google.cloud.firestore import ( + ArrayUnion, + BSONBinary, + BSONDecimal128, + BSONInt32, + BSONMaxKey, + BSONMinKey, + BSONObjectID, + BSONRegex, + BSONTimestamp, + Increment, +) + + +def test_bson_system_transforms_rejection(): + # ArrayUnion preserves BSON wrappers + union = ArrayUnion([BSONInt32(5)]) + assert union.values[0] == BSONInt32(5) + + # Increment explicitly rejects BSONInt32 with TypeError + with pytest.raises(TypeError): + Increment(BSONInt32(1)) + + # Increment explicitly rejects BSONDecimal128 with TypeError + with pytest.raises(TypeError): + Increment(BSONDecimal128("1.0")) + + +@pytest.mark.parametrize("database", [FIRESTORE_ENTERPRISE_DB], indirect=True) +def test_bson_document_writes(client, cleanup, database): + """Test standard write operations for BSON types on Enterprise DB.""" + collection_id = "bson_docs_write_" + 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 diff --git a/packages/google-cloud-firestore/tests/unit/v1/test_bson.py b/packages/google-cloud-firestore/tests/unit/v1/test_bson.py new file mode 100644 index 000000000000..589f30f4c34c --- /dev/null +++ b/packages/google-cloud-firestore/tests/unit/v1/test_bson.py @@ -0,0 +1,253 @@ +# -*- 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. +# +"""Unit tests for google.cloud.firestore_v1.bson classes.""" + +import copy +import decimal +import pickle + +import pytest + +from google.cloud.firestore_v1.bson import ( + BSONBinary, + BSONDecimal128, + BSONInt32, + BSONMaxKey, + BSONMinKey, + BSONObjectID, + BSONRegex, + BSONTimestamp, + BSONType, +) + + +def test_bson_object_id_constructors_and_properties(): + # 24-char hex string + hex_str = "507f191e810c19729de860ea" + oid1 = BSONObjectID(hex_str) + assert oid1.value == hex_str + assert oid1.to_map_value() == {"__oid__": hex_str} + + # 12 raw bytes + raw_bytes = b"\x50\x7f\x19\x1e\x81\x0c\x19\x72\x9d\xe8\x60\xea" + oid2 = BSONObjectID(raw_bytes) + assert oid2.value == hex_str + + # Invalid bytes length raises ValueError + with pytest.raises(ValueError, match="must be 12 raw bytes"): + BSONObjectID(b"invalid_bytes_len") + + +def test_bson_constructors_validation(): + # bytearray is rejected (requires str or bytes) + raw_bytes = bytes.fromhex("507f1f77bcf86cd799439011") + with pytest.raises(TypeError, match="requires str or bytes"): + BSONObjectID(bytearray(raw_bytes)) + + # Invalid constructors + with pytest.raises(ValueError): + BSONObjectID("invalid_hex") + + with pytest.raises(ValueError): + BSONObjectID(b"short") + + with pytest.raises(TypeError): + BSONObjectID(12345) + + +def test_bson_object_id_equality_hash_pickle(): + oid1 = BSONObjectID("507f191e810c19729de860ea") + oid2 = BSONObjectID("507f191e810c19729de860ea") + oid3 = BSONObjectID("000000000000000000000000") + + assert oid1 == oid2 + assert oid1 != oid3 + assert hash(oid1) == hash(oid2) + + # Pickle + pickled = pickle.dumps(oid1) + unpickled = pickle.loads(pickled) + assert unpickled == oid1 + assert unpickled.value == oid1.value + + +def test_bson_decimal128(): + dec1 = BSONDecimal128("0.10") + assert dec1.value == "0.10" + assert dec1.to_decimal() == decimal.Decimal("0.10") + assert dec1.to_map_value() == {"__decimal128__": "0.10"} + + dec_nan = BSONDecimal128("NaN") + assert dec_nan.to_decimal().is_nan() + + # Eager validation + with pytest.raises(ValueError): + BSONDecimal128("not_a_number") + + with pytest.raises(TypeError): + BSONDecimal128(True) + + # Decimal vs Decimal128 equality and hash parity + py_dec = decimal.Decimal("0.10") + assert dec1 == py_dec + assert py_dec == dec1 + assert hash(dec1) == hash(py_dec) + assert dec1 in {py_dec} + + # Pickle + pickled = pickle.dumps(dec1) + unpickled = pickle.loads(pickled) + assert unpickled == dec1 + + +def test_bson_timestamp(): + ts1 = BSONTimestamp(1600000000, 1) + assert ts1.seconds == 1600000000 + assert ts1.increment == 1 + assert ts1.to_map_value() == { + "__timestamp__": {"seconds": 1600000000, "increment": 1} + } + + # Bounds validation + with pytest.raises(ValueError): + BSONTimestamp(4294967296, 1) + + with pytest.raises(TypeError): + BSONTimestamp(100.5, 1) + + # Pickle + pickled = pickle.dumps(ts1) + unpickled = pickle.loads(pickled) + assert unpickled == ts1 + + +def test_bson_regex(): + reg = BSONRegex("abc", "ixm") + assert reg.pattern == "abc" + assert reg.options == "imx" + assert reg.to_map_value() == {"__regex__": {"pattern": "abc", "options": "imx"}} + + # Pickle + pickled = pickle.dumps(reg) + unpickled = pickle.loads(pickled) + assert unpickled == reg + + +def test_bson_binary(): + bin1 = BSONBinary(b"data", 0) + assert bin1.data == b"data" + assert bin1.subtype == 0 + assert bin1.to_map_value() == {"__binary__": {"sub_type": 0, "bytes": b"data"}} + + # Default subtype=0 + bin_default = BSONBinary(b"data") + assert bin_default.subtype == 0 + + # Pickle + pickled = pickle.dumps(bin1) + unpickled = pickle.loads(pickled) + assert unpickled == bin1 + assert unpickled.data == b"data" + assert unpickled.subtype == 0 + + +def test_bson_int32(): + val = BSONInt32(100) + assert isinstance(val, BSONInt32) + assert not isinstance(val, int) + assert val.value == 100 + assert val == 100 + assert val.to_map_value() == {"__int__": 100} + + # Bounds validation + with pytest.raises(ValueError): + BSONInt32(2147483648) + + with pytest.raises(ValueError): + BSONInt32(-2147483649) + + with pytest.raises(TypeError): + BSONInt32(True) + + # Direct addition raises TypeError for standalone wrapper + with pytest.raises(TypeError): + val + 50 + + # Pickle + pickled = pickle.dumps(val) + unpickled = pickle.loads(pickled) + assert unpickled == val + assert isinstance(unpickled, BSONInt32) + assert unpickled.value == 100 + + +def test_bson_min_key_and_max_key(): + min1 = BSONMinKey() + min2 = BSONMinKey() + assert min1 == min2 + assert hash(min1) == hash(min2) + assert min1.to_map_value() == {"__minkey__": 1} + + max1 = BSONMaxKey() + max2 = BSONMaxKey() + assert max1 == max2 + assert hash(max1) == hash(max2) + assert max1.to_map_value() == {"__maxkey__": 1} + + # Copy / Deepcopy + assert copy.copy(min1) == min1 + assert copy.deepcopy(max1) == max1 + + # Pickle + assert pickle.loads(pickle.dumps(min1)) == min1 + assert pickle.loads(pickle.dumps(max1)) == max1 + + +def test_bson_int_enum_subclass_support(): + import enum + + class MySubtype(enum.IntEnum): + USER_DEFINED = 128 + + class MySec(enum.IntEnum): + VAL = 100 + + bin_enum = BSONBinary(b"data", MySubtype.USER_DEFINED) + assert bin_enum.subtype == 128 + + ts_enum = BSONTimestamp(MySec.VAL, MySec.VAL) + assert ts_enum.seconds == 100 + assert ts_enum.increment == 100 + + int_enum = BSONInt32(MySec.VAL) + assert int_enum.value == 100 + + +def test_bson_type_base_class_inheritance(): + instances = [ + BSONObjectID("507f191e810c19729de860ea"), + BSONDecimal128("123.45"), + BSONTimestamp(100, 1), + BSONRegex("abc", "i"), + BSONBinary(b"data", 0), + BSONInt32(42), + BSONMinKey(), + BSONMaxKey(), + ] + for obj in instances: + assert isinstance(obj, BSONType) + assert hasattr(obj, "to_map_value") + assert isinstance(obj.to_map_value(), dict)