Skip to content

Commit 71cb746

Browse files
committed
feat(firestore): add BSONBinary support
1 parent 0f09d53 commit 71cb746

7 files changed

Lines changed: 183 additions & 1 deletion

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
@@ -70,6 +70,7 @@ replacements:
7070
from google.cloud.firestore_v1.base_query import And, FieldFilter, Or
7171
from google.cloud.firestore_v1.batch import WriteBatch
7272
from google.cloud.firestore_v1.bson import (
73+
BSONBinary,
7374
BSONInt32,
7475
BSONMaxKey,
7576
BSONMinKey,
@@ -176,6 +177,7 @@ replacements:
176177
"async_transactional",
177178
"AsyncTransaction",
178179
"AsyncWriteBatch",
180+
"BSONBinary",
179181
"BSONInt32",
180182
"BSONMaxKey",
181183
"BSONMinKey",
@@ -252,6 +254,7 @@ replacements:
252254
AsyncQuery,
253255
AsyncTransaction,
254256
AsyncWriteBatch,
257+
BSONBinary,
255258
BSONInt32,
256259
BSONMaxKey,
257260
BSONMinKey,
@@ -313,6 +316,7 @@ replacements:
313316
"async_transactional",
314317
"AsyncTransaction",
315318
"AsyncWriteBatch",
319+
"BSONBinary",
316320
"BSONInt32",
317321
"BSONMaxKey",
318322
"BSONMinKey",

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
AsyncQuery,
3636
AsyncTransaction,
3737
AsyncWriteBatch,
38+
BSONBinary,
3839
BSONInt32,
3940
BSONMaxKey,
4041
BSONMinKey,
@@ -96,6 +97,7 @@
9697
"async_transactional",
9798
"AsyncTransaction",
9899
"AsyncWriteBatch",
100+
"BSONBinary",
99101
"BSONInt32",
100102
"BSONMaxKey",
101103
"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
@@ -47,6 +47,7 @@
4747
from google.cloud.firestore_v1.base_query import And, FieldFilter, Or
4848
from google.cloud.firestore_v1.batch import WriteBatch
4949
from google.cloud.firestore_v1.bson import (
50+
BSONBinary,
5051
BSONInt32,
5152
BSONMaxKey,
5253
BSONMinKey,
@@ -153,6 +154,7 @@
153154
"async_transactional",
154155
"AsyncTransaction",
155156
"AsyncWriteBatch",
157+
"BSONBinary",
156158
"BSONInt32",
157159
"BSONMaxKey",
158160
"BSONMinKey",

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

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
"BSONMinKey",
3434
"BSONMaxKey",
3535
"BSONInt32",
36+
"BSONBinary",
3637
]
3738

3839
_OBJECT_ID_BYTES_LEN = 12
@@ -214,3 +215,75 @@ def __eq__(self, other: Any) -> bool:
214215

215216
def __hash__(self) -> int:
216217
return hash((type(self), self._value))
218+
219+
220+
class BSONBinary(_BSONType):
221+
"""Represents a BSON binary data container with a subtype for Firestore.
222+
223+
Args:
224+
data (Union[bytes, bytearray]): The binary byte payload.
225+
subtype (int, optional): A 1-byte BSON binary subtype tag (0 to 255).
226+
Defaults to 0 (generic binary).
227+
228+
Raises:
229+
TypeError: If data is not bytes/bytearray or subtype is not an integer/is a boolean.
230+
ValueError: If subtype is outside the 1-byte range (0 to 255).
231+
232+
Example:
233+
>>> binary = BSONBinary(b"hello world", subtype=0)
234+
>>> binary.data
235+
b'hello world'
236+
>>> binary.subtype
237+
0
238+
"""
239+
240+
__slots__ = ("_data", "_subtype")
241+
242+
_SUBTYPE_MIN: int = 0
243+
_SUBTYPE_MAX: int = 255
244+
245+
def __init__(self, data: Union[bytes, bytearray], subtype: int = 0):
246+
if not isinstance(data, (bytes, bytearray)):
247+
raise TypeError("BSONBinary data must be bytes or bytearray.")
248+
if isinstance(subtype, bool) or not isinstance(subtype, int):
249+
raise TypeError("BSONBinary subtype must be an int.")
250+
if not (self._SUBTYPE_MIN <= subtype <= self._SUBTYPE_MAX):
251+
raise ValueError(
252+
f"BSONBinary subtype must be between {self._SUBTYPE_MIN} and {self._SUBTYPE_MAX}."
253+
)
254+
self._data: bytes = bytes(data)
255+
self._subtype: int = subtype
256+
257+
@property
258+
def data(self) -> bytes:
259+
"""bytes: The binary byte payload."""
260+
return self._data
261+
262+
@property
263+
def subtype(self) -> int:
264+
"""int: The BSON binary subtype tag (0 to 255)."""
265+
return self._subtype
266+
267+
def _to_map_value(self) -> Union[bytes, Dict[str, bytes]]:
268+
"""Returns representation for wire serialization.
269+
270+
If subtype is 0 (generic binary), returns raw bytes.
271+
If subtype is non-zero, returns map dictionary representation.
272+
"""
273+
if self._subtype == 0:
274+
return self._data
275+
return {"__binary__": bytes([self._subtype]) + self._data}
276+
277+
def __repr__(self) -> str:
278+
return f"BSONBinary({self._data!r}, subtype={self._subtype})"
279+
280+
def __bytes__(self) -> bytes:
281+
return self._data
282+
283+
def __eq__(self, other: Any) -> bool:
284+
if isinstance(other, BSONBinary):
285+
return self._data == other._data and self._subtype == other._subtype
286+
return NotImplemented
287+
288+
def __hash__(self) -> int:
289+
return hash((type(self), self._data, self._subtype))

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,14 +49,14 @@
4949
from google.cloud.firestore_v1.base_query import And, FieldFilter, Or
5050
from google.cloud.firestore_v1.base_vector_query import DistanceMeasure
5151
from google.cloud.firestore_v1.bson import (
52+
BSONBinary,
5253
BSONInt32,
5354
BSONMaxKey,
5455
BSONMinKey,
5556
BSONObjectId,
5657
)
5758
from google.cloud.firestore_v1.vector import Vector
5859

59-
6060
def _get_credentials_and_project():
6161
if FIRESTORE_EMULATOR:
6262
credentials = EMULATOR_CREDS
@@ -1292,6 +1292,8 @@ def test_bson_document_writes(client, cleanup, database):
12921292
"min_key": BSONMinKey(),
12931293
"max_key": BSONMaxKey(),
12941294
"int32_val": BSONInt32(42),
1295+
"binary_val_sub0": BSONBinary(b"hello", subtype=0),
1296+
"binary_val_sub128": BSONBinary(b"world", subtype=128),
12951297
}
12961298

12971299
doc_ref.set(bson_payload)
@@ -1303,6 +1305,8 @@ def test_bson_document_writes(client, cleanup, database):
13031305
"min_key": {"__min__": None},
13041306
"max_key": {"__max__": None},
13051307
"int32_val": {"__int__": 42},
1308+
"binary_val_sub0": b"hello",
1309+
"binary_val_sub128": {"__binary__": b"\x80world"},
13061310
}
13071311

13081312

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,13 @@
3232
NotFound,
3333
)
3434
from google.cloud._helpers import _datetime_to_pb_timestamp
35+
from google.cloud.firestore_v1.bson import (
36+
BSONBinary,
37+
BSONInt32,
38+
BSONMaxKey,
39+
BSONMinKey,
40+
BSONObjectId,
41+
)
3542
from google.oauth2 import service_account
3643
from test__helpers import (
3744
EMULATOR_CREDS,
@@ -52,6 +59,7 @@
5259
from google.cloud.firestore_v1.base_query import And, FieldFilter, Or
5360
from google.cloud.firestore_v1.base_vector_query import DistanceMeasure
5461
from google.cloud.firestore_v1.bson import (
62+
BSONBinary,
5563
BSONInt32,
5664
BSONMaxKey,
5765
BSONMinKey,
@@ -1265,6 +1273,8 @@ async def test_async_bson_document_writes(client, cleanup, database):
12651273
"min_key": BSONMinKey(),
12661274
"max_key": BSONMaxKey(),
12671275
"int32_val": BSONInt32(42),
1276+
"binary_val_sub0": BSONBinary(b"hello", subtype=0),
1277+
"binary_val_sub128": BSONBinary(b"world", subtype=128),
12681278
}
12691279

12701280
await doc_ref.set(bson_payload)
@@ -1276,6 +1286,8 @@ async def test_async_bson_document_writes(client, cleanup, database):
12761286
"min_key": {"__min__": None},
12771287
"max_key": {"__max__": None},
12781288
"int32_val": {"__int__": 42},
1289+
"binary_val_sub0": b"hello",
1290+
"binary_val_sub128": {"__binary__": b"\x80world"},
12791291
}
12801292

12811293

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

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
import pytest
2222

2323
from google.cloud.firestore_v1.bson import (
24+
BSONBinary,
2425
BSONInt32,
2526
BSONMaxKey,
2627
BSONMinKey,
@@ -267,3 +268,87 @@ def test_bson_int32_copy():
267268
def test_bson_int32_pickle():
268269
val = BSONInt32(42)
269270
assert pickle.loads(pickle.dumps(val)) == val
271+
272+
273+
def test_bson_binary_default_subtype():
274+
val = BSONBinary(b"hello")
275+
assert val.data == b"hello"
276+
assert val.subtype == 0
277+
278+
279+
def test_bson_binary_custom_subtype():
280+
val = BSONBinary(bytearray(b"world"), subtype=128)
281+
assert val.data == b"world"
282+
assert val.subtype == 128
283+
284+
285+
def test_bson_binary_to_map_value():
286+
assert BSONBinary(b"hello", subtype=0)._to_map_value() == b"hello"
287+
assert BSONBinary(b"world", subtype=128)._to_map_value() == {
288+
"__binary__": b"\x80world"
289+
}
290+
291+
292+
def test_bson_binary_bytes_coercion():
293+
assert bytes(BSONBinary(b"hello")) == b"hello"
294+
295+
296+
def test_bson_binary_repr():
297+
assert repr(BSONBinary(b"hello", subtype=0)) == "BSONBinary(b'hello', subtype=0)"
298+
assert (
299+
repr(BSONBinary(b"world", subtype=128)) == "BSONBinary(b'world', subtype=128)"
300+
)
301+
302+
303+
def test_bson_binary_boundaries():
304+
bin_min = BSONBinary(b"test", subtype=0)
305+
bin_max = BSONBinary(b"test", subtype=255)
306+
assert bin_min.subtype == 0
307+
assert bin_max.subtype == 255
308+
309+
310+
@pytest.mark.parametrize(
311+
"data_input, subtype_input, exc_type, match_msg",
312+
[
313+
("not bytes", 0, TypeError, "must be bytes or bytearray"),
314+
(123, 0, TypeError, "must be bytes or bytearray"),
315+
(None, 0, TypeError, "must be bytes or bytearray"),
316+
(b"data", 256, ValueError, "must be between"),
317+
(b"data", -1, ValueError, "must be between"),
318+
(b"data", True, TypeError, "subtype must be an int"),
319+
(b"data", False, TypeError, "subtype must be an int"),
320+
(b"data", "0", TypeError, "subtype must be an int"),
321+
],
322+
)
323+
def test_bson_binary_invalid_inputs(data_input, subtype_input, exc_type, match_msg):
324+
with pytest.raises(exc_type, match=match_msg):
325+
BSONBinary(data_input, subtype=subtype_input)
326+
327+
328+
def test_bson_binary_equality():
329+
bin1 = BSONBinary(b"abc", subtype=0)
330+
bin2 = BSONBinary(b"abc", subtype=0)
331+
bin3 = BSONBinary(b"abc", subtype=1)
332+
bin4 = BSONBinary(b"xyz", subtype=0)
333+
assert bin1 == bin2
334+
assert bin1 != bin3
335+
assert bin1 != bin4
336+
assert bin1 != b"abc"
337+
338+
339+
def test_bson_binary_hash_and_dict_key():
340+
bin1 = BSONBinary(b"abc", subtype=0)
341+
bin2 = BSONBinary(b"abc", subtype=0)
342+
assert hash(bin1) == hash(bin2)
343+
assert len({bin1, bin2}) == 1
344+
345+
346+
def test_bson_binary_copy():
347+
val = BSONBinary(b"hello", subtype=5)
348+
assert copy.copy(val) == val
349+
assert copy.deepcopy(val) == val
350+
351+
352+
def test_bson_binary_pickle():
353+
val = BSONBinary(b"hello", subtype=5)
354+
assert pickle.loads(pickle.dumps(val)) == val

0 commit comments

Comments
 (0)