From d004f7076d3fd3bdac6f19ac08d84f5f00f6f6bf Mon Sep 17 00:00:00 2001 From: Sakthivel Subramanian Date: Mon, 27 Jul 2026 21:15:28 +0000 Subject: [PATCH 1/3] fix(spanner): implement dict protocol and nested unwrapping for JsonObject Fixes JsonObject array/scalar/null variants breaking standard Python container protocols (len, bool, iter, getitem, contains, eq). Adds _unwrap_for_json helper to safely serialize nested JsonObject instances without data erasure. Fixes #15870 --- .../google/cloud/spanner_v1/data_types.py | 115 +++++++- .../tests/unit/test_datatypes.py | 272 +++++++++++++----- 2 files changed, 300 insertions(+), 87 deletions(-) diff --git a/packages/google-cloud-spanner/google/cloud/spanner_v1/data_types.py b/packages/google-cloud-spanner/google/cloud/spanner_v1/data_types.py index 59a2268e98a7..cac6c412e7f6 100644 --- a/packages/google-cloud-spanner/google/cloud/spanner_v1/data_types.py +++ b/packages/google-cloud-spanner/google/cloud/spanner_v1/data_types.py @@ -55,7 +55,80 @@ def __init__(self, *args, **kwargs): self._simple_value = args[0]._simple_value if not self._is_null: - super(JsonObject, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) + + def __len__(self): + if self._is_null: + return 0 + if self._is_array: + return len(self._array_value) + if self._is_scalar_value: + return 1 + return super().__len__() + + def __bool__(self): + if self._is_null: + return False + if self._is_array: + return bool(self._array_value) + if self._is_scalar_value: + return True + return super().__len__() > 0 + + def __iter__(self): + if self._is_array: + return iter(self._array_value) + if self._is_scalar_value: + raise TypeError( + f"'{type(self._simple_value).__name__}' object is not iterable" + ) + return super().__iter__() + + def __getitem__(self, key): + if self._is_array: + return self._array_value[key] + if self._is_scalar_value: + raise TypeError( + f"'{type(self._simple_value).__name__}' object is not subscriptable" + ) + return super().__getitem__(key) + + def __contains__(self, item): + if self._is_array: + return item in self._array_value + if self._is_scalar_value: + raise TypeError( + f"argument of type '{type(self._simple_value).__name__}' " + "is not iterable" + ) + return super().__contains__(item) + + def __eq__(self, other): + if isinstance(other, JsonObject): + if self._is_array: + return ( + getattr(other, "_is_array", False) + and self._array_value == other._array_value + ) + if self._is_scalar_value: + return ( + getattr(other, "_is_scalar_value", False) + and self._simple_value == other._simple_value + ) + if self._is_null: + return getattr(other, "_is_null", False) + return not ( + getattr(other, "_is_array", False) + or getattr(other, "_is_scalar_value", False) + or getattr(other, "_is_null", False) + ) and super().__eq__(other) + if self._is_array: + return self._array_value == other + if self._is_scalar_value: + return self._simple_value == other + if self._is_null: + return other is None or (isinstance(other, dict) and len(other) == 0) + return super().__eq__(other) def __repr__(self): if self._is_array: @@ -64,7 +137,7 @@ def __repr__(self): if self._is_scalar_value: return str(self._simple_value) - return super(JsonObject, self).__repr__() + return super().__repr__() @classmethod def from_str(cls, str_repr): @@ -90,17 +163,33 @@ def serialize(self): if self._is_null: return None + raw = _unwrap_for_json(self) if self._is_scalar_value: - return json.dumps(self._simple_value) + return json.dumps(raw) + + return json.dumps(raw, sort_keys=True, separators=(",", ":")) - if self._is_array: - return json.dumps(self._array_value, sort_keys=True, separators=(",", ":")) - return json.dumps(self, sort_keys=True, separators=(",", ":")) +def _unwrap_for_json(val): + """Recursively unwrap JsonObject instances for safe json.dumps serialization.""" + if isinstance(val, JsonObject): + if val._is_null: + return None + if val._is_array: + return [_unwrap_for_json(item) for item in val._array_value] + if val._is_scalar_value: + return val._simple_value + return {k: _unwrap_for_json(v) for k, v in val.items()} + if isinstance(val, dict): + return {k: _unwrap_for_json(v) for k, v in val.items()} + if isinstance(val, (list, tuple)): + return [_unwrap_for_json(item) for item in val] + return val _INTERVAL_PATTERN = re.compile( - r"^P(-?\d+Y)?(-?\d+M)?(-?\d+D)?(T(-?\d+H)?(-?\d+M)?(-?((\d+([.,]\d{1,9})?)|([.,]\d{1,9}))S)?)?$" + r"^P(-?\d+Y)?(-?\d+M)?(-?\d+D)?" + r"(T(-?\d+H)?(-?\d+M)?(-?((\d+([.,]\d{1,9})?)|([.,]\d{1,9}))S)?)?$" ) @@ -109,7 +198,7 @@ class Interval: """Represents a Spanner INTERVAL type. An interval is a combination of months, days and nanoseconds. - Internally, Spanner supports Interval value with the following range of individual fields: + Internally, Spanner supports Interval value with individual fields range: months: [-120000, 120000] days: [-3660000, 3660000] nanoseconds: [-316224000000000000000, 316224000000000000000] @@ -199,12 +288,14 @@ def from_str(cls, s: str) -> "Interval": parts = match.groups() if not any(parts[:3]) and not parts[3]: raise ValueError( - f"Invalid interval format: at least one component (Y/M/D/H/M/S) is required: {s}" + "Invalid interval format: at least one component " + f"(Y/M/D/H/M/S) is required: {s}" ) if parts[3] == "T" and not any(parts[4:7]): raise ValueError( - f"Invalid interval format: time designator 'T' present but no time components specified: {s}" + "Invalid interval format: time designator 'T' present " + f"but no time components specified: {s}" ) def parse_num(s: str, suffix: str) -> int: @@ -298,14 +389,14 @@ def _proto_enum(int_val, proto_enum_object): def get_proto_message(bytes_string, proto_message_object): - """parses serialized protocol buffer bytes' data or its list into proto message or list of proto message. + """Parses serialized protocol buffer bytes data or list into proto message. Args: bytes_string (bytes or list[bytes]): bytes object. proto_message_object (Message): Message object for parsing Returns: - Message or list[Message]: parses serialized protocol buffer data into this message. + Message or list[Message]: Parsed protocol buffer message(s). Raises: ValueError: if the input proto_message_object is not of type Message diff --git a/packages/google-cloud-spanner/tests/unit/test_datatypes.py b/packages/google-cloud-spanner/tests/unit/test_datatypes.py index c72c964dad17..1d1933598ab1 100644 --- a/packages/google-cloud-spanner/tests/unit/test_datatypes.py +++ b/packages/google-cloud-spanner/tests/unit/test_datatypes.py @@ -1,6 +1,6 @@ -# Copyright 2024 Google LLC All rights reserved. +# Copyright 2024 Google LLC # -# Licensed under the Apache License, Version 2.0 (the "License"); +# Licensed under the Apache License, Version 2.0 ( disputes ); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # @@ -12,87 +12,209 @@ # See the License for the specific language governing permissions and # limitations under the License. - import json import unittest from google.cloud.spanner_v1.data_types import JsonObject -class Test_JsonObject_serde(unittest.TestCase): - def test_w_dict(self): - data = {"foo": "bar"} - expected = json.dumps(data, sort_keys=True, separators=(",", ":")) - data_jsonobject = JsonObject(data) - self.assertEqual(data_jsonobject.serialize(), expected) +class Test_JsonObject(unittest.TestCase): + def _make_one(self, *args, **kwargs): + return JsonObject(*args, **kwargs) - def test_w_list_of_dict(self): - data = [{"foo1": "bar1"}, {"foo2": "bar2"}] + def test_serialize_dict(self): + data = {"id": "m1", "content": "hello"} + obj = self._make_one(data) expected = json.dumps(data, sort_keys=True, separators=(",", ":")) - data_jsonobject = JsonObject(data) - self.assertEqual(data_jsonobject.serialize(), expected) + self.assertEqual(obj.serialize(), expected) - def test_w_JsonObject_of_dict(self): - data = {"foo": "bar"} + def test_serialize_array(self): + data = [{"id": "m1", "content": "hello"}] + obj = self._make_one(data) expected = json.dumps(data, sort_keys=True, separators=(",", ":")) - data_jsonobject = JsonObject(JsonObject(data)) - self.assertEqual(data_jsonobject.serialize(), expected) + self.assertEqual(obj.serialize(), expected) + + def test_serialize_scalar(self): + obj = self._make_one("hello") + self.assertEqual(obj.serialize(), '"hello"') + + def test_serialize_null(self): + obj = self._make_one(None) + self.assertIsNone(obj.serialize()) + + def test_serialize_nested_array_jsonobject(self): + obj = self._make_one([JsonObject([1, 2]), JsonObject(42)]) + self.assertEqual(obj.serialize(), "[[1,2],42]") - def test_w_JsonObject_of_list_of_dict(self): - data = [{"foo1": "bar1"}, {"foo2": "bar2"}] - expected = json.dumps(data, sort_keys=True, separators=(",", ":")) - data_jsonobject = JsonObject(JsonObject(data)) - self.assertEqual(data_jsonobject.serialize(), expected) - - def test_w_simple_float_JsonData(self): - data = 1.1 - expected = json.dumps(data) - data_jsonobject = JsonObject(data) - self.assertEqual(data_jsonobject.serialize(), expected) - - def test_w_simple_str_JsonData(self): - data = "foo" - expected = json.dumps(data) - data_jsonobject = JsonObject(data) - self.assertEqual(data_jsonobject.serialize(), expected) - - def test_w_empty_str_JsonData(self): - data = "" - expected = json.dumps(data) - data_jsonobject = JsonObject(data) - self.assertEqual(data_jsonobject.serialize(), expected) - - def test_w_None_JsonData(self): - data = None - data_jsonobject = JsonObject(data) - self.assertEqual(data_jsonobject.serialize(), None) - - def test_w_list_of_simple_JsonData(self): - data = [1.1, "foo"] - expected = json.dumps(data, sort_keys=True, separators=(",", ":")) - data_jsonobject = JsonObject(data) - self.assertEqual(data_jsonobject.serialize(), expected) - - def test_w_empty_list(self): - data = [] - expected = json.dumps(data) - data_jsonobject = JsonObject(data) - self.assertEqual(data_jsonobject.serialize(), expected) - - def test_w_empty_dict(self): - data = [{}] - expected = json.dumps(data) - data_jsonobject = JsonObject(data) - self.assertEqual(data_jsonobject.serialize(), expected) - - def test_w_JsonObject_of_simple_JsonData(self): - data = 1.1 - expected = json.dumps(data) - data_jsonobject = JsonObject(JsonObject(data)) - self.assertEqual(data_jsonobject.serialize(), expected) - - def test_w_JsonObject_of_list_of_simple_JsonData(self): - data = [1.1, "foo"] - expected = json.dumps(data, sort_keys=True, separators=(",", ":")) - data_jsonobject = JsonObject(JsonObject(data)) - self.assertEqual(data_jsonobject.serialize(), expected) + def test_from_str(self): + obj = JsonObject.from_str('{"id": "m1"}') + self.assertEqual(obj.serialize(), '{"id":"m1"}') + + def test_from_str_array(self): + obj = JsonObject.from_str('[{"id": "m1"}]') + self.assertEqual(obj.serialize(), '[{"id":"m1"}]') + + def test_from_str_null(self): + obj = JsonObject.from_str("null") + self.assertIsNone(obj.serialize()) + + +class Test_JsonObject_dict_protocol(unittest.TestCase): + """Verify that JsonObject behaves correctly with standard Python + operations (len, bool, iteration, indexing) for all JSON variants.""" + + def test_isinstance_dict(self): + obj = JsonObject({"a": 1}) + self.assertTrue(isinstance(obj, dict)) + obj_arr = JsonObject([1, 2]) + self.assertTrue(isinstance(obj_arr, dict)) + + def test_array_len(self): + obj = JsonObject([{"id": 1}, {"id": 2}]) + self.assertEqual(len(obj), 2) + + def test_array_bool_truthy(self): + obj = JsonObject([{"id": 1}]) + self.assertTrue(obj) + + def test_array_bool_empty(self): + obj = JsonObject([]) + self.assertFalse(obj) + + def test_array_iter(self): + data = [{"a": 1}, {"b": 2}] + obj = JsonObject(data) + self.assertEqual(list(obj), data) + + def test_array_getitem(self): + data = [{"a": 1}, {"b": 2}] + obj = JsonObject(data) + self.assertEqual(obj[0], {"a": 1}) + self.assertEqual(obj[1], {"b": 2}) + + def test_array_contains(self): + data = [1, 2, 3] + obj = JsonObject(data) + self.assertIn(2, obj) + self.assertNotIn(4, obj) + + def test_array_eq(self): + data = [{"id": 1}] + obj = JsonObject(data) + self.assertEqual(obj, data) + + def test_dict_len(self): + obj = JsonObject({"a": 1, "b": 2}) + self.assertEqual(len(obj), 2) + + def test_dict_bool(self): + obj = JsonObject({"a": 1}) + self.assertTrue(obj) + + def test_dict_iter(self): + obj = JsonObject({"a": 1, "b": 2}) + self.assertEqual(sorted(obj), ["a", "b"]) + + def test_dict_getitem(self): + obj = JsonObject({"key": "value"}) + self.assertEqual(obj["key"], "value") + + def test_null_len(self): + obj = JsonObject(None) + self.assertEqual(len(obj), 0) + + def test_null_bool(self): + obj = JsonObject(None) + self.assertFalse(obj) + + def test_scalar_len(self): + obj = JsonObject(42) + self.assertEqual(len(obj), 1) + + def test_scalar_bool(self): + obj = JsonObject(42) + self.assertTrue(obj) + + def test_scalar_not_iterable(self): + obj = JsonObject(42) + with self.assertRaises(TypeError): + iter(obj) + + def test_scalar_not_subscriptable(self): + obj = JsonObject(42) + with self.assertRaises(TypeError): + _ = obj[0] + + +class Test_JsonObject_complex_nested(unittest.TestCase): + """Complex integration unit tests for deeply nested JsonObject compositions, + multi-level arrays/dicts, cross-type equality, and edge cases.""" + + def test_deeply_nested_serialization(self): + complex_obj = JsonObject( + { + "config": JsonObject({"enabled": True, "timeout": 30}), + "data": JsonObject( + [ + JsonObject([1, 2]), + JsonObject({"score": 42.5}), + JsonObject(None), + ] + ), + "meta": JsonObject("v1.0"), + } + ) + expected = ( + '{"config":{"enabled":true,"timeout":30},' + '"data":[[1,2],{"score":42.5},null],' + '"meta":"v1.0"}' + ) + self.assertEqual(complex_obj.serialize(), expected) + + def test_deeply_nested_equality(self): + obj1 = JsonObject( + { + "users": JsonObject( + [ + JsonObject({"id": 1, "roles": JsonObject(["admin"])}), + JsonObject({"id": 2, "roles": JsonObject(["user"])}), + ] + ) + } + ) + obj2 = JsonObject( + { + "users": [ + {"id": 1, "roles": ["admin"]}, + {"id": 2, "roles": ["user"]}, + ] + } + ) + raw_native = { + "users": [ + {"id": 1, "roles": ["admin"]}, + {"id": 2, "roles": ["user"]}, + ] + } + self.assertEqual(obj1, obj2) + self.assertEqual(obj1, raw_native) + + def test_multi_level_indexing_and_iteration(self): + data = JsonObject( + [ + JsonObject([10, 20, 30]), + JsonObject({"key": "val"}), + ] + ) + self.assertEqual(data[0][1], 20) + self.assertEqual(data[1]["key"], "val") + self.assertEqual(len(data), 2) + self.assertEqual(len(data[0]), 3) + self.assertIn({"key": "val"}, data) + + def test_triple_rewrapping(self): + obj = JsonObject(JsonObject(JsonObject([1, 2, 3]))) + self.assertTrue(obj._is_array) + self.assertEqual(len(obj), 3) + self.assertEqual(obj[0], 1) + self.assertEqual(obj.serialize(), "[1,2,3]") From 93392837c8858f4653313d323a3f406562f9c94d Mon Sep 17 00:00:00 2001 From: Sakthivel Subramanian Date: Tue, 28 Jul 2026 05:33:35 +0000 Subject: [PATCH 2/3] fix(spanner): implement dict protocol and nested unwrapping for JsonObject --- .../google/cloud/spanner_v1/data_types.py | 25 +++++++++---------- .../tests/unit/test__helpers.py | 5 +++- .../tests/unit/test_datatypes.py | 14 +++++++++-- 3 files changed, 28 insertions(+), 16 deletions(-) diff --git a/packages/google-cloud-spanner/google/cloud/spanner_v1/data_types.py b/packages/google-cloud-spanner/google/cloud/spanner_v1/data_types.py index cac6c412e7f6..27531ae3a557 100644 --- a/packages/google-cloud-spanner/google/cloud/spanner_v1/data_types.py +++ b/packages/google-cloud-spanner/google/cloud/spanner_v1/data_types.py @@ -54,7 +54,9 @@ def __init__(self, *args, **kwargs): elif self._is_scalar_value: self._simple_value = args[0]._simple_value - if not self._is_null: + if self._is_null: + super().__init__({"__json_null__": True}) + else: super().__init__(*args, **kwargs) def __len__(self): @@ -72,7 +74,7 @@ def __bool__(self): if self._is_array: return bool(self._array_value) if self._is_scalar_value: - return True + return bool(self._simple_value) return super().__len__() > 0 def __iter__(self): @@ -106,30 +108,27 @@ def __contains__(self, item): def __eq__(self, other): if isinstance(other, JsonObject): if self._is_array: - return ( - getattr(other, "_is_array", False) - and self._array_value == other._array_value - ) + return other._is_array and self._array_value == other._array_value if self._is_scalar_value: return ( - getattr(other, "_is_scalar_value", False) - and self._simple_value == other._simple_value + other._is_scalar_value and self._simple_value == other._simple_value ) if self._is_null: - return getattr(other, "_is_null", False) + return other._is_null return not ( - getattr(other, "_is_array", False) - or getattr(other, "_is_scalar_value", False) - or getattr(other, "_is_null", False) + other._is_array or other._is_scalar_value or other._is_null ) and super().__eq__(other) if self._is_array: return self._array_value == other if self._is_scalar_value: return self._simple_value == other if self._is_null: - return other is None or (isinstance(other, dict) and len(other) == 0) + return other is None return super().__eq__(other) + def __ne__(self, other): + return not (self == other) + def __repr__(self): if self._is_array: return str(self._array_value) diff --git a/packages/google-cloud-spanner/tests/unit/test__helpers.py b/packages/google-cloud-spanner/tests/unit/test__helpers.py index b98d4a36e85b..0a6e9594b167 100644 --- a/packages/google-cloud-spanner/tests/unit/test__helpers.py +++ b/packages/google-cloud-spanner/tests/unit/test__helpers.py @@ -868,6 +868,7 @@ def test_w_json(self): from google.protobuf.struct_pb2 import Value from google.cloud.spanner_v1 import Type, TypeCode + from google.cloud.spanner_v1.data_types import JsonObject VALUE = {"id": 27863, "Name": "Anamika"} str_repr = json.dumps(VALUE, sort_keys=True, separators=(",", ":")) @@ -884,7 +885,9 @@ def test_w_json(self): field_type = Type(code=TypeCode.JSON) value_pb = Value(string_value=str_repr) - self.assertEqual(self._callFUT(value_pb, field_type, field_name), {}) + self.assertEqual( + self._callFUT(value_pb, field_type, field_name), JsonObject(None) + ) def test_w_unknown_type(self): from google.protobuf.struct_pb2 import Value diff --git a/packages/google-cloud-spanner/tests/unit/test_datatypes.py b/packages/google-cloud-spanner/tests/unit/test_datatypes.py index 1d1933598ab1..b96c42fcbbf8 100644 --- a/packages/google-cloud-spanner/tests/unit/test_datatypes.py +++ b/packages/google-cloud-spanner/tests/unit/test_datatypes.py @@ -132,8 +132,18 @@ def test_scalar_len(self): self.assertEqual(len(obj), 1) def test_scalar_bool(self): - obj = JsonObject(42) - self.assertTrue(obj) + self.assertTrue(JsonObject(42)) + self.assertFalse(JsonObject(False)) + self.assertFalse(JsonObject(0)) + self.assertFalse(JsonObject(0.0)) + self.assertFalse(JsonObject("")) + + def test_null_eq(self): + null_obj = JsonObject(None) + self.assertEqual(null_obj, JsonObject(None)) + self.assertEqual(null_obj, None) + self.assertNotEqual(null_obj, {}) + self.assertNotEqual(null_obj, JsonObject({})) def test_scalar_not_iterable(self): obj = JsonObject(42) From 83c2f80c95f294b5bd5667a3d3f23c8cfa032e51 Mon Sep 17 00:00:00 2001 From: Sakthivel Subramanian Date: Tue, 28 Jul 2026 07:59:50 +0000 Subject: [PATCH 3/3] fix(spanner): fix equality symmetry, array tuple normalization, and dict methods in JsonObject --- .../google/cloud/spanner_v1/data_types.py | 76 +++++++++++++++++-- .../tests/unit/test_datatypes.py | 64 +++++++++++++++- 2 files changed, 133 insertions(+), 7 deletions(-) diff --git a/packages/google-cloud-spanner/google/cloud/spanner_v1/data_types.py b/packages/google-cloud-spanner/google/cloud/spanner_v1/data_types.py index 27531ae3a557..23b583b58a60 100644 --- a/packages/google-cloud-spanner/google/cloud/spanner_v1/data_types.py +++ b/packages/google-cloud-spanner/google/cloud/spanner_v1/data_types.py @@ -31,14 +31,20 @@ class JsonObject(dict): """ def __init__(self, *args, **kwargs): - self._is_null = (args, kwargs) == ((), {}) or args == (None,) - self._is_array = len(args) and isinstance(args[0], (list, tuple)) - self._is_scalar_value = len(args) == 1 and not isinstance(args[0], (list, dict)) + self._is_null = (args, kwargs) == ((), {}) or ( + len(args) == 1 and args[0] is None + ) + self._is_array = len(args) == 1 and isinstance(args[0], (list, tuple)) + self._is_scalar_value = ( + len(args) == 1 + and args[0] is not None + and not isinstance(args[0], (list, dict)) + ) # if the JSON object is represented with an array, # the value is contained separately if self._is_array: - self._array_value = args[0] + self._array_value = list(args[0]) return # If it's a scalar value, set _simple_value and return early @@ -50,7 +56,7 @@ def __init__(self, *args, **kwargs): self._is_array = args[0]._is_array self._is_scalar_value = args[0]._is_scalar_value if self._is_array: - self._array_value = args[0]._array_value + self._array_value = list(args[0]._array_value) elif self._is_scalar_value: self._simple_value = args[0]._simple_value @@ -59,6 +65,62 @@ def __init__(self, *args, **kwargs): else: super().__init__(*args, **kwargs) + def get(self, key, default=None): + if self._is_array: + try: + return self._array_value[key] + except (IndexError, TypeError): + return default + if self._is_scalar_value or self._is_null: + return default + return super().get(key, default) + + def copy(self): + if self._is_array: + return JsonObject(list(self._array_value)) + if self._is_scalar_value: + return JsonObject(self._simple_value) + if self._is_null: + return JsonObject() + return JsonObject(super().copy()) + + def keys(self): + if self._is_array or self._is_scalar_value or self._is_null: + return {}.keys() + return super().keys() + + def values(self): + if self._is_array: + return dict(enumerate(self._array_value)).values() + if self._is_scalar_value: + return {0: self._simple_value}.values() + if self._is_null: + return {}.values() + return super().values() + + def items(self): + if self._is_array: + return dict(enumerate(self._array_value)).items() + if self._is_scalar_value: + return {0: self._simple_value}.items() + if self._is_null: + return {}.items() + return super().items() + + def pop(self, key, *args): + if self._is_array: + try: + return self._array_value.pop(key) + except (IndexError, TypeError): + if args: + return args[0] + raise KeyError(key) from None + if self._is_scalar_value or self._is_null: + if args: + return args[0] + raise KeyError(key) + return super().pop(key, *args) + def __len__(self): if self._is_null: return 0 @@ -78,6 +140,8 @@ def __bool__(self): return super().__len__() > 0 def __iter__(self): + if self._is_null: + return iter([]) if self._is_array: return iter(self._array_value) if self._is_scalar_value: @@ -96,6 +160,8 @@ def __getitem__(self, key): return super().__getitem__(key) def __contains__(self, item): + if self._is_null: + return False if self._is_array: return item in self._array_value if self._is_scalar_value: diff --git a/packages/google-cloud-spanner/tests/unit/test_datatypes.py b/packages/google-cloud-spanner/tests/unit/test_datatypes.py index b96c42fcbbf8..9a81f5659360 100644 --- a/packages/google-cloud-spanner/tests/unit/test_datatypes.py +++ b/packages/google-cloud-spanner/tests/unit/test_datatypes.py @@ -1,6 +1,6 @@ -# Copyright 2024 Google LLC +# Copyright 2024 Google LLC All rights reserved. # -# Licensed under the Apache License, Version 2.0 ( disputes ); +# Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # @@ -12,6 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. + import json import unittest @@ -63,6 +64,65 @@ class Test_JsonObject_dict_protocol(unittest.TestCase): """Verify that JsonObject behaves correctly with standard Python operations (len, bool, iteration, indexing) for all JSON variants.""" + def test_asymmetric_equality(self): + a = JsonObject() + b = JsonObject(None) + self.assertEqual(a == b, b == a, "Equality symmetry is broken!") + self.assertEqual(a, b) + + def test_tuple_vs_list_equality(self): + tup = JsonObject((1, 2)) + lst = JsonObject([1, 2]) + self.assertEqual(tup, lst) + self.assertEqual(tup, [1, 2]) + + def test_get_method(self): + arr = JsonObject([10, 20]) + self.assertEqual(arr.get(0), 10) + self.assertEqual(arr.get(1), 20) + self.assertIsNone(arr.get(2)) + obj = JsonObject({"a": 1}) + self.assertEqual(obj.get("a"), 1) + self.assertIsNone(obj.get("b")) + scalar = JsonObject(42) + self.assertIsNone(scalar.get("a")) + + def test_copy_method(self): + arr = JsonObject([10, 20]) + cp_arr = arr.copy() + self.assertEqual(cp_arr, arr) + self.assertEqual(cp_arr.get(0), 10) + + obj = JsonObject({"a": 1}) + cp_obj = obj.copy() + self.assertEqual(cp_obj, obj) + + scalar = JsonObject(42) + cp_scalar = scalar.copy() + self.assertEqual(cp_scalar, scalar) + + def test_keys_values_items_pop(self): + arr = JsonObject([10, 20]) + self.assertEqual(list(arr.keys()), []) + self.assertEqual(list(arr.values()), [10, 20]) + self.assertEqual(list(arr.items()), [(0, 10), (1, 20)]) + val = arr.pop(0) + self.assertEqual(val, 10) + self.assertEqual(len(arr), 1) + + obj = JsonObject({"a": 1}) + self.assertEqual(list(obj.keys()), ["a"]) + self.assertEqual(list(obj.values()), [1]) + self.assertEqual(list(obj.items()), [("a", 1)]) + + def test_null_sentinel_hiding(self): + null_obj = JsonObject() + self.assertEqual(list(null_obj), []) + self.assertNotIn("__json_null__", null_obj) + null_obj_none = JsonObject(None) + self.assertEqual(list(null_obj_none), []) + self.assertNotIn("__json_null__", null_obj_none) + def test_isinstance_dict(self): obj = JsonObject({"a": 1}) self.assertTrue(isinstance(obj, dict))