diff --git a/conformance/json-patch-vectors.json b/conformance/json-patch-vectors.json index f493a16..e4025cd 100644 --- a/conformance/json-patch-vectors.json +++ b/conformance/json-patch-vectors.json @@ -12,6 +12,8 @@ { "name": "move", "doc": {"a": 1, "b": 2}, "patch": [{"op": "move", "from": "/a", "path": "/c"}], "expected": {"b": 2, "c": 1} }, { "name": "copy", "doc": {"a": {"x": 1}}, "patch": [{"op": "copy", "from": "/a", "path": "/b"}], "expected": {"a": {"x": 1}, "b": {"x": 1}} }, { "name": "test then replace", "doc": {"a": 1}, "patch": [{"op": "test", "path": "/a", "value": 1}, {"op": "replace", "path": "/a", "value": 2}], "expected": {"a": 2} }, + { "name": "test bool true", "doc": {"a": true}, "patch": [{"op": "test", "path": "/a", "value": true}, {"op": "replace", "path": "/a", "value": false}], "expected": {"a": false} }, + { "name": "test nested bool", "doc": {"a": {"b": [true, false]}}, "patch": [{"op": "test", "path": "/a", "value": {"b": [true, false]}}], "expected": {"a": {"b": [true, false]}} }, { "name": "escaped ~1 (slash) key", "doc": {"a/b": 1}, "patch": [{"op": "replace", "path": "/a~1b", "value": 2}], "expected": {"a/b": 2} }, { "name": "escaped ~0 (tilde) key", "doc": {"a~b": 1}, "patch": [{"op": "replace", "path": "/a~0b", "value": 2}], "expected": {"a~b": 2} }, { "name": "nested replace", "doc": {"a": {"b": [0, 0]}}, "patch": [{"op": "replace", "path": "/a/b/1", "value": 9}], "expected": {"a": {"b": [0, 9]}} }, @@ -27,6 +29,11 @@ { "name": "remove array out of range", "doc": {"a": [1]}, "patch": [{"op": "remove", "path": "/a/5"}] }, { "name": "add into missing intermediate", "doc": {"a": {}}, "patch": [{"op": "add", "path": "/a/b/c", "value": 1}] }, { "name": "test mismatch", "doc": {"a": 1}, "patch": [{"op": "test", "path": "/a", "value": 2}] }, + { "name": "test bool true vs int 1", "doc": {"a": 1}, "patch": [{"op": "test", "path": "/a", "value": true}] }, + { "name": "test int 1 vs bool true", "doc": {"a": true}, "patch": [{"op": "test", "path": "/a", "value": 1}] }, + { "name": "test bool false vs int 0", "doc": {"a": 0}, "patch": [{"op": "test", "path": "/a", "value": false}] }, + { "name": "test int 0 vs bool false", "doc": {"a": false}, "patch": [{"op": "test", "path": "/a", "value": 0}] }, + { "name": "test nested bool vs int", "doc": {"a": {"b": 1}}, "patch": [{"op": "test", "path": "/a", "value": {"b": true}}] }, { "name": "move into own child", "doc": {"a": {"b": 1}}, "patch": [{"op": "move", "from": "/a", "path": "/a/c"}] }, { "name": "unknown op", "doc": {"a": 1}, "patch": [{"op": "frobnicate", "path": "/a", "value": 2}] }, { "name": "remove root", "doc": {"a": 1}, "patch": [{"op": "remove", "path": ""}] }, diff --git a/packages/chap-analytics/chap_analytics/_patch.py b/packages/chap-analytics/chap_analytics/_patch.py index 839ad0c..b80d4ce 100644 --- a/packages/chap-analytics/chap_analytics/_patch.py +++ b/packages/chap-analytics/chap_analytics/_patch.py @@ -100,6 +100,29 @@ def _get(doc: Any, path: str) -> Any: return parent[key] +def _json_equal(a: Any, b: Any) -> bool: + """JSON-type-aware deep equality for the RFC 6902 ``test`` operation. + + Python's ``==`` treats ``True == 1`` and ``False == 0`` as equal because + ``bool`` is a subclass of ``int``. JSON distinguishes them: ``true`` is a + boolean, ``1`` is a number. The TypeScript reference's ``deepEqual`` + handles this via ``typeof``; this function is the Python equivalent. + """ + if isinstance(a, bool) != isinstance(b, bool): + return False + if a is None or b is None: + return a is b + if isinstance(a, dict): + if not isinstance(b, dict) or len(a) != len(b): + return False + return all(k in b and _json_equal(a[k], b[k]) for k in a) + if isinstance(a, list): + if not isinstance(b, list) or len(a) != len(b): + return False + return all(_json_equal(x, y) for x, y in zip(a, b)) + return a == b + + def _apply_one(doc: Any, op: dict) -> Any: # noqa: C901 - one branch per RFC 6902 operation kind = op.get("op") path = op.get("path", "") @@ -180,7 +203,7 @@ def _apply_one(doc: Any, op: dict) -> Any: # noqa: C901 - one branch per RFC 69 if kind == "test": if "value" not in op: raise PatchError("'test' requires 'value'") - if _get(doc, path) != op["value"]: + if not _json_equal(_get(doc, path), op["value"]): raise PatchError(f"'test' failed at {path}") return doc diff --git a/packages/coordinator-py/chap_coordinator/patch.py b/packages/coordinator-py/chap_coordinator/patch.py index 11f29bf..cfe9dcf 100644 --- a/packages/coordinator-py/chap_coordinator/patch.py +++ b/packages/coordinator-py/chap_coordinator/patch.py @@ -129,6 +129,29 @@ def _get(doc: Any, path: str) -> Any: return parent[key] +def _json_equal(a: Any, b: Any) -> bool: + """JSON-type-aware deep equality for the RFC 6902 ``test`` operation. + + Python's ``==`` treats ``True == 1`` and ``False == 0`` as equal because + ``bool`` is a subclass of ``int``. JSON distinguishes them: ``true`` is a + boolean, ``1`` is a number. The TypeScript reference's ``deepEqual`` + handles this via ``typeof``; this function is the Python equivalent. + """ + if isinstance(a, bool) != isinstance(b, bool): + return False + if a is None or b is None: + return a is b + if isinstance(a, dict): + if not isinstance(b, dict) or len(a) != len(b): + return False + return all(k in b and _json_equal(a[k], b[k]) for k in a) + if isinstance(a, list): + if not isinstance(b, list) or len(a) != len(b): + return False + return all(_json_equal(x, y) for x, y in zip(a, b)) + return a == b + + def _apply_one(doc: Any, op: dict) -> Any: """Apply a single op in place, returning the document.""" kind = op.get("op") @@ -212,7 +235,7 @@ def _apply_one(doc: Any, op: dict) -> Any: if "value" not in op: raise PatchError("'test' requires 'value'") actual = _get(doc, path) - if actual != op["value"]: + if not _json_equal(actual, op["value"]): raise PatchError(f"'test' failed at {path}") return doc