From de9b107cda6dbbd12cb16cb68577f6690a73eaf2 Mon Sep 17 00:00:00 2001 From: ohmayr Date: Wed, 16 Sep 2026 08:49:01 +0000 Subject: [PATCH 1/6] 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 0c4b8dd00b9d..7b3ef468f9d2 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 @@ -405,3 +407,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 ddc8c8a58529..6608e077245a 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 cca12f5eca61..8adcc3b026ed 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 79654e1669cb..cc619ebec642 100644 --- a/packages/google-cloud-firestore/tests/unit/v1/test_bson.py +++ b/packages/google-cloud-firestore/tests/unit/v1/test_bson.py @@ -16,12 +16,14 @@ """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, @@ -480,3 +482,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 From f2c3ada58bd57c2002425d3a23024b7157c71a83 Mon Sep 17 00:00:00 2001 From: ohmayr Date: Mon, 21 Sep 2026 08:25:37 +0000 Subject: [PATCH 2/6] refactor(firestore): address review feedback for BSONDecimal128 - Convert to_decimal from property to method call to_decimal(). - Add __float__ and __int__ numeric protocol methods to BSONDecimal128. - Improve __eq__ and __hash__ for IEEE 754 special values (NaN, -NaN, sNaN, inf, -inf) using Decimal.is_nan(). - Add comprehensive test cases in test_bson.py for float/int conversions and special decimal values. --- .../google/cloud/firestore_v1/bson.py | 30 +++++++++---- .../tests/unit/v1/test_bson.py | 43 +++++++++++++++---- 2 files changed, 55 insertions(+), 18 deletions(-) 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 7b3ef468f9d2..7bc3786949c8 100644 --- a/packages/google-cloud-firestore/google/cloud/firestore_v1/bson.py +++ b/packages/google-cloud-firestore/google/cloud/firestore_v1/bson.py @@ -425,7 +425,7 @@ class BSONDecimal128(_BSONType): >>> dec = BSONDecimal128("123.45") >>> dec.value '123.45' - >>> dec.to_decimal + >>> dec.to_decimal() Decimal('123.45') """ @@ -451,7 +451,6 @@ 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) @@ -466,25 +465,38 @@ def __repr__(self) -> str: def __str__(self) -> str: return self._value + def __float__(self) -> float: + """float: Convert decimal value to float.""" + return float(self._value) + + def __int__(self) -> int: + """int: Convert decimal value to integer.""" + return int(self.to_decimal()) + 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 + d1, d2 = self.to_decimal(), other.to_decimal() + if d1.is_nan() and d2.is_nan(): + return True + return d1 == d2 except decimal.InvalidOperation: return self._value == other._value if isinstance(other, decimal.Decimal): try: - return self.to_decimal == other + d1 = self.to_decimal() + if d1.is_nan() and other.is_nan(): + return True + return d1 == 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) + d = self.to_decimal() + if d.is_nan(): + return hash((type(self), "NAN")) + return hash(d) except decimal.InvalidOperation: return hash((type(self), self._value)) 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 cc619ebec642..8cd0401afc07 100644 --- a/packages/google-cloud-firestore/tests/unit/v1/test_bson.py +++ b/packages/google-cloud-firestore/tests/unit/v1/test_bson.py @@ -17,6 +17,7 @@ import copy import decimal +import math import pickle import pytest @@ -487,7 +488,7 @@ def test_bson_regex_pickle(): 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_decimal() == decimal.Decimal("123.45") assert dec1._to_map_value() == {"__decimal128__": "123.45"} assert repr(dec1) == "BSONDecimal128('123.45')" assert str(dec1) == "123.45" @@ -505,16 +506,40 @@ def test_bson_decimal128_valid(): 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"} +def test_bson_decimal128_float_and_int(): + dec = BSONDecimal128("123.45") + assert float(dec) == 123.45 + assert int(dec) == 123 - inf_dec = BSONDecimal128("Infinity") - assert inf_dec.value == "Infinity" - neg_inf_dec = BSONDecimal128("-Infinity") - assert neg_inf_dec.value == "-Infinity" +@pytest.mark.parametrize( + "special_val", + [ + "inf", + "-inf", + "Infinity", + "-Infinity", + "NaN", + "-NaN", + "sNaN", + "-sNaN", + float("inf"), + float("-inf"), + float("nan"), + ], +) +def test_bson_decimal128_special_values(special_val): + dec1 = BSONDecimal128(special_val) + dec2 = BSONDecimal128(special_val) + assert dec1.value == str(special_val) + assert dec1._to_map_value() == {"__decimal128__": str(special_val)} + assert dec1 == dec2 + assert hash(dec1) == hash(dec2) + + if "inf" in str(special_val).lower(): + assert math.isinf(float(dec1)) + elif "nan" in str(special_val).lower(): + assert math.isnan(float(dec1)) @pytest.mark.parametrize( From e5a756085deae8246f565a67658ff3395bdb8ccb Mon Sep 17 00:00:00 2001 From: ohmayr Date: Mon, 21 Sep 2026 08:31:22 +0000 Subject: [PATCH 3/6] fix(firestore): handle signaling NaN strings in BSONDecimal128.__float__ - Safely convert sNaN and -sNaN to float("nan") / float("-nan") in __float__ without raising ValueError. Towards #18395 --- .../google-cloud-firestore/google/cloud/firestore_v1/bson.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 7bc3786949c8..18e54dadef45 100644 --- a/packages/google-cloud-firestore/google/cloud/firestore_v1/bson.py +++ b/packages/google-cloud-firestore/google/cloud/firestore_v1/bson.py @@ -467,7 +467,10 @@ def __str__(self) -> str: def __float__(self) -> float: """float: Convert decimal value to float.""" - return float(self._value) + d = self.to_decimal() + if d.is_nan(): + return float("-nan") if d.is_signed() else float("nan") + return float(d) def __int__(self) -> int: """int: Convert decimal value to integer.""" From d9112fd35336abf540ae5fe9d4b56124afe31d0c Mon Sep 17 00:00:00 2001 From: ohmayr Date: Wed, 23 Sep 2026 18:26:55 +0000 Subject: [PATCH 4/6] fix(firestore): disallow float in BSONDecimal128 and validate input strings Disallow passing float to BSONDecimal128 to prevent binary floating-point precision loss and edge cases, aligning with PyMongo's Decimal128 behavior. Validate string and numeric inputs via decimal.Decimal in __init__ to reject invalid numeric strings. --- .../google/cloud/firestore_v1/bson.py | 22 ++++++++++------ .../tests/unit/v1/test_bson.py | 25 +++++++++---------- 2 files changed, 27 insertions(+), 20 deletions(-) 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 18e54dadef45..5a6b602dca3d 100644 --- a/packages/google-cloud-firestore/google/cloud/firestore_v1/bson.py +++ b/packages/google-cloud-firestore/google/cloud/firestore_v1/bson.py @@ -413,12 +413,12 @@ 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, + value (Union[str, int, decimal.Decimal, BSONDecimal128]): + The decimal value as a string, integer, decimal.Decimal, or BSONDecimal128 instance. Raises: - TypeError: If value is a boolean or unsupported type. + TypeError: If value is a boolean or an unsupported type. ValueError: If value cannot be parsed as a valid decimal number. Example: @@ -433,18 +433,26 @@ class BSONDecimal128(_BSONType): def __init__( self, - value: Union[str, int, float, decimal.Decimal, "BSONDecimal128"], + value: Union[str, int, decimal.Decimal, "BSONDecimal128"], ): if isinstance(value, BSONDecimal128): self._value: str = value._value - elif isinstance(value, (str, int, float, decimal.Decimal)) and not isinstance( + elif isinstance(value, (str, int, decimal.Decimal)) and not isinstance( value, bool ): + try: + # Validate the value parses as a valid decimal number + decimal.Decimal(value) + except decimal.InvalidOperation as exc: + raise ValueError(f"Cannot convert {value!r} to Decimal: {exc}") from exc self._value = str(value) - else: + elif isinstance(value, float): raise TypeError( - "BSONDecimal128 value must be a Decimal, str, int, or float." + "BSONDecimal128 does not accept float values due to potential precision loss. " + "Convert the float to a str or decimal.Decimal first." ) + else: + raise TypeError("BSONDecimal128 value must be a Decimal, str, or int.") @property def value(self) -> str: 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 8cd0401afc07..92a3a1b128e3 100644 --- a/packages/google-cloud-firestore/tests/unit/v1/test_bson.py +++ b/packages/google-cloud-firestore/tests/unit/v1/test_bson.py @@ -496,14 +496,11 @@ def test_bson_decimal128_valid(): dec2 = BSONDecimal128(42) assert dec2.value == "42" - dec3 = BSONDecimal128(1.5) - assert dec3.value == "1.5" + dec3 = BSONDecimal128(decimal.Decimal("99.99")) + assert dec3.value == "99.99" - dec4 = BSONDecimal128(decimal.Decimal("99.99")) - assert dec4.value == "99.99" - - dec5 = BSONDecimal128(dec1) - assert dec5.value == "123.45" + dec4 = BSONDecimal128(dec1) + assert dec4.value == "123.45" def test_bson_decimal128_float_and_int(): @@ -523,9 +520,6 @@ def test_bson_decimal128_float_and_int(): "-NaN", "sNaN", "-sNaN", - float("inf"), - float("-inf"), - float("nan"), ], ) def test_bson_decimal128_special_values(special_val): @@ -545,9 +539,14 @@ def test_bson_decimal128_special_values(special_val): @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"), + (True, TypeError, "value must be a Decimal, str, or int"), + (False, TypeError, "value must be a Decimal, str, or int"), + ([1, 2], TypeError, "value must be a Decimal, str, or int"), + (1.5, TypeError, "does not accept float values"), + (float("inf"), TypeError, "does not accept float values"), + (float("nan"), TypeError, "does not accept float values"), + ("not-a-number", ValueError, "Cannot convert 'not-a-number' to Decimal"), + ("12.34.56", ValueError, "Cannot convert '12.34.56' to Decimal"), ], ) def test_bson_decimal128_invalid_inputs(val_input, exc_type, match_msg): From a81ee31bd8c91b65ce2f2abdec497dbf60e8e511 Mon Sep 17 00:00:00 2001 From: ohmayr Date: Thu, 24 Sep 2026 00:40:59 +0000 Subject: [PATCH 5/6] fix(firestore): align BSONDecimal128 equality with PyMongo container parity Restrict BSONDecimal128.__eq__ to other BSONDecimal128 instances, matching PyMongo's bson.decimal128.Decimal128 specification where instances compare equal based on their underlying BSON value (including NaN == NaN). Require calling .to_decimal() for comparisons against standard decimal.Decimal. --- .../google/cloud/firestore_v1/bson.py | 12 ++++-------- .../tests/unit/v1/test_bson.py | 18 +++++++++--------- 2 files changed, 13 insertions(+), 17 deletions(-) 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 5a6b602dca3d..13bdcac7ffa1 100644 --- a/packages/google-cloud-firestore/google/cloud/firestore_v1/bson.py +++ b/packages/google-cloud-firestore/google/cloud/firestore_v1/bson.py @@ -488,19 +488,15 @@ def __eq__(self, other: Any) -> bool: if isinstance(other, BSONDecimal128): try: d1, d2 = self.to_decimal(), other.to_decimal() + # Following PyMongo's bson.decimal128.Decimal128 specification, + # two Decimal128 instances compare equal if their underlying BSON + # encodings are identical (including NaN == NaN). This aligns with + # Firestore query and indexing semantics where NaN matches NaN. if d1.is_nan() and d2.is_nan(): return True return d1 == d2 except decimal.InvalidOperation: return self._value == other._value - if isinstance(other, decimal.Decimal): - try: - d1 = self.to_decimal() - if d1.is_nan() and other.is_nan(): - return True - return d1 == other - except decimal.InvalidOperation: - return False return NotImplemented def __hash__(self) -> int: 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 92a3a1b128e3..a07a9efa2e47 100644 --- a/packages/google-cloud-firestore/tests/unit/v1/test_bson.py +++ b/packages/google-cloud-firestore/tests/unit/v1/test_bson.py @@ -560,31 +560,31 @@ def test_bson_decimal128_equality(): d3 = BSONDecimal128("678.90") assert d1 == d2 assert d1 != d3 - assert d1 == decimal.Decimal("123.45") assert d1 != "123.45" + # Pure container parity with PyMongo: BSONDecimal128 does not equate to + # standard decimal.Decimal directly; developers use .to_decimal() for math/comparison. + assert d1 != decimal.Decimal("123.45") + assert d1.to_decimal() == decimal.Decimal("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! + assert d_trail == d_int nan1 = BSONDecimal128("NaN") nan2 = BSONDecimal128("NaN") + # Two BSONDecimal128 instances compare equal for NaN (PyMongo container parity) 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") + d3 = BSONDecimal128("678.90") # 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 + assert len({d1, d2}) == 1 + assert len({d1, d3}) == 2 nan1 = BSONDecimal128("NaN") nan2 = BSONDecimal128("NaN") From a26eb7c66761b9a61eb71fb5e2f3e6743ed87ea9 Mon Sep 17 00:00:00 2001 From: ohmayr Date: Thu, 24 Sep 2026 00:45:32 +0000 Subject: [PATCH 6/6] test(firestore): add system tests for BSONDecimal128 special values Add sync and async system tests validating that BSONDecimal128 special values (inf, -inf, NaN) are written to enterprise Firestore databases and normalized to Infinity, -Infinity, and NaN upon storage. --- .../tests/system/test_system.py | 26 ++++++++++++++++++ .../tests/system/test_system_async.py | 27 +++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/packages/google-cloud-firestore/tests/system/test_system.py b/packages/google-cloud-firestore/tests/system/test_system.py index 6608e077245a..3d95991aeb86 100644 --- a/packages/google-cloud-firestore/tests/system/test_system.py +++ b/packages/google-cloud-firestore/tests/system/test_system.py @@ -1342,6 +1342,32 @@ def test_bson_regex_invalid_options(client, cleanup, database): assert "Invalid regex option" in exc_info.value.message +@pytest.mark.parametrize("database", [FIRESTORE_ENTERPRISE_DB], indirect=True) +def test_bson_decimal128_special_values(client, cleanup, database): + """Test write and read operations for BSONDecimal128 special values against backend.""" + collection_id = "bson_decimal128_special_" + UNIQUE_RESOURCE_ID + doc_ref = client.collection(collection_id).document("special_decimals") + cleanup(doc_ref.delete) + + # Firestore backend accepts "inf", "-inf", and "NaN", automatically + # normalizing them to "Infinity", "-Infinity", and "NaN" upon storage. + doc_ref.set( + { + "inf_val": BSONDecimal128("inf"), + "neg_inf_val": BSONDecimal128("-inf"), + "nan_val": BSONDecimal128("NaN"), + } + ) + + snapshot = doc_ref.get() + assert snapshot.exists + assert snapshot.to_dict() == { + "inf_val": {"__decimal128__": "Infinity"}, + "neg_inf_val": {"__decimal128__": "-Infinity"}, + "nan_val": {"__decimal128__": "NaN"}, + } + + @pytest.fixture(scope="module") def query_docs(client, database): collection_id = "qs" + UNIQUE_RESOURCE_ID 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 8adcc3b026ed..824bed1b597b 100644 --- a/packages/google-cloud-firestore/tests/system/test_system_async.py +++ b/packages/google-cloud-firestore/tests/system/test_system_async.py @@ -1316,6 +1316,33 @@ async def test_async_bson_regex_invalid_options(client, cleanup, database): assert "Invalid regex option" in exc_info.value.message +@pytest.mark.asyncio +@pytest.mark.parametrize("database", [FIRESTORE_ENTERPRISE_DB], indirect=True) +async def test_async_bson_decimal128_special_values(client, cleanup, database): + """Test async write and read operations for BSONDecimal128 special values against backend.""" + collection_id = "async_bson_decimal128_special_" + UNIQUE_RESOURCE_ID + doc_ref = client.collection(collection_id).document("special_decimals") + cleanup(doc_ref.delete) + + # Firestore backend accepts "inf", "-inf", and "NaN", automatically + # normalizing them to "Infinity", "-Infinity", and "NaN" upon storage. + await doc_ref.set( + { + "inf_val": BSONDecimal128("inf"), + "neg_inf_val": BSONDecimal128("-inf"), + "nan_val": BSONDecimal128("NaN"), + } + ) + + snapshot = await doc_ref.get() + assert snapshot.exists + assert snapshot.to_dict() == { + "inf_val": {"__decimal128__": "Infinity"}, + "neg_inf_val": {"__decimal128__": "-Infinity"}, + "nan_val": {"__decimal128__": "NaN"}, + } + + @pytest_asyncio.fixture(scope="module") async def query_docs(client): collection_id = "qs" + UNIQUE_RESOURCE_ID