Skip to content

Commit 136d4cc

Browse files
committed
feat(firestore): add BSONDecimal128 support
1 parent ed7f12b commit 136d4cc

7 files changed

Lines changed: 190 additions & 0 deletions

File tree

.librarian/generator-input/client-post-processing/firestore-integration.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ replacements:
7171
from google.cloud.firestore_v1.batch import WriteBatch
7272
from google.cloud.firestore_v1.bson import (
7373
BSONBinary,
74+
BSONDecimal128,
7475
BSONInt32,
7576
BSONMaxKey,
7677
BSONMinKey,
@@ -180,6 +181,7 @@ replacements:
180181
"AsyncTransaction",
181182
"AsyncWriteBatch",
182183
"BSONBinary",
184+
"BSONDecimal128",
183185
"BSONInt32",
184186
"BSONMaxKey",
185187
"BSONMinKey",
@@ -259,6 +261,7 @@ replacements:
259261
AsyncTransaction,
260262
AsyncWriteBatch,
261263
BSONBinary,
264+
BSONDecimal128,
262265
BSONInt32,
263266
BSONMaxKey,
264267
BSONMinKey,
@@ -323,6 +326,7 @@ replacements:
323326
"AsyncTransaction",
324327
"AsyncWriteBatch",
325328
"BSONBinary",
329+
"BSONDecimal128",
326330
"BSONInt32",
327331
"BSONMaxKey",
328332
"BSONMinKey",

packages/google-cloud-firestore/google/cloud/firestore/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
AsyncTransaction,
3737
AsyncWriteBatch,
3838
BSONBinary,
39+
BSONDecimal128,
3940
BSONInt32,
4041
BSONMaxKey,
4142
BSONMinKey,
@@ -100,6 +101,7 @@
100101
"AsyncTransaction",
101102
"AsyncWriteBatch",
102103
"BSONBinary",
104+
"BSONDecimal128",
103105
"BSONInt32",
104106
"BSONMaxKey",
105107
"BSONMinKey",

packages/google-cloud-firestore/google/cloud/firestore_v1/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848
from google.cloud.firestore_v1.batch import WriteBatch
4949
from google.cloud.firestore_v1.bson import (
5050
BSONBinary,
51+
BSONDecimal128,
5152
BSONInt32,
5253
BSONMaxKey,
5354
BSONMinKey,
@@ -157,6 +158,7 @@
157158
"AsyncTransaction",
158159
"AsyncWriteBatch",
159160
"BSONBinary",
161+
"BSONDecimal128",
160162
"BSONInt32",
161163
"BSONMaxKey",
162164
"BSONMinKey",

packages/google-cloud-firestore/google/cloud/firestore_v1/bson.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
"""
2626

2727
import abc
28+
import decimal
2829
import re
2930
from typing import Any, Dict, Union
3031

@@ -36,6 +37,7 @@
3637
"BSONBinary",
3738
"BSONTimestamp",
3839
"BSONRegex",
40+
"BSONDecimal128",
3941
]
4042

4143
_OBJECT_ID_BYTES_LEN = 12
@@ -445,3 +447,91 @@ def __eq__(self, other: Any) -> bool:
445447

446448
def __hash__(self) -> int:
447449
return hash((type(self), self._pattern, self._options))
450+
451+
452+
class BSONDecimal128(_BSONType):
453+
"""Represents a BSON 128-bit Decimal container for Firestore.
454+
455+
Args:
456+
value (Union[str, int, float, decimal.Decimal, BSONDecimal128]):
457+
The decimal value as a string, integer, float, decimal.Decimal,
458+
or BSONDecimal128 instance.
459+
460+
Raises:
461+
TypeError: If value is a boolean or unsupported type.
462+
ValueError: If value cannot be parsed as a valid decimal number.
463+
464+
Example:
465+
>>> dec = BSONDecimal128("123.45")
466+
>>> dec.value
467+
'123.45'
468+
>>> dec.to_decimal
469+
Decimal('123.45')
470+
"""
471+
472+
__slots__ = ("_value",)
473+
474+
def __init__(
475+
self,
476+
value: Union[str, int, float, decimal.Decimal, "BSONDecimal128"],
477+
):
478+
if isinstance(value, bool):
479+
raise TypeError(
480+
"BSONDecimal128 value must be a Decimal, str, int, or float."
481+
)
482+
483+
if isinstance(value, BSONDecimal128):
484+
self._value: str = value._value
485+
return
486+
487+
if isinstance(value, decimal.Decimal):
488+
self._value = str(value)
489+
return
490+
491+
if isinstance(value, (str, int, float)):
492+
try:
493+
dec_val = decimal.Decimal(
494+
str(value) if isinstance(value, float) else value
495+
)
496+
self._value = str(dec_val)
497+
except (decimal.InvalidOperation, TypeError, ValueError) as exc:
498+
raise ValueError(f"Invalid BSONDecimal128 value: {value!r}.") from exc
499+
else:
500+
raise TypeError(
501+
"BSONDecimal128 value must be a Decimal, str, int, or float."
502+
)
503+
504+
@property
505+
def value(self) -> str:
506+
"""str: The string representation of the 128-bit decimal value."""
507+
return self._value
508+
509+
@property
510+
def to_decimal(self) -> decimal.Decimal:
511+
"""decimal.Decimal: Convert to Python standard library Decimal instance."""
512+
return decimal.Decimal(self._value)
513+
514+
def _to_map_value(self) -> Dict[str, str]:
515+
"""Returns map dictionary representation for wire serialization."""
516+
return {"__decimal128__": self._value}
517+
518+
def __repr__(self) -> str:
519+
return f"BSONDecimal128({self._value!r})"
520+
521+
def __str__(self) -> str:
522+
return self._value
523+
524+
def __eq__(self, other: Any) -> bool:
525+
if isinstance(other, BSONDecimal128):
526+
if self._value.upper() == "NAN" and other._value.upper() == "NAN":
527+
return True
528+
return self._value == other._value
529+
if isinstance(other, decimal.Decimal):
530+
return self.to_decimal == other
531+
return NotImplemented
532+
533+
def __hash__(self) -> int:
534+
normalized_str = (
535+
"NAN" if self._value.upper() == "NAN" else self._value
536+
)
537+
return hash((type(self), normalized_str))

packages/google-cloud-firestore/tests/system/test_system.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050
from google.cloud.firestore_v1.base_vector_query import DistanceMeasure
5151
from google.cloud.firestore_v1.bson import (
5252
BSONBinary,
53+
BSONDecimal128,
5354
BSONInt32,
5455
BSONMaxKey,
5556
BSONMinKey,
@@ -1298,6 +1299,7 @@ def test_bson_document_writes(client, cleanup, database):
12981299
"binary_val_sub128": BSONBinary(b"world", subtype=128),
12991300
"timestamp_val": BSONTimestamp(1700000000, 1),
13001301
"regex_val": BSONRegex("^hello.*$", options="i"),
1302+
"decimal128_val": BSONDecimal128("123.45"),
13011303
}
13021304

13031305
doc_ref.set(bson_payload)
@@ -1322,6 +1324,7 @@ def test_bson_document_writes(client, cleanup, database):
13221324
"options": "i",
13231325
}
13241326
},
1327+
"decimal128_val": {"__decimal128__": "123.45"},
13251328
}
13261329

13271330

packages/google-cloud-firestore/tests/system/test_system_async.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@
5353
from google.cloud.firestore_v1.base_vector_query import DistanceMeasure
5454
from google.cloud.firestore_v1.bson import (
5555
BSONBinary,
56+
BSONDecimal128,
5657
BSONInt32,
5758
BSONMaxKey,
5859
BSONMinKey,
@@ -1271,6 +1272,7 @@ async def test_async_bson_document_writes(client, cleanup, database):
12711272
"binary_val_sub128": BSONBinary(b"world", subtype=128),
12721273
"timestamp_val": BSONTimestamp(1700000000, 1),
12731274
"regex_val": BSONRegex("^hello.*$", options="i"),
1275+
"decimal128_val": BSONDecimal128("123.45"),
12741276
}
12751277

12761278
await doc_ref.set(bson_payload)
@@ -1295,6 +1297,7 @@ async def test_async_bson_document_writes(client, cleanup, database):
12951297
"options": "i",
12961298
}
12971299
},
1300+
"decimal128_val": {"__decimal128__": "123.45"},
12981301
}
12991302

13001303

packages/google-cloud-firestore/tests/unit/v1/test_bson.py

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,15 @@
1616
"""Unit tests for google.cloud.firestore_v1.bson classes."""
1717

1818
import copy
19+
import decimal
1920
import pickle
2021
import re
2122

2223
import pytest
2324

2425
from google.cloud.firestore_v1.bson import (
2526
BSONBinary,
27+
BSONDecimal128,
2628
BSONInt32,
2729
BSONMaxKey,
2830
BSONMinKey,
@@ -497,3 +499,87 @@ def test_bson_regex_copy():
497499
def test_bson_regex_pickle():
498500
rx = BSONRegex("^abc", options="i")
499501
assert pickle.loads(pickle.dumps(rx)) == rx
502+
503+
504+
def test_bson_decimal128_valid():
505+
dec1 = BSONDecimal128("123.45")
506+
assert dec1.value == "123.45"
507+
assert dec1.to_decimal == decimal.Decimal("123.45")
508+
assert dec1._to_map_value() == {"__decimal128__": "123.45"}
509+
assert repr(dec1) == "BSONDecimal128('123.45')"
510+
assert str(dec1) == "123.45"
511+
512+
dec2 = BSONDecimal128(42)
513+
assert dec2.value == "42"
514+
515+
dec3 = BSONDecimal128(1.5)
516+
assert dec3.value == "1.5"
517+
518+
dec4 = BSONDecimal128(decimal.Decimal("99.99"))
519+
assert dec4.value == "99.99"
520+
521+
dec5 = BSONDecimal128(dec1)
522+
assert dec5.value == "123.45"
523+
524+
525+
def test_bson_decimal128_special_values():
526+
nan_dec = BSONDecimal128("NaN")
527+
assert nan_dec.value == "NaN"
528+
assert nan_dec._to_map_value() == {"__decimal128__": "NaN"}
529+
530+
inf_dec = BSONDecimal128("Infinity")
531+
assert inf_dec.value == "Infinity"
532+
533+
neg_inf_dec = BSONDecimal128("-Infinity")
534+
assert neg_inf_dec.value == "-Infinity"
535+
536+
537+
@pytest.mark.parametrize(
538+
"val_input, exc_type, match_msg",
539+
[
540+
(True, TypeError, "value must be a Decimal, str, int, or float"),
541+
(False, TypeError, "value must be a Decimal, str, int, or float"),
542+
([1, 2], TypeError, "value must be a Decimal, str, int, or float"),
543+
("invalid_number", ValueError, "Invalid BSONDecimal128 value"),
544+
],
545+
)
546+
def test_bson_decimal128_invalid_inputs(val_input, exc_type, match_msg):
547+
with pytest.raises(exc_type, match=match_msg):
548+
BSONDecimal128(val_input)
549+
550+
551+
def test_bson_decimal128_equality():
552+
d1 = BSONDecimal128("123.45")
553+
d2 = BSONDecimal128("123.45")
554+
d3 = BSONDecimal128("678.90")
555+
assert d1 == d2
556+
assert d1 != d3
557+
assert d1 == decimal.Decimal("123.45")
558+
assert d1 != "123.45"
559+
560+
nan1 = BSONDecimal128("NaN")
561+
nan2 = BSONDecimal128("NaN")
562+
assert nan1 == nan2
563+
564+
565+
def test_bson_decimal128_hash_and_dict_key():
566+
d1 = BSONDecimal128("123.45")
567+
d2 = BSONDecimal128("123.45")
568+
assert hash(d1) == hash(d2)
569+
assert len({d1, d2}) == 1
570+
571+
nan1 = BSONDecimal128("NaN")
572+
nan2 = BSONDecimal128("NaN")
573+
assert hash(nan1) == hash(nan2)
574+
assert len({nan1, nan2}) == 1
575+
576+
577+
def test_bson_decimal128_copy():
578+
d = BSONDecimal128("123.45")
579+
assert copy.copy(d) == d
580+
assert copy.deepcopy(d) == d
581+
582+
583+
def test_bson_decimal128_pickle():
584+
d = BSONDecimal128("123.45")
585+
assert pickle.loads(pickle.dumps(d)) == d

0 commit comments

Comments
 (0)