Skip to content
This repository was archived by the owner on Apr 1, 2026. It is now read-only.

Commit 0ed173f

Browse files
committed
feat: add support for AddToCell in Data Client
1 parent 7a91bbf commit 0ed173f

2 files changed

Lines changed: 195 additions & 0 deletions

File tree

google/cloud/bigtable/data/mutations.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,14 @@ def _from_dict(cls, input_dict: dict[str, Any]) -> Mutation:
123123
instance = DeleteAllFromFamily(details["family_name"])
124124
elif "delete_from_row" in input_dict:
125125
instance = DeleteAllFromRow()
126+
elif "add_to_cell" in input_dict:
127+
details = input_dict["add_to_cell"]
128+
instance = AddToCell(
129+
details["family_name"],
130+
details["column_qualifier"]["raw_value"],
131+
details["input"]["int_value"],
132+
details["timestamp"]["raw_timestamp_micros"],
133+
)
126134
except KeyError as e:
127135
raise ValueError("Invalid mutation dictionary") from e
128136
if instance is None:
@@ -276,6 +284,65 @@ def _to_dict(self) -> dict[str, Any]:
276284
}
277285

278286

287+
@dataclass
288+
class AddToCell(Mutation):
289+
"""
290+
Mutation to add a value to an aggregate cell.
291+
292+
293+
Args:
294+
family: The name of the column family to which the cell belongs.
295+
qualifier: The column qualifier of the cell.
296+
value: The value to be accumulated into the cell.
297+
timestamp_micros: The timestamp of the cell.
298+
299+
Raises:
300+
TypeError: If `qualifier` is not `bytes` or `str`.
301+
TypeError: If `value` is not `int`.
302+
ValueError: If `timestamp_micros` is less than `_SERVER_SIDE_TIMESTAMP`.
303+
"""
304+
305+
def __init__(
306+
self,
307+
family: str,
308+
qualifier: bytes | str,
309+
value: int,
310+
timestamp_micros: int | None = None,
311+
):
312+
qualifier = qualifier.encode() if isinstance(qualifier, str) else qualifier
313+
if not isinstance(qualifier, bytes):
314+
raise TypeError("qualifier must be bytes or str")
315+
if not isinstance(value, int):
316+
raise TypeError("value must be int")
317+
if abs(value) > _MAX_INCREMENT_VALUE:
318+
raise ValueError(
319+
"int values must be between -2**63 and 2**63 (64-bit signed int)"
320+
)
321+
322+
if timestamp_micros is None:
323+
# use current timestamp, with milisecond precision
324+
timestamp_micros = time.time_ns() // 1000
325+
timestamp_micros = timestamp_micros - (timestamp_micros % 1000)
326+
327+
self.family = family
328+
self.qualifier = qualifier
329+
self.value = value
330+
self.timestamp_micros = timestamp_micros
331+
332+
def _to_dict(self) -> dict[str, Any]:
333+
return {
334+
"add_to_cell": {
335+
"family_name": self.family,
336+
"column_qualifier": {"raw_value": self.qualifier},
337+
"timestamp": {"raw_timestamp_micros": self.timestamp_micros},
338+
"input": {"int_value": self.value},
339+
}
340+
}
341+
342+
def is_idempotent(self) -> bool:
343+
return False
344+
345+
279346
class RowMutationEntry:
280347
"""
281348
A single entry in a `MutateRows` request.

tests/unit/data/test_mutations.py

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -706,3 +706,131 @@ def test__from_dict(self):
706706
assert len(instance.mutations) == 1
707707
assert isinstance(instance.mutations[0], mutations.DeleteAllFromFamily)
708708
assert instance.mutations[0].family_to_delete == "test_family"
709+
710+
711+
class TestAddToCell:
712+
def _target_class(self):
713+
from google.cloud.bigtable.data.mutations import AddToCell
714+
715+
return AddToCell
716+
717+
def _make_one(self, *args, **kwargs):
718+
return self._target_class()(*args, **kwargs)
719+
720+
@pytest.mark.parametrize("input_val", [2**64, -(2**64)])
721+
def test_ctor_large_int(self, input_val):
722+
with pytest.raises(ValueError) as e:
723+
self._make_one(family="f", qualifier=b"b", value=input_val)
724+
assert "int values must be between" in str(e.value)
725+
726+
@pytest.mark.parametrize("input_val", ["", "a", "abc", "hello world!"])
727+
def test_ctor_str_value(self, input_val):
728+
with pytest.raises(ValueError) as e:
729+
self._make_one(family="f", qualifier=b"b", value=input_val)
730+
assert "value must be int" in str(e.value)
731+
732+
def test_ctor(self):
733+
"""Ensure constructor sets expected values"""
734+
expected_family = "test-family"
735+
expected_qualifier = b"test-qualifier"
736+
expected_value = 1234
737+
expected_timestamp = 1234567890
738+
instance = self._make_one(
739+
expected_family, expected_qualifier, expected_value, expected_timestamp
740+
)
741+
assert instance.family == expected_family
742+
assert instance.qualifier == expected_qualifier
743+
assert instance.value == expected_value
744+
assert instance.timestamp_micros == expected_timestamp
745+
746+
def test_ctor_negative_timestamp(self):
747+
"""Only positive or -1 timestamps are valid"""
748+
with pytest.raises(ValueError) as e:
749+
self._make_one("test-family", b"test-qualifier", b"test-value", -2)
750+
assert (
751+
"timestamp_micros must be positive (or -1 for server-side timestamp)"
752+
in str(e.value)
753+
)
754+
755+
@pytest.mark.parametrize(
756+
"timestamp_ns,expected_timestamp_micros",
757+
[
758+
(0, 0),
759+
(1, 0),
760+
(123, 0),
761+
(999, 0),
762+
(999_999, 0),
763+
(1_000_000, 1000),
764+
(1_234_567, 1000),
765+
(1_999_999, 1000),
766+
(2_000_000, 2000),
767+
(1_234_567_890_123, 1_234_567_000),
768+
],
769+
)
770+
def test_ctor_no_timestamp(self, timestamp_ns, expected_timestamp_micros):
771+
"""If no timestamp is given, should use current time with millisecond precision"""
772+
with mock.patch("time.time_ns", return_value=timestamp_ns):
773+
instance = self._make_one("test-family", b"test-qualifier", 1234)
774+
assert instance.timestamp_micros == expected_timestamp_micros
775+
776+
def test__to_dict(self):
777+
"""ensure dict representation is as expected"""
778+
expected_family = "test-family"
779+
expected_qualifier = b"test-qualifier"
780+
expected_value = 1234
781+
expected_timestamp = 123456789
782+
instance = self._make_one(
783+
expected_family, expected_qualifier, expected_value, expected_timestamp
784+
)
785+
got_dict = instance._to_dict()
786+
assert list(got_dict.keys()) == ["add_to_cell"]
787+
got_inner_dict = got_dict["add_to_cell"]
788+
assert got_inner_dict["family_name"] == expected_family
789+
assert got_inner_dict["column_qualifier"]["raw_value"] == expected_qualifier
790+
assert got_inner_dict["timestamp_micros"]["raw_timestamp_micros"] == expected_timestamp
791+
assert got_inner_dict["value"]["int_value"] == expected_value
792+
assert len(got_inner_dict.keys()) == 4
793+
794+
def test__to_pb(self):
795+
"""ensure proto representation is as expected"""
796+
import google.cloud.bigtable_v2.types.data as data_pb
797+
798+
expected_family = "test-family"
799+
expected_qualifier = b"test-qualifier"
800+
expected_value = 1234
801+
expected_timestamp = 123456789
802+
instance = self._make_one(
803+
expected_family, expected_qualifier, expected_value, expected_timestamp
804+
)
805+
got_pb = instance._to_pb()
806+
assert isinstance(got_pb, data_pb.Mutation)
807+
assert got_pb.set_cell.family_name == expected_family
808+
assert got_pb.set_cell.column_qualifier.raw_value == expected_qualifier
809+
assert got_pb.set_cell.timestamp_micros.raw_timestamp_micros == expected_timestamp
810+
assert got_pb.set_cell.value.int_value == expected_value
811+
812+
@pytest.mark.parametrize(
813+
"timestamp",
814+
[
815+
(1234567890),
816+
(1),
817+
(0),
818+
(-1),
819+
(None),
820+
],
821+
)
822+
def test_is_idempotent(self, timestamp, expected_value):
823+
"""is_idempotent is not based on whether an explicit timestamp is set"""
824+
instance = self._make_one(
825+
"test-family", b"test-qualifier", 1234, timestamp
826+
)
827+
assert not instance.is_idempotent()
828+
829+
def test___str__(self):
830+
"""Str representation of mutations should be to_dict"""
831+
instance = self._make_one(
832+
"test-family", b"test-qualifier", 1234, 1234567890
833+
)
834+
str_value = instance.__str__()
835+
dict_value = instance._to_dict()
836+
assert str_value == str(dict_value)

0 commit comments

Comments
 (0)