Skip to content

Commit 53dbc8c

Browse files
committed
feat(firestore): add BSONBinary support
1 parent afb165f commit 53dbc8c

7 files changed

Lines changed: 261 additions & 2 deletions

File tree

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,8 @@ 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,
74+
BSONInt32,
7375
BSONMaxKey,
7476
BSONMinKey,
7577
BSONObjectId,
@@ -175,6 +177,8 @@ replacements:
175177
"async_transactional",
176178
"AsyncTransaction",
177179
"AsyncWriteBatch",
180+
"BSONBinary",
181+
"BSONInt32",
178182
"BSONMaxKey",
179183
"BSONMinKey",
180184
"BSONObjectId",
@@ -250,6 +254,8 @@ replacements:
250254
AsyncQuery,
251255
AsyncTransaction,
252256
AsyncWriteBatch,
257+
BSONBinary,
258+
BSONInt32,
253259
BSONMaxKey,
254260
BSONMinKey,
255261
BSONObjectId,
@@ -310,6 +316,8 @@ replacements:
310316
"async_transactional",
311317
"AsyncTransaction",
312318
"AsyncWriteBatch",
319+
"BSONBinary",
320+
"BSONInt32",
313321
"BSONMaxKey",
314322
"BSONMinKey",
315323
"BSONObjectId",

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@
3535
AsyncQuery,
3636
AsyncTransaction,
3737
AsyncWriteBatch,
38+
BSONBinary,
39+
BSONInt32,
3840
BSONMaxKey,
3941
BSONMinKey,
4042
BSONObjectId,
@@ -95,6 +97,8 @@
9597
"async_transactional",
9698
"AsyncTransaction",
9799
"AsyncWriteBatch",
100+
"BSONBinary",
101+
"BSONInt32",
98102
"BSONMaxKey",
99103
"BSONMinKey",
100104
"BSONObjectId",

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@
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,
51+
BSONInt32,
5052
BSONMaxKey,
5153
BSONMinKey,
5254
BSONObjectId,
@@ -152,6 +154,8 @@
152154
"async_transactional",
153155
"AsyncTransaction",
154156
"AsyncWriteBatch",
157+
"BSONBinary",
158+
"BSONInt32",
155159
"BSONMaxKey",
156160
"BSONMinKey",
157161
"BSONObjectId",

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: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,10 +48,15 @@
4848
from google.cloud import firestore_v1 as firestore
4949
from google.cloud.firestore_v1.base_query import And, FieldFilter, Or
5050
from google.cloud.firestore_v1.base_vector_query import DistanceMeasure
51-
from google.cloud.firestore_v1.bson import BSONMaxKey, BSONMinKey, BSONObjectId
51+
from google.cloud.firestore_v1.bson import (
52+
BSONBinary,
53+
BSONInt32,
54+
BSONMaxKey,
55+
BSONMinKey,
56+
BSONObjectId,
57+
)
5258
from google.cloud.firestore_v1.vector import Vector
5359

54-
5560
def _get_credentials_and_project():
5661
if FIRESTORE_EMULATOR:
5762
credentials = EMULATOR_CREDS
@@ -1286,6 +1291,9 @@ def test_bson_document_writes(client, cleanup, database):
12861291
"user_id": BSONObjectId("507f191e810c19729de860ea"),
12871292
"min_key": BSONMinKey(),
12881293
"max_key": BSONMaxKey(),
1294+
"int32_val": BSONInt32(42),
1295+
"binary_val_sub0": BSONBinary(b"hello", subtype=0),
1296+
"binary_val_sub128": BSONBinary(b"world", subtype=128),
12891297
}
12901298

12911299
doc_ref.set(bson_payload)
@@ -1296,6 +1304,9 @@ def test_bson_document_writes(client, cleanup, database):
12961304
"user_id": {"__oid__": "507f191e810c19729de860ea"},
12971305
"min_key": {"__min__": None},
12981306
"max_key": {"__max__": None},
1307+
"int32_val": {"__int__": 42},
1308+
"binary_val_sub0": b"hello",
1309+
"binary_val_sub128": {"__binary__": b"\x80world"},
12991310
}
13001311

13011312

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

Lines changed: 13 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,
@@ -1259,6 +1266,9 @@ async def test_async_bson_document_writes(client, cleanup, database):
12591266
"user_id": BSONObjectId("507f191e810c19729de860ea"),
12601267
"min_key": BSONMinKey(),
12611268
"max_key": BSONMaxKey(),
1269+
"int32_val": BSONInt32(42),
1270+
"binary_val_sub0": BSONBinary(b"hello", subtype=0),
1271+
"binary_val_sub128": BSONBinary(b"world", subtype=128),
12621272
}
12631273

12641274
await doc_ref.set(bson_payload)
@@ -1269,6 +1279,9 @@ async def test_async_bson_document_writes(client, cleanup, database):
12691279
"user_id": {"__oid__": "507f191e810c19729de860ea"},
12701280
"min_key": {"__min__": None},
12711281
"max_key": {"__max__": None},
1282+
"int32_val": {"__int__": 42},
1283+
"binary_val_sub0": b"hello",
1284+
"binary_val_sub128": {"__binary__": b"\x80world"},
12721285
}
12731286

12741287

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

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

2323
from google.cloud.firestore_v1.bson import (
24+
BSONBinary,
25+
BSONInt32,
2426
BSONMaxKey,
2527
BSONMinKey,
2628
BSONObjectId,
@@ -195,3 +197,147 @@ def test_bson_maxkey_copy():
195197
def test_bson_maxkey_pickle():
196198
key = BSONMaxKey()
197199
assert pickle.loads(pickle.dumps(key)) == key
200+
201+
202+
def test_bson_int32_valid():
203+
val = BSONInt32(42)
204+
assert val.value == 42
205+
assert int(val) == 42
206+
assert str(val) == "42"
207+
assert repr(val) == "BSONInt32(42)"
208+
assert val._to_map_value() == {"__int__": 42}
209+
210+
211+
def test_bson_int32_boundaries():
212+
min_val = BSONInt32(-2147483648)
213+
max_val = BSONInt32(2147483647)
214+
assert min_val.value == -2147483648
215+
assert max_val.value == 2147483647
216+
217+
218+
@pytest.mark.parametrize(
219+
"invalid_input, exc_type, match_msg",
220+
[
221+
(2147483648, ValueError, "must be between"),
222+
(-2147483649, ValueError, "must be between"),
223+
(True, TypeError, "requires an int"),
224+
(False, TypeError, "requires an int"),
225+
("42", TypeError, "requires an int"),
226+
(42.0, TypeError, "requires an int"),
227+
(None, TypeError, "requires an int"),
228+
],
229+
)
230+
def test_bson_int32_invalid_inputs(invalid_input, exc_type, match_msg):
231+
with pytest.raises(exc_type, match=match_msg):
232+
BSONInt32(invalid_input)
233+
234+
235+
def test_bson_int32_equality():
236+
val1 = BSONInt32(42)
237+
val2 = BSONInt32(42)
238+
val3 = BSONInt32(100)
239+
assert val1 == val2
240+
assert val1 != val3
241+
assert val1 != 42
242+
243+
244+
def test_bson_int32_hash_and_dict_key():
245+
val1 = BSONInt32(42)
246+
val2 = BSONInt32(42)
247+
assert hash(val1) == hash(val2)
248+
assert len({val1, val2}) == 1
249+
250+
251+
def test_bson_int32_copy():
252+
val = BSONInt32(42)
253+
assert copy.copy(val) == val
254+
assert copy.deepcopy(val) == val
255+
256+
257+
def test_bson_int32_pickle():
258+
val = BSONInt32(42)
259+
assert pickle.loads(pickle.dumps(val)) == val
260+
261+
262+
def test_bson_binary_default_subtype():
263+
val = BSONBinary(b"hello")
264+
assert val.data == b"hello"
265+
assert val.subtype == 0
266+
267+
268+
def test_bson_binary_custom_subtype():
269+
val = BSONBinary(bytearray(b"world"), subtype=128)
270+
assert val.data == b"world"
271+
assert val.subtype == 128
272+
273+
274+
def test_bson_binary_to_map_value():
275+
assert BSONBinary(b"hello", subtype=0)._to_map_value() == b"hello"
276+
assert BSONBinary(b"world", subtype=128)._to_map_value() == {
277+
"__binary__": b"\x80world"
278+
}
279+
280+
281+
def test_bson_binary_bytes_coercion():
282+
assert bytes(BSONBinary(b"hello")) == b"hello"
283+
284+
285+
def test_bson_binary_repr():
286+
assert repr(BSONBinary(b"hello", subtype=0)) == "BSONBinary(b'hello', subtype=0)"
287+
assert (
288+
repr(BSONBinary(b"world", subtype=128)) == "BSONBinary(b'world', subtype=128)"
289+
)
290+
291+
292+
def test_bson_binary_boundaries():
293+
bin_min = BSONBinary(b"test", subtype=0)
294+
bin_max = BSONBinary(b"test", subtype=255)
295+
assert bin_min.subtype == 0
296+
assert bin_max.subtype == 255
297+
298+
299+
@pytest.mark.parametrize(
300+
"data_input, subtype_input, exc_type, match_msg",
301+
[
302+
("not bytes", 0, TypeError, "must be bytes or bytearray"),
303+
(123, 0, TypeError, "must be bytes or bytearray"),
304+
(None, 0, TypeError, "must be bytes or bytearray"),
305+
(b"data", 256, ValueError, "must be between"),
306+
(b"data", -1, ValueError, "must be between"),
307+
(b"data", True, TypeError, "subtype must be an int"),
308+
(b"data", False, TypeError, "subtype must be an int"),
309+
(b"data", "0", TypeError, "subtype must be an int"),
310+
],
311+
)
312+
def test_bson_binary_invalid_inputs(data_input, subtype_input, exc_type, match_msg):
313+
with pytest.raises(exc_type, match=match_msg):
314+
BSONBinary(data_input, subtype=subtype_input)
315+
316+
317+
def test_bson_binary_equality():
318+
bin1 = BSONBinary(b"abc", subtype=0)
319+
bin2 = BSONBinary(b"abc", subtype=0)
320+
bin3 = BSONBinary(b"abc", subtype=1)
321+
bin4 = BSONBinary(b"xyz", subtype=0)
322+
assert bin1 == bin2
323+
assert bin1 != bin3
324+
assert bin1 != bin4
325+
assert bin1 != b"abc"
326+
327+
328+
def test_bson_binary_hash_and_dict_key():
329+
bin1 = BSONBinary(b"abc", subtype=0)
330+
bin2 = BSONBinary(b"abc", subtype=0)
331+
assert hash(bin1) == hash(bin2)
332+
assert len({bin1, bin2}) == 1
333+
334+
335+
def test_bson_binary_copy():
336+
val = BSONBinary(b"hello", subtype=5)
337+
assert copy.copy(val) == val
338+
assert copy.deepcopy(val) == val
339+
340+
341+
def test_bson_binary_pickle():
342+
val = BSONBinary(b"hello", subtype=5)
343+
assert pickle.loads(pickle.dumps(val)) == val

0 commit comments

Comments
 (0)