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
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 @@ -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:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this intended to be a property? The name looks like it should be a method

"""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":

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what about float("inf")?

It looks like there are some other special case strings too (e,g, "-NaN"). Maybe we should have some tests around these

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

@daniel-sanche daniel-sanche Sep 18, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should probably implement __float__, so this type can be treated as a number

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))
3 changes: 3 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 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 Down
97 changes: 97 additions & 0 deletions packages/google-cloud-firestore/tests/unit/v1/test_bson.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,15 @@
"""Unit tests for google.cloud.firestore_v1.bson classes."""

import copy
import decimal
import pickle
import re

import pytest

from google.cloud.firestore_v1.bson import (
BSONBinary,
BSONDecimal128,
BSONInt32,
BSONMaxKey,
BSONMinKey,
Expand Down Expand Up @@ -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
Comment thread
ohmayr marked this conversation as resolved.


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
Comment thread
ohmayr marked this conversation as resolved.


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
Loading