From 8baccaa646b8110484889ac3a65cf4bb926ce1f0 Mon Sep 17 00:00:00 2001 From: ohmayr Date: Wed, 16 Sep 2026 08:49:01 +0000 Subject: [PATCH] feat(firestore): add BSONDecimal128 support --- .../firestore-integration.yaml | 4 + .../google/cloud/firestore/__init__.py | 2 + .../google/cloud/firestore_v1/__init__.py | 2 + .../google/cloud/firestore_v1/bson.py | 83 ++++++++++++++++ .../tests/system/test_system.py | 3 + .../tests/system/test_system_async.py | 3 + .../tests/unit/v1/test_bson.py | 97 +++++++++++++++++++ 7 files changed, 194 insertions(+) diff --git a/.librarian/generator-input/client-post-processing/firestore-integration.yaml b/.librarian/generator-input/client-post-processing/firestore-integration.yaml index 49fd20d5f427..f43581fb6130 100644 --- a/.librarian/generator-input/client-post-processing/firestore-integration.yaml +++ b/.librarian/generator-input/client-post-processing/firestore-integration.yaml @@ -71,6 +71,7 @@ replacements: from google.cloud.firestore_v1.batch import WriteBatch from google.cloud.firestore_v1.bson import ( BSONBinary, + BSONDecimal128, BSONInt32, BSONMaxKey, BSONMinKey, @@ -180,6 +181,7 @@ replacements: "AsyncTransaction", "AsyncWriteBatch", "BSONBinary", + "BSONDecimal128", "BSONInt32", "BSONMaxKey", "BSONMinKey", @@ -259,6 +261,7 @@ replacements: AsyncTransaction, AsyncWriteBatch, BSONBinary, + BSONDecimal128, BSONInt32, BSONMaxKey, BSONMinKey, @@ -323,6 +326,7 @@ replacements: "AsyncTransaction", "AsyncWriteBatch", "BSONBinary", + "BSONDecimal128", "BSONInt32", "BSONMaxKey", "BSONMinKey", diff --git a/packages/google-cloud-firestore/google/cloud/firestore/__init__.py b/packages/google-cloud-firestore/google/cloud/firestore/__init__.py index eaa2daacd0f1..f14aa807d509 100644 --- a/packages/google-cloud-firestore/google/cloud/firestore/__init__.py +++ b/packages/google-cloud-firestore/google/cloud/firestore/__init__.py @@ -36,6 +36,7 @@ AsyncTransaction, AsyncWriteBatch, BSONBinary, + BSONDecimal128, BSONInt32, BSONMaxKey, BSONMinKey, @@ -100,6 +101,7 @@ "AsyncTransaction", "AsyncWriteBatch", "BSONBinary", + "BSONDecimal128", "BSONInt32", "BSONMaxKey", "BSONMinKey", 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 e7445eeedf3a..f8a91acf9124 100644 --- a/packages/google-cloud-firestore/google/cloud/firestore_v1/__init__.py +++ b/packages/google-cloud-firestore/google/cloud/firestore_v1/__init__.py @@ -48,6 +48,7 @@ from google.cloud.firestore_v1.batch import WriteBatch from google.cloud.firestore_v1.bson import ( BSONBinary, + BSONDecimal128, BSONInt32, BSONMaxKey, BSONMinKey, @@ -157,6 +158,7 @@ "AsyncTransaction", "AsyncWriteBatch", "BSONBinary", + "BSONDecimal128", "BSONInt32", "BSONMaxKey", "BSONMinKey", diff --git a/packages/google-cloud-firestore/google/cloud/firestore_v1/bson.py b/packages/google-cloud-firestore/google/cloud/firestore_v1/bson.py index 40b7ef346566..40e1d77f09fb 100644 --- a/packages/google-cloud-firestore/google/cloud/firestore_v1/bson.py +++ b/packages/google-cloud-firestore/google/cloud/firestore_v1/bson.py @@ -25,6 +25,7 @@ """ import abc +import decimal import re from typing import Any, Dict, Union @@ -36,6 +37,7 @@ "BSONBinary", "BSONTimestamp", "BSONRegex", + "BSONDecimal128", ] _OBJECT_ID_BYTES_LEN = 12 @@ -425,3 +427,84 @@ def __eq__(self, other: Any) -> bool: def __hash__(self) -> int: return hash((type(self), self._pattern, self._options)) + + +class BSONDecimal128(_BSONType): + """Represents a BSON 128-bit Decimal container for Firestore. + + Args: + value (Union[str, int, float, decimal.Decimal, BSONDecimal128]): + The decimal value as a string, integer, float, decimal.Decimal, + or BSONDecimal128 instance. + + Raises: + TypeError: If value is a boolean or unsupported type. + ValueError: If value cannot be parsed as a valid decimal number. + + Example: + >>> dec = BSONDecimal128("123.45") + >>> dec.value + '123.45' + >>> dec.to_decimal + Decimal('123.45') + """ + + __slots__ = ("_value",) + + def __init__( + self, + value: Union[str, int, float, decimal.Decimal, "BSONDecimal128"], + ): + if isinstance(value, BSONDecimal128): + self._value: str = value._value + elif isinstance(value, (str, int, float, decimal.Decimal)) and not isinstance( + value, bool + ): + self._value = str(value) + else: + raise TypeError( + "BSONDecimal128 value must be a Decimal, str, int, or float." + ) + + @property + def value(self) -> str: + """str: The string representation of the 128-bit decimal value.""" + return self._value + + @property + def to_decimal(self) -> decimal.Decimal: + """decimal.Decimal: Convert to Python standard library Decimal instance.""" + return decimal.Decimal(self._value) + + def _to_map_value(self) -> Dict[str, str]: + """Returns map dictionary representation for wire serialization.""" + return {"__decimal128__": self._value} + + def __repr__(self) -> str: + return f"BSONDecimal128({self._value!r})" + + def __str__(self) -> str: + return self._value + + def __eq__(self, other: Any) -> bool: + if isinstance(other, BSONDecimal128): + if self._value.upper() == "NAN" and other._value.upper() == "NAN": + return True + try: + return self.to_decimal == other.to_decimal + except decimal.InvalidOperation: + return self._value == other._value + if isinstance(other, decimal.Decimal): + try: + return self.to_decimal == other + except decimal.InvalidOperation: + return False + return NotImplemented + + def __hash__(self) -> int: + if self._value.upper() == "NAN": + return hash((type(self), "NAN")) + try: + return hash(self.to_decimal) + except decimal.InvalidOperation: + return hash((type(self), self._value)) diff --git a/packages/google-cloud-firestore/tests/system/test_system.py b/packages/google-cloud-firestore/tests/system/test_system.py index bee648ace9b3..85a1b3546ba8 100644 --- a/packages/google-cloud-firestore/tests/system/test_system.py +++ b/packages/google-cloud-firestore/tests/system/test_system.py @@ -50,6 +50,7 @@ from google.cloud.firestore_v1.base_vector_query import DistanceMeasure from google.cloud.firestore_v1.bson import ( BSONBinary, + BSONDecimal128, BSONInt32, BSONMaxKey, BSONMinKey, @@ -1298,6 +1299,7 @@ def test_bson_document_writes(client, cleanup, database): "binary_val_sub128": BSONBinary(b"world", subtype=128), "timestamp_val": BSONTimestamp(1700000000, 1), "regex_val": BSONRegex("^hello.*$", options="i"), + "decimal128_val": BSONDecimal128("123.45"), } doc_ref.set(bson_payload) @@ -1322,6 +1324,7 @@ def test_bson_document_writes(client, cleanup, database): "options": "i", } }, + "decimal128_val": {"__decimal128__": "123.45"}, } diff --git a/packages/google-cloud-firestore/tests/system/test_system_async.py b/packages/google-cloud-firestore/tests/system/test_system_async.py index 479fa66ee860..fac9aef81fe4 100644 --- a/packages/google-cloud-firestore/tests/system/test_system_async.py +++ b/packages/google-cloud-firestore/tests/system/test_system_async.py @@ -53,6 +53,7 @@ from google.cloud.firestore_v1.base_vector_query import DistanceMeasure from google.cloud.firestore_v1.bson import ( BSONBinary, + BSONDecimal128, BSONInt32, BSONMaxKey, BSONMinKey, @@ -1271,6 +1272,7 @@ async def test_async_bson_document_writes(client, cleanup, database): "binary_val_sub128": BSONBinary(b"world", subtype=128), "timestamp_val": BSONTimestamp(1700000000, 1), "regex_val": BSONRegex("^hello.*$", options="i"), + "decimal128_val": BSONDecimal128("123.45"), } await doc_ref.set(bson_payload) @@ -1295,6 +1297,7 @@ async def test_async_bson_document_writes(client, cleanup, database): "options": "i", } }, + "decimal128_val": {"__decimal128__": "123.45"}, } diff --git a/packages/google-cloud-firestore/tests/unit/v1/test_bson.py b/packages/google-cloud-firestore/tests/unit/v1/test_bson.py index b5be269f0061..e1a93427be9d 100644 --- a/packages/google-cloud-firestore/tests/unit/v1/test_bson.py +++ b/packages/google-cloud-firestore/tests/unit/v1/test_bson.py @@ -16,6 +16,7 @@ """Unit tests for google.cloud.firestore_v1.bson classes.""" import copy +import decimal import pickle import re @@ -23,6 +24,7 @@ from google.cloud.firestore_v1.bson import ( BSONBinary, + BSONDecimal128, BSONInt32, BSONMaxKey, BSONMinKey, @@ -482,3 +484,98 @@ def test_bson_regex_copy(): def test_bson_regex_pickle(): rx = BSONRegex("^abc", options="i") assert pickle.loads(pickle.dumps(rx)) == rx + + +def test_bson_decimal128_valid(): + dec1 = BSONDecimal128("123.45") + assert dec1.value == "123.45" + assert dec1.to_decimal == decimal.Decimal("123.45") + assert dec1._to_map_value() == {"__decimal128__": "123.45"} + assert repr(dec1) == "BSONDecimal128('123.45')" + assert str(dec1) == "123.45" + + dec2 = BSONDecimal128(42) + assert dec2.value == "42" + + dec3 = BSONDecimal128(1.5) + assert dec3.value == "1.5" + + dec4 = BSONDecimal128(decimal.Decimal("99.99")) + assert dec4.value == "99.99" + + dec5 = BSONDecimal128(dec1) + assert dec5.value == "123.45" + + +def test_bson_decimal128_special_values(): + nan_dec = BSONDecimal128("NaN") + assert nan_dec.value == "NaN" + assert nan_dec._to_map_value() == {"__decimal128__": "NaN"} + + inf_dec = BSONDecimal128("Infinity") + assert inf_dec.value == "Infinity" + + neg_inf_dec = BSONDecimal128("-Infinity") + assert neg_inf_dec.value == "-Infinity" + + +@pytest.mark.parametrize( + "val_input, exc_type, match_msg", + [ + (True, TypeError, "value must be a Decimal, str, int, or float"), + (False, TypeError, "value must be a Decimal, str, int, or float"), + ([1, 2], TypeError, "value must be a Decimal, str, int, or float"), + ], +) +def test_bson_decimal128_invalid_inputs(val_input, exc_type, match_msg): + with pytest.raises(exc_type, match=match_msg): + BSONDecimal128(val_input) + + +def test_bson_decimal128_equality(): + d1 = BSONDecimal128("123.45") + d2 = BSONDecimal128("123.45") + d3 = BSONDecimal128("678.90") + assert d1 == d2 + assert d1 != d3 + assert d1 == decimal.Decimal("123.45") + assert d1 != "123.45" + + # Transitivity test: BSONDecimal128("1.0") == Decimal("1") == BSONDecimal128("1") + d_trail = BSONDecimal128("1.0") + d_int = BSONDecimal128("1") + dec_int = decimal.Decimal("1") + assert d_trail == dec_int + assert d_int == dec_int + assert d_trail == d_int # Transitivity enforced! + + nan1 = BSONDecimal128("NaN") + nan2 = BSONDecimal128("NaN") + assert nan1 == nan2 + + +def test_bson_decimal128_hash_and_dict_key(): + d1 = BSONDecimal128("123.45") + d2 = BSONDecimal128("123.45") + dec_val = decimal.Decimal("123.45") + + # Hash invariant test: if a == b, then hash(a) == hash(b) + assert hash(d1) == hash(d2) + assert hash(d1) == hash(dec_val) + assert len({d1, d2, dec_val}) == 1 + + nan1 = BSONDecimal128("NaN") + nan2 = BSONDecimal128("NaN") + assert hash(nan1) == hash(nan2) + assert len({nan1, nan2}) == 1 + + +def test_bson_decimal128_copy(): + d = BSONDecimal128("123.45") + assert copy.copy(d) == d + assert copy.deepcopy(d) == d + + +def test_bson_decimal128_pickle(): + d = BSONDecimal128("123.45") + assert pickle.loads(pickle.dumps(d)) == d