Skip to content

Commit 9e6ef79

Browse files
committed
Fix atomic patch application corner cases
1 parent a0e1bbc commit 9e6ef79

3 files changed

Lines changed: 68 additions & 8 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
**Fixes**
66

77
- Fixed lexing of quoted name selectors ending in an escaped backslash, like `$['a\\']['b']`. The lexer's look-behind for the closing quote treated a quote following an escaped backslash (`\\`) as an escaped quote (`\'`), so these selectors failed to tokenize. This also broke the RFC 9535 normalized path round-trip: normalized paths produced for object keys containing backslashes could not be parsed back. See [#132](https://github.com/jg-rp/python-jsonpath/pull/132).
8+
- Fixed atomic JSONPatch application when a patch op replaces the document root.
89

910
**Features**
1011

jsonpath/patch.py

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -694,16 +694,19 @@ def atomic(
694694
JSONPatchTestFailure: When a _test_ operation does not pass.
695695
`JSONPatchTestFailure` is a subclass of `JSONPatchError`.
696696
"""
697-
data_ = copy.deepcopy(data)
698-
self.apply(data_) # This could raise a JSONPatchError.
699-
data.clear()
697+
data_ = self.apply(copy.deepcopy(data)) # This could raise a JSONPatchError.
700698

701-
if isinstance(data, dict):
702-
data.update(data_)
703-
else:
704-
data.extend(data_)
699+
if isinstance(data, dict) and isinstance(data_, dict):
700+
data.clear()
701+
data.update(data_) # type: ignore
702+
return data
705703

706-
return data
704+
if isinstance(data, list) and isinstance(data_, list):
705+
data.clear()
706+
data.extend(data_) # type: ignore
707+
return data
708+
709+
return data_
707710

708711
def patched(
709712
self,

tests/test_json_patch.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -354,6 +354,62 @@ def test_atomic_patch_array_fail() -> None:
354354
assert data == data_
355355

356356

357+
def test_atomically_replace_root() -> None:
358+
ops: List[Dict[str, Any]] = [
359+
{"op": "replace", "path": "", "value": {"foo": "bar"}},
360+
]
361+
362+
data: Dict[str, Any] = {"a": {"b": {"c": 1}}}
363+
patched_data = patch.atomic(ops, data)
364+
365+
# Note that atomic patch application clears and updates `data` when
366+
# the document root is replaced with a compatible object. `patch.apply()`
367+
# does not do this.
368+
assert data == patched_data
369+
assert data == {"foo": "bar"}
370+
371+
372+
def test_atomically_replace_root_with_incompatible_type() -> None:
373+
ops: List[Dict[str, Any]] = [
374+
{"op": "replace", "path": "", "value": [1, 2, 3]},
375+
]
376+
377+
data: Dict[str, Any] = {"a": {"b": {"c": 1}}}
378+
patched_data = patch.atomic(ops, data)
379+
380+
assert data != patched_data
381+
assert data == {"a": {"b": {"c": 1}}}
382+
assert patched_data == [1, 2, 3]
383+
384+
385+
def test_atomically_replace_root_primitive() -> None:
386+
ops: List[Dict[str, Any]] = [
387+
{"op": "replace", "path": "", "value": "foo"},
388+
]
389+
390+
data: Dict[str, Any] = {"a": {"b": {"c": 1}}}
391+
patched_data = patch.atomic(ops, data)
392+
393+
assert data != patched_data
394+
assert data == {"a": {"b": {"c": 1}}}
395+
assert patched_data == "foo"
396+
397+
398+
def test_apply_replace_root() -> None:
399+
ops: List[Dict[str, Any]] = [
400+
{"op": "replace", "path": "", "value": {"foo": "bar"}},
401+
]
402+
403+
data: Dict[str, Any] = {"a": {"b": {"c": 1}}}
404+
patched_data = patch.apply(ops, data)
405+
406+
# Note that patch application can not replace `data` when the document root
407+
# is replaced.
408+
assert data != patched_data
409+
assert data == {"a": {"b": {"c": 1}}}
410+
assert patched_data == {"foo": "bar"}
411+
412+
357413
def test_patched_does_not_mutate_data() -> None:
358414
"""Test that _patched_ modifies a deep copy of data."""
359415
patch_doc: List[Dict[str, Any]] = [

0 commit comments

Comments
 (0)