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 3 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
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