fix(spanner): implement dict protocol and nested unwrapping for JsonObject - #17915
fix(spanner): implement dict protocol and nested unwrapping for JsonObject#17915sakthivelmanii wants to merge 5 commits into
Conversation
…bject 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
There was a problem hiding this comment.
Code Review
This pull request implements standard Python container protocols (such as length, truthiness, iteration, indexing, and equality) for JsonObject to support array, scalar, and null variants, and introduces a recursive unwrapping helper for serialization. The feedback recommends correcting the truthiness of scalar values to reflect their actual boolean value, simplifying equality checks by removing defensive getattr calls, preventing null objects from equating to empty dictionaries, and expanding unit tests to cover falsy scalars.
89a45f3 to
9339283
Compare
olavloite
left a comment
There was a problem hiding this comment.
Code Review: PR #17915 (fix(spanner): implement dict protocol and nested unwrapping for JsonObject)
Executive Summary
PR #17915 aims to fix issue #15870 by allowing JsonObject (which wraps Spanner JSON types) to support standard Python container protocols (len, bool, iter, __getitem__, __contains__, __eq__) for array, scalar, and null JSON variants, as well as fixing nested JsonObject serialization via _unwrap_for_json.
While the PR makes good progress towards fixing nested serialization and basic index/loop access, the current implementation is incomplete, contains critical equality and initialization bugs, and breaks fundamental Python protocol expectations.
1. Implementation Assessment
Completeness: Incomplete Dict Protocol
JsonObject inherits directly from dict (class JsonObject(dict)). However, the PR only overrides standard magic methods (__len__, __bool__, __iter__, __getitem__, __contains__), leaving core dict methods unhandled for array, scalar, and null variants:
.get(key[, default])is broken:arr = JsonObject([10, 20])allowsarr[0](returns10), butarr.get(0)returnsNone..copy()causes silent data loss: Callingarr.copy()onJsonObject([10, 20])callsdict.copy(), which returns an empty dictionary{}—erasing all array data!.keys(),.values(),.items()diverge fromiter():list(arr)yields[10, 20], butlist(arr.keys())returns[].dict(arr)erases data:dict(JsonObject([10, 20]))returns{}.
Reasonableness: High Architectural Tension
Subclassing dict for an object that can hold a JSON list, int, str, bool, or None creates an architectural mismatch. Because isinstance(obj, dict) evaluates to True for all JsonObject instances:
- Passing
JsonObject([10, 20])to standard Python libraries or built-ins that checkisinstance(val, dict)(e.g., standardjson.dumps(obj)) will result in silent serialization as{}(empty dict) rather than[10, 20].
Efficiency: Good
_unwrap_for_json uses a recursive tree traversal. It is json.dumps.
2. Bugs & Critical Flaws Found
Bug A: Asymmetric Equality (a == b vs b == a)
Symmetry is a strict requirement for Python equality (a == b MUST equal b == a). In PR 17915, comparing JsonObject() and JsonObject(None) violates this:
>>> JsonObject() == JsonObject(None)
True
>>> JsonObject(None) == JsonObject()
False # VIOLATION: Equality symmetry is broken!Root Cause: In JsonObject.__init__:
self._is_scalar_value = len(args) == 1 and not isinstance(args[0], (list, dict))Because isinstance(None, (list, dict)) is False, JsonObject(None) sets _is_scalar_value = True and stores _simple_value = None.
When evaluating JsonObject(None) == JsonObject():
JsonObject(None)checksif self._is_scalar_value:- It returns
other._is_scalar_value and self._simple_value == other._simple_value JsonObject()._is_scalar_valueisFalse, so it returnsFalse!
Bug B: Inconsistent Invocations for JSON null
JsonObject() and JsonObject(None) behave completely differently when operated on:
iter(JsonObject(None))-> RaisesTypeError: 'NoneType' object is not iterable(due to_is_scalar_value = True).iter(JsonObject())-> Returnsiter(["__json_null__"])(leaking internal dict key__json_null__)."__json_null__" in JsonObject()-> ReturnsTrue."__json_null__" in JsonObject(None)-> RaisesTypeError.
Bug C: Tuple vs List Array Inequality
>>> JsonObject((1, 2)) == [1, 2]
False
>>> JsonObject((1, 2)) == JsonObject([1, 2])
FalseSince JsonObject supports initializing arrays with tuples (isinstance(args[0], (list, tuple))), comparing a tuple-backed array to a list-backed array should evaluate to True for JSON semantics.
3. Test Coverage & Missing Tests
While unit tests were added for standard use cases, key edge cases and regression scenarios are missing:
- Equality Symmetry Tests: Missing test assertions verifying
a == bandb == aacross all null/scalar/array combinations. - Dict Method Tests: Missing unit tests verifying
.get(),.copy(),.keys(),.values(), and.items()on non-dict variants. - Third-Party / Dict Interop Tests: Missing tests checking behavior when
dict()orcopy.deepcopy()is called on aJsonObject.
4. Breaking Changes Analysis
Category 1: Enhancements & Fixes ("This did not work -> Now works")
- Array / Scalar Indexing & Iteration: Operations like
len(JsonObject([1, 2])),JsonObject([1, 2])[0],for item in JsonObject([1, 2]), and42 in JsonObject([42])previously raised errors or returned incorrect dict lengths (0). These now work as expected. - Nested Serialization: Serializing
JsonObject({"a": JsonObject([1, 2])})previously produced{"a": {}}(data loss). It now produces{"a": [1, 2]}.
Category 2: Behavioral Changes ("Works differently than before")
- Truthiness & Length of Null Objects:
- Previously,
bool(JsonObject())returnedTrue(because the underlying dict held{"__json_null__": True}). Now,bool(JsonObject())returnsFalse. len(JsonObject())now returns0(previously returned1).- Should we worry about this change? No. Returning
Falseforbool(null)and0forlen(null)aligns with standard Python semantics wherenull/empty values are falsy.
- Previously,
Recommended Fixes for the PR Author
To make this PR robust and safe to merge, the following updates are recommended:
-
Fix Null / Scalar Detection in
__init__:
ExcludeNoneexplicitly from_is_scalar_value:self._is_scalar_value = len(args) == 1 and args[0] is not None and not isinstance(args[0], (list, dict))
-
Normalize Tuple Arrays to Lists:
Convert input tuples to lists during initialization to ensure equality comparisons work consistently:if self._is_array: self._array_value = list(args[0]) return
-
Override
.get()and.copy()onJsonObject: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(None) return JsonObject(super().copy())
| """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: |
There was a problem hiding this comment.
Please undo this change. It does not appear to be related to the actual change, and the new comment does not make sense.
|
|
||
| _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)?" |
There was a problem hiding this comment.
Can we undo these unrelated changes? (unless it would otherwise cause CI failures due to wrong formatting)
There was a problem hiding this comment.
It would cause formatting issues. ruff automatically formatted this.
| @@ -1,6 +1,6 @@ | |||
| # Copyright 2024 Google LLC All rights reserved. | |||
There was a problem hiding this comment.
This also seems unrelated (and wrong?)
| # See the License for the specific language governing permissions and | ||
| # limitations under the License. | ||
|
|
||
|
|
There was a problem hiding this comment.
nit: remove, unless it causes a formatting error
…ict methods in JsonObject
aa9bb61 to
83c2f80
Compare
olavloite
left a comment
There was a problem hiding this comment.
Can we add these unit tests:
import unittest
from google.cloud.spanner_v1.data_types import JsonObject, _unwrap_for_json
class Test_JsonObject_edge_cases_and_invariants(unittest.TestCase):
"""Unit tests catching remaining bugs in JsonObject rewrapping and dict invariants."""
def test_rewrapping_null_object_preserves_null_state(self):
"""Verify that wrapping a null JsonObject preserves _is_null and serialize()."""
# Test default JsonObject() null
null_obj = JsonObject()
rewrapped = JsonObject(null_obj)
self.assertTrue(rewrapped._is_null, "Rewrapped JsonObject lost _is_null=True flag")
self.assertIsNone(rewrapped.serialize(), "Rewrapped JsonObject() should serialize to None")
self.assertFalse(bool(rewrapped), "Rewrapped JsonObject() should be falsy")
self.assertEqual(len(rewrapped), 0, "Rewrapped JsonObject() length should be 0")
# Test JsonObject(None) null
null_none_obj = JsonObject(None)
rewrapped_none = JsonObject(null_none_obj)
self.assertTrue(rewrapped_none._is_null, "Rewrapped JsonObject(None) lost _is_null=True flag")
self.assertIsNone(rewrapped_none.serialize(), "Rewrapped JsonObject(None) should serialize to None")
def test_keys_values_items_dict_invariant_for_arrays(self):
"""Verify that dict.keys(), dict.values(), and dict.items() are mutually consistent for array JsonObjects."""
arr = JsonObject([10, 20])
keys = list(arr.keys())
values = list(arr.values())
items = list(arr.items())
# Standard Python dict invariant: list(keys) MUST match [k for k, v in items]
self.assertEqual(keys, [0, 1], "keys() should return integer indices matching items() keys")
self.assertEqual(values, [10, 20], "values() should return array elements")
self.assertEqual(items, [(0, 10), (1, 20)])
self.assertEqual(keys, [k for k, v in items], "d.keys() must match [k for k, v in d.items()]")
# Verify zip(keys, values) reconstructs items
self.assertEqual(dict(zip(arr.keys(), arr.values())), {0: 10, 1: 20})
def test_nested_rewrapped_null_serialization(self):
"""Verify that nested rewrapped null JsonObjects serialize correctly inside parent containers."""
nested = JsonObject({"a": JsonObject(JsonObject(None))})
self.assertEqual(nested.serialize(), '{"a":null}')
raw_unwrapped = _unwrap_for_json({"a": JsonObject(JsonObject(None))})
self.assertEqual(raw_unwrapped, {"a": None})| self._is_array = args[0]._is_array | ||
| self._is_scalar_value = args[0]._is_scalar_value |
There was a problem hiding this comment.
I think that we need to add an assignement for _is_null as well:
| self._is_array = args[0]._is_array | |
| self._is_scalar_value = args[0]._is_scalar_value | |
| self._is_null = args[0]._is_null | |
| self._is_array = args[0]._is_array | |
| self._is_scalar_value = args[0]._is_scalar_value |
| def keys(self): | ||
| if self._is_array or self._is_scalar_value or self._is_null: | ||
| return {}.keys() | ||
| return super().keys() |
There was a problem hiding this comment.
For an array, the keys should be equal to the indexes of the array:
| def keys(self): | |
| if self._is_array or self._is_scalar_value or self._is_null: | |
| return {}.keys() | |
| return super().keys() | |
| def keys(self): | |
| if self._is_array: | |
| return dict(enumerate(self._array_value)).keys() | |
| if self._is_scalar_value or self._is_null: | |
| return {}.keys() | |
| return super().keys() |
… keys() dict invariant
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