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..13bdcac7ffa1 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,103 @@ 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, decimal.Decimal, BSONDecimal128]): + The decimal value as a string, integer, decimal.Decimal, + or BSONDecimal128 instance. + + Raises: + TypeError: If value is a boolean or an 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, decimal.Decimal, "BSONDecimal128"], + ): + if isinstance(value, BSONDecimal128): + self._value: str = value._value + 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) + elif isinstance(value, float): + raise TypeError( + "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: + """str: The string representation of the 128-bit decimal value.""" + return self._value + + 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 __float__(self) -> float: + """float: Convert decimal value to float.""" + 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.""" + return int(self.to_decimal()) + + 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 + return NotImplemented + + def __hash__(self) -> int: + try: + 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/system/test_system.py b/packages/google-cloud-firestore/tests/system/test_system.py index ddc8c8a58529..3d95991aeb86 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"}, } @@ -1339,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 cca12f5eca61..824bed1b597b 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"}, } @@ -1313,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 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..a07a9efa2e47 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,15 @@ """Unit tests for google.cloud.firestore_v1.bson classes.""" import copy +import decimal +import math import pickle import pytest from google.cloud.firestore_v1.bson import ( BSONBinary, + BSONDecimal128, BSONInt32, BSONMaxKey, BSONMinKey, @@ -480,3 +483,121 @@ 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(decimal.Decimal("99.99")) + assert dec3.value == "99.99" + + dec4 = BSONDecimal128(dec1) + assert dec4.value == "123.45" + + +def test_bson_decimal128_float_and_int(): + dec = BSONDecimal128("123.45") + assert float(dec) == 123.45 + assert int(dec) == 123 + + +@pytest.mark.parametrize( + "special_val", + [ + "inf", + "-inf", + "Infinity", + "-Infinity", + "NaN", + "-NaN", + "sNaN", + "-sNaN", + ], +) +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( + "val_input, exc_type, match_msg", + [ + (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): + 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 != "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") + + d_trail = BSONDecimal128("1.0") + d_int = BSONDecimal128("1") + 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") + d3 = BSONDecimal128("678.90") + + # Hash invariant test: if a == b, then hash(a) == hash(b) + assert hash(d1) == hash(d2) + assert len({d1, d2}) == 1 + assert len({d1, d3}) == 2 + + 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