Skip to content
Open
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 @@ -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,
Expand Down Expand Up @@ -180,6 +181,7 @@ replacements:
"AsyncTransaction",
"AsyncWriteBatch",
"BSONBinary",
"BSONDecimal128",
"BSONInt32",
"BSONMaxKey",
"BSONMinKey",
Expand Down Expand Up @@ -259,6 +261,7 @@ replacements:
AsyncTransaction,
AsyncWriteBatch,
BSONBinary,
BSONDecimal128,
BSONInt32,
BSONMaxKey,
BSONMinKey,
Expand Down Expand Up @@ -323,6 +326,7 @@ replacements:
"AsyncTransaction",
"AsyncWriteBatch",
"BSONBinary",
"BSONDecimal128",
"BSONInt32",
"BSONMaxKey",
"BSONMinKey",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
AsyncTransaction,
AsyncWriteBatch,
BSONBinary,
BSONDecimal128,
BSONInt32,
BSONMaxKey,
BSONMinKey,
Expand Down Expand Up @@ -100,6 +101,7 @@
"AsyncTransaction",
"AsyncWriteBatch",
"BSONBinary",
"BSONDecimal128",
"BSONInt32",
"BSONMaxKey",
"BSONMinKey",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
from google.cloud.firestore_v1.batch import WriteBatch
from google.cloud.firestore_v1.bson import (
BSONBinary,
BSONDecimal128,
BSONInt32,
BSONMaxKey,
BSONMinKey,
Expand Down Expand Up @@ -157,6 +158,7 @@
"AsyncTransaction",
"AsyncWriteBatch",
"BSONBinary",
"BSONDecimal128",
"BSONInt32",
"BSONMaxKey",
"BSONMinKey",
Expand Down
102 changes: 102 additions & 0 deletions packages/google-cloud-firestore/google/cloud/firestore_v1/bson.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
"""

import abc
import decimal
import re
from typing import Any, Dict, Union

Expand All @@ -36,6 +37,7 @@
"BSONBinary",
"BSONTimestamp",
"BSONRegex",
"BSONDecimal128",
]

_OBJECT_ID_BYTES_LEN = 12
Expand Down Expand Up @@ -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:
Comment thread
ohmayr marked this conversation as resolved.
"""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

Comment thread
ohmayr marked this conversation as resolved.
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))
29 changes: 29 additions & 0 deletions packages/google-cloud-firestore/tests/system/test_system.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand All @@ -1322,6 +1324,7 @@ def test_bson_document_writes(client, cleanup, database):
"options": "i",
}
},
"decimal128_val": {"__decimal128__": "123.45"},
}


Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand All @@ -1295,6 +1297,7 @@ async def test_async_bson_document_writes(client, cleanup, database):
"options": "i",
}
},
"decimal128_val": {"__decimal128__": "123.45"},
}


Expand All @@ -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
Expand Down
Loading
Loading