json-atom 0.3.0__tar.gz → 0.4.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {json_atom-0.3.0 → json_atom-0.4.1}/PKG-INFO +1 -1
- {json_atom-0.3.0 → json_atom-0.4.1}/pyproject.toml +1 -1
- {json_atom-0.3.0 → json_atom-0.4.1}/src/json_atom/_identity.py +52 -18
- {json_atom-0.3.0 → json_atom-0.4.1}/src/json_atom/apply.py +74 -0
- {json_atom-0.3.0 → json_atom-0.4.1}/src/json_atom/invert.py +20 -3
- {json_atom-0.3.0 → json_atom-0.4.1}/src/json_atom/models.py +42 -5
- {json_atom-0.3.0 → json_atom-0.4.1}/src/json_atom/validate.py +39 -2
- {json_atom-0.3.0 → json_atom-0.4.1}/tests/test_diff.py +169 -0
- json_atom-0.4.1/tests/test_move_copy.py +363 -0
- {json_atom-0.3.0 → json_atom-0.4.1}/.github/workflows/ci.yml +0 -0
- {json_atom-0.3.0 → json_atom-0.4.1}/.github/workflows/publish.yml +0 -0
- {json_atom-0.3.0 → json_atom-0.4.1}/.gitignore +0 -0
- {json_atom-0.3.0 → json_atom-0.4.1}/.pre-commit-config.yaml +0 -0
- {json_atom-0.3.0 → json_atom-0.4.1}/CLAUDE.md +0 -0
- {json_atom-0.3.0 → json_atom-0.4.1}/LICENSE +0 -0
- {json_atom-0.3.0 → json_atom-0.4.1}/README.md +0 -0
- {json_atom-0.3.0 → json_atom-0.4.1}/examples/advanced_identity.py +0 -0
- {json_atom-0.3.0 → json_atom-0.4.1}/examples/audit_log.py +0 -0
- {json_atom-0.3.0 → json_atom-0.4.1}/examples/data_sync.py +0 -0
- {json_atom-0.3.0 → json_atom-0.4.1}/examples/index_vs_keyed.py +0 -0
- {json_atom-0.3.0 → json_atom-0.4.1}/examples/keyed_arrays.py +0 -0
- {json_atom-0.3.0 → json_atom-0.4.1}/examples/quick_api_payload.py +0 -0
- {json_atom-0.3.0 → json_atom-0.4.1}/examples/state_transitions.py +0 -0
- {json_atom-0.3.0 → json_atom-0.4.1}/examples/undo_redo.py +0 -0
- {json_atom-0.3.0 → json_atom-0.4.1}/src/json_atom/__init__.py +0 -0
- {json_atom-0.3.0 → json_atom-0.4.1}/src/json_atom/_utils.py +0 -0
- {json_atom-0.3.0 → json_atom-0.4.1}/src/json_atom/compare.py +0 -0
- {json_atom-0.3.0 → json_atom-0.4.1}/src/json_atom/diff.py +0 -0
- {json_atom-0.3.0 → json_atom-0.4.1}/src/json_atom/errors.py +0 -0
- {json_atom-0.3.0 → json_atom-0.4.1}/src/json_atom/json_patch.py +0 -0
- {json_atom-0.3.0 → json_atom-0.4.1}/src/json_atom/path.py +0 -0
- {json_atom-0.3.0 → json_atom-0.4.1}/src/json_atom/py.typed +0 -0
- {json_atom-0.3.0 → json_atom-0.4.1}/tests/__init__.py +0 -0
- {json_atom-0.3.0 → json_atom-0.4.1}/tests/conftest.py +0 -0
- {json_atom-0.3.0 → json_atom-0.4.1}/tests/fixtures/basic-replace.json +0 -0
- {json_atom-0.3.0 → json_atom-0.4.1}/tests/fixtures/keyed-array-update.json +0 -0
- {json_atom-0.3.0 → json_atom-0.4.1}/tests/test_apply.py +0 -0
- {json_atom-0.3.0 → json_atom-0.4.1}/tests/test_compare.py +0 -0
- {json_atom-0.3.0 → json_atom-0.4.1}/tests/test_conformance.py +0 -0
- {json_atom-0.3.0 → json_atom-0.4.1}/tests/test_cross_validation.py +0 -0
- {json_atom-0.3.0 → json_atom-0.4.1}/tests/test_edge_cases.py +0 -0
- {json_atom-0.3.0 → json_atom-0.4.1}/tests/test_extensions.py +0 -0
- {json_atom-0.3.0 → json_atom-0.4.1}/tests/test_invert.py +0 -0
- {json_atom-0.3.0 → json_atom-0.4.1}/tests/test_json_patch.py +0 -0
- {json_atom-0.3.0 → json_atom-0.4.1}/tests/test_models.py +0 -0
- {json_atom-0.3.0 → json_atom-0.4.1}/tests/test_path.py +0 -0
- {json_atom-0.3.0 → json_atom-0.4.1}/tests/test_pydantic.py +0 -0
- {json_atom-0.3.0 → json_atom-0.4.1}/tests/test_utils.py +0 -0
- {json_atom-0.3.0 → json_atom-0.4.1}/tests/test_validate.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: json-atom
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.1
|
|
4
4
|
Summary: Python implementation of the JSON Atom v0 specification
|
|
5
5
|
Project-URL: Homepage, https://github.com/ltwlf/json-atom-py
|
|
6
6
|
Project-URL: Repository, https://github.com/ltwlf/json-atom-py
|
|
@@ -14,6 +14,7 @@ from typing import Any, Literal
|
|
|
14
14
|
|
|
15
15
|
from json_atom._utils import json_equal
|
|
16
16
|
from json_atom.errors import DiffError
|
|
17
|
+
from json_atom.path import _NESTED_PATH_RE
|
|
17
18
|
|
|
18
19
|
|
|
19
20
|
@dataclass(frozen=True, slots=True)
|
|
@@ -23,20 +24,28 @@ class IdentityResolver:
|
|
|
23
24
|
The ``property`` name appears in filter paths: ``[?(@.{property}==...)]``.
|
|
24
25
|
The ``resolve`` callable extracts the identity value from an element.
|
|
25
26
|
|
|
27
|
+
Supports nested dot paths like ``positionNumber.value`` which resolves
|
|
28
|
+
via ``elem["positionNumber"]["value"]``. When a custom resolver is
|
|
29
|
+
provided, the stored value is validated using the same dot-path
|
|
30
|
+
semantics for ``property``.
|
|
31
|
+
|
|
26
32
|
.. important::
|
|
27
33
|
|
|
28
34
|
The value returned by ``resolve`` **must** match the value actually
|
|
29
|
-
stored
|
|
30
|
-
keyed-array filters by
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
match during ``apply_delta`` / ``resolve_path``.
|
|
35
|
+
stored at the ``property`` path on the element. JSON Atom applies
|
|
36
|
+
keyed-array filters by resolving the property path, so if the
|
|
37
|
+
resolver returns a value that doesn't match the stored value, the
|
|
38
|
+
generated delta paths will not match during ``apply_delta``.
|
|
34
39
|
|
|
35
40
|
Example::
|
|
36
41
|
|
|
37
42
|
# Simple key: use the ``id`` field directly
|
|
38
43
|
IdentityResolver("id", lambda e: e["id"])
|
|
39
44
|
|
|
45
|
+
# Nested key: use a dot-separated path
|
|
46
|
+
IdentityResolver("positionNumber.value",
|
|
47
|
+
lambda e: e["positionNumber"]["value"])
|
|
48
|
+
|
|
40
49
|
# Validation: coerce to int, ensuring consistency
|
|
41
50
|
# (only safe when the stored value round-trips through int)
|
|
42
51
|
IdentityResolver("id", lambda e: int(e["id"]))
|
|
@@ -125,6 +134,28 @@ def _normalize_identity_value(value: IdentityKey) -> _ResolvedIdentity:
|
|
|
125
134
|
)
|
|
126
135
|
|
|
127
136
|
|
|
137
|
+
_SENTINEL = object()
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _resolve_nested(elem: Any, key_property: str) -> Any:
|
|
141
|
+
"""Resolve a possibly-nested dot path on a dict element.
|
|
142
|
+
|
|
143
|
+
Returns ``_SENTINEL`` if any segment is missing.
|
|
144
|
+
For non-nested keys, falls back to a simple dict lookup.
|
|
145
|
+
"""
|
|
146
|
+
if not isinstance(elem, dict):
|
|
147
|
+
return _SENTINEL
|
|
148
|
+
is_nested = "." in key_property and _NESTED_PATH_RE.match(key_property) is not None
|
|
149
|
+
if not is_nested:
|
|
150
|
+
return elem.get(key_property, _SENTINEL)
|
|
151
|
+
current: Any = elem
|
|
152
|
+
for seg in key_property.split("."):
|
|
153
|
+
if not isinstance(current, dict) or seg not in current:
|
|
154
|
+
return _SENTINEL
|
|
155
|
+
current = current[seg]
|
|
156
|
+
return current
|
|
157
|
+
|
|
158
|
+
|
|
128
159
|
def extract_identity(
|
|
129
160
|
elem: Any,
|
|
130
161
|
key_property: str,
|
|
@@ -133,8 +164,11 @@ def extract_identity(
|
|
|
133
164
|
"""Extract the identity value from an array element.
|
|
134
165
|
|
|
135
166
|
Uses the custom resolver if provided, otherwise reads the key property
|
|
136
|
-
directly from the element dict.
|
|
137
|
-
|
|
167
|
+
directly from the element dict. Supports nested dot paths like
|
|
168
|
+
``positionNumber.value`` → ``elem["positionNumber"]["value"]``.
|
|
169
|
+
|
|
170
|
+
The returned value must be a JSON scalar (str, int, float, bool, or None)
|
|
171
|
+
suitable for use in filter paths.
|
|
138
172
|
|
|
139
173
|
Raises:
|
|
140
174
|
DiffError: If the element is missing the key property or the resolver
|
|
@@ -147,25 +181,25 @@ def extract_identity(
|
|
|
147
181
|
raise DiffError(
|
|
148
182
|
f"Identity resolver for '{key_property}' failed on element {elem!r}: {exc}"
|
|
149
183
|
) from exc
|
|
150
|
-
# Verify the element
|
|
151
|
-
#
|
|
152
|
-
#
|
|
153
|
-
|
|
154
|
-
if
|
|
184
|
+
# Verify the element has the key property and that the stored value
|
|
185
|
+
# matches the resolved value — apply_delta matches filter paths via
|
|
186
|
+
# the key property, so a mismatch would produce unapplyable deltas.
|
|
187
|
+
stored = _resolve_nested(elem, key_property)
|
|
188
|
+
if stored is _SENTINEL:
|
|
155
189
|
raise DiffError(
|
|
156
190
|
f"Resolver for '{key_property}' returned {value!r} but element "
|
|
157
|
-
f"is
|
|
191
|
+
f"is missing identity path '{key_property}': {elem!r}"
|
|
158
192
|
)
|
|
159
|
-
stored = elem[key_property]
|
|
160
193
|
if not json_equal(stored, value):
|
|
161
194
|
raise DiffError(
|
|
162
195
|
f"Resolver for '{key_property}' returned {value!r} but "
|
|
163
|
-
f"
|
|
196
|
+
f"stored value at '{key_property}' is {stored!r} — filter path would not match"
|
|
164
197
|
)
|
|
165
|
-
elif not isinstance(elem, dict) or key_property not in elem:
|
|
166
|
-
raise DiffError(f"Array element missing identity key '{key_property}': {elem!r}")
|
|
167
198
|
else:
|
|
168
|
-
|
|
199
|
+
stored = _resolve_nested(elem, key_property)
|
|
200
|
+
if stored is _SENTINEL:
|
|
201
|
+
raise DiffError(f"Array element missing identity key '{key_property}': {elem!r}")
|
|
202
|
+
value = stored
|
|
169
203
|
|
|
170
204
|
if not isinstance(value, (str, int, float, bool, type(None))):
|
|
171
205
|
raise DiffError(
|
|
@@ -50,6 +50,13 @@ def apply_delta(obj: Any, delta: Delta) -> Any:
|
|
|
50
50
|
def _apply_operation(obj: Any, op: Operation) -> Any:
|
|
51
51
|
"""Apply a single operation and return the (possibly new) root object."""
|
|
52
52
|
op_type = op["op"]
|
|
53
|
+
|
|
54
|
+
# Move/copy: two-path operations handled separately
|
|
55
|
+
if op_type == "move":
|
|
56
|
+
return _apply_move(obj, op)
|
|
57
|
+
if op_type == "copy":
|
|
58
|
+
return _apply_copy(obj, op)
|
|
59
|
+
|
|
53
60
|
path_str = op["path"]
|
|
54
61
|
segments = parse_path(path_str)
|
|
55
62
|
|
|
@@ -74,6 +81,73 @@ def _apply_operation(obj: Any, op: Operation) -> Any:
|
|
|
74
81
|
return obj
|
|
75
82
|
|
|
76
83
|
|
|
84
|
+
# ---------------------------------------------------------------------------
|
|
85
|
+
# Move / Copy operations (spec Section 6.6, 6.7)
|
|
86
|
+
# ---------------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _read_value_at_path(obj: Any, path_str: str) -> Any:
|
|
90
|
+
"""Resolve a JSON Atom path to its value in the document."""
|
|
91
|
+
segments = parse_path(path_str)
|
|
92
|
+
if not segments:
|
|
93
|
+
return obj # root
|
|
94
|
+
current = obj
|
|
95
|
+
for seg in segments:
|
|
96
|
+
if isinstance(seg, PropertySegment):
|
|
97
|
+
if not isinstance(current, dict) or seg.name not in current:
|
|
98
|
+
raise ApplyError(f"Property '{seg.name}' not found: {path_str}")
|
|
99
|
+
current = current[seg.name]
|
|
100
|
+
elif isinstance(seg, IndexSegment):
|
|
101
|
+
if not isinstance(current, list) or seg.index >= len(current):
|
|
102
|
+
raise ApplyError(f"Index {seg.index} out of bounds: {path_str}")
|
|
103
|
+
current = current[seg.index]
|
|
104
|
+
elif isinstance(seg, KeyFilterSegment):
|
|
105
|
+
if not isinstance(current, list):
|
|
106
|
+
raise ApplyError(f"Cannot apply key filter on {type(current).__name__}: {path_str}")
|
|
107
|
+
idx = _find_key_filter_match(current, seg, path_str)
|
|
108
|
+
current = current[idx]
|
|
109
|
+
elif isinstance(seg, ValueFilterSegment):
|
|
110
|
+
if not isinstance(current, list):
|
|
111
|
+
raise ApplyError(f"Cannot apply value filter on {type(current).__name__}: {path_str}")
|
|
112
|
+
idx = _find_value_filter_match(current, seg, path_str)
|
|
113
|
+
current = current[idx]
|
|
114
|
+
return current
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _apply_move(obj: Any, op: Operation) -> Any:
|
|
118
|
+
"""Apply a move operation: read from source, remove, add at target."""
|
|
119
|
+
from_path = op["from"]
|
|
120
|
+
to_path = op["path"]
|
|
121
|
+
|
|
122
|
+
# Read value at source
|
|
123
|
+
value = _read_value_at_path(obj, from_path)
|
|
124
|
+
|
|
125
|
+
# Remove from source
|
|
126
|
+
remove_op = Operation(op="remove", path=from_path)
|
|
127
|
+
obj = _apply_operation(obj, remove_op)
|
|
128
|
+
|
|
129
|
+
# Add to target — no deepcopy needed, value was removed from source
|
|
130
|
+
add_op = Operation(op="add", path=to_path, value=value)
|
|
131
|
+
obj = _apply_operation(obj, add_op)
|
|
132
|
+
|
|
133
|
+
return obj
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _apply_copy(obj: Any, op: Operation) -> Any:
|
|
137
|
+
"""Apply a copy operation: read from source, deep-clone to target."""
|
|
138
|
+
from_path = op["from"]
|
|
139
|
+
to_path = op["path"]
|
|
140
|
+
|
|
141
|
+
# Read value at source — add operation handles deep-cloning
|
|
142
|
+
value = _read_value_at_path(obj, from_path)
|
|
143
|
+
|
|
144
|
+
# Add to target
|
|
145
|
+
add_op = Operation(op="add", path=to_path, value=value)
|
|
146
|
+
obj = _apply_operation(obj, add_op)
|
|
147
|
+
|
|
148
|
+
return obj
|
|
149
|
+
|
|
150
|
+
|
|
77
151
|
# ---------------------------------------------------------------------------
|
|
78
152
|
# Root operations (spec Section 6.5)
|
|
79
153
|
# ---------------------------------------------------------------------------
|
|
@@ -18,7 +18,8 @@ def invert_delta(delta: Delta) -> Delta:
|
|
|
18
18
|
|
|
19
19
|
The inverse delta, when applied to the target document, recovers the source.
|
|
20
20
|
|
|
21
|
-
Requires all ``replace`` and ``remove`` operations to have ``oldValue
|
|
21
|
+
Requires all ``replace`` and ``remove`` operations to have ``oldValue``,
|
|
22
|
+
and all ``copy`` operations to have ``value``.
|
|
22
23
|
Preserves extension properties at both envelope and operation levels.
|
|
23
24
|
|
|
24
25
|
Raises InvertError if the delta is not reversible or structurally invalid.
|
|
@@ -30,11 +31,13 @@ def invert_delta(delta: Delta) -> Delta:
|
|
|
30
31
|
|
|
31
32
|
operations = delta["operations"]
|
|
32
33
|
|
|
33
|
-
# Check reversibility:
|
|
34
|
+
# Check reversibility: replace/remove need oldValue, copy needs value
|
|
34
35
|
for i, op in enumerate(operations):
|
|
35
36
|
op_type = op["op"]
|
|
36
37
|
if op_type in ("replace", "remove") and "oldValue" not in op:
|
|
37
38
|
raise InvertError(f"operations[{i}]: '{op_type}' operation missing 'oldValue' (required for inversion)")
|
|
39
|
+
if op_type == "copy" and "value" not in op:
|
|
40
|
+
raise InvertError(f"operations[{i}]: 'copy' operation missing 'value' (required for inversion)")
|
|
38
41
|
|
|
39
42
|
# Build inverted operations in reverse order (spec Section 9.2)
|
|
40
43
|
inverted_ops: list[Operation] = []
|
|
@@ -60,6 +63,8 @@ def _invert_operation(op: Operation) -> Operation:
|
|
|
60
63
|
add(path, value) -> remove(path, oldValue=value)
|
|
61
64
|
remove(path, oldValue) -> add(path, value=oldValue)
|
|
62
65
|
replace(path, value, oldValue) -> replace(path, value=oldValue, oldValue=value)
|
|
66
|
+
move(from, path) -> move(from=path, path=from)
|
|
67
|
+
copy(from, path, value) -> remove(path, oldValue=value)
|
|
63
68
|
"""
|
|
64
69
|
op_type = op["op"]
|
|
65
70
|
inverted: dict[str, Any] = {}
|
|
@@ -86,6 +91,16 @@ def _invert_operation(op: Operation) -> Operation:
|
|
|
86
91
|
inverted["value"] = op["oldValue"]
|
|
87
92
|
inverted["oldValue"] = op["value"]
|
|
88
93
|
|
|
94
|
+
elif op_type == "move":
|
|
95
|
+
# move(from, path) -> move(from=path, path=from)
|
|
96
|
+
inverted["from"] = op["path"]
|
|
97
|
+
inverted["path"] = op["from"]
|
|
98
|
+
|
|
99
|
+
elif op_type == "copy":
|
|
100
|
+
# copy(from, path, value) -> remove(path, oldValue=value)
|
|
101
|
+
inverted["op"] = "remove"
|
|
102
|
+
inverted["oldValue"] = op["value"]
|
|
103
|
+
|
|
89
104
|
return Operation(inverted)
|
|
90
105
|
|
|
91
106
|
|
|
@@ -95,7 +110,9 @@ def _inverted_op_type(op_type: str) -> str:
|
|
|
95
110
|
return "remove"
|
|
96
111
|
if op_type == "remove":
|
|
97
112
|
return "add"
|
|
98
|
-
|
|
113
|
+
if op_type == "copy":
|
|
114
|
+
return "remove"
|
|
115
|
+
return op_type # replace and move stay the same
|
|
99
116
|
|
|
100
117
|
|
|
101
118
|
def revert_delta(obj: Any, delta: Delta) -> Any:
|
|
@@ -209,10 +209,10 @@ class ComparisonNode:
|
|
|
209
209
|
# ---------------------------------------------------------------------------
|
|
210
210
|
|
|
211
211
|
# Fields defined by the spec — everything else is an extension (Section 11)
|
|
212
|
-
_OP_SPEC_KEYS = frozenset({"op", "path", "value", "oldValue"})
|
|
212
|
+
_OP_SPEC_KEYS = frozenset({"op", "path", "from", "value", "oldValue"})
|
|
213
213
|
_DELTA_SPEC_KEYS = frozenset({"format", "version", "operations"})
|
|
214
214
|
|
|
215
|
-
type OpType = Literal["add", "remove", "replace"]
|
|
215
|
+
type OpType = Literal["add", "remove", "replace", "move", "copy"]
|
|
216
216
|
|
|
217
217
|
|
|
218
218
|
class Operation(dict[str, Any]):
|
|
@@ -276,9 +276,44 @@ class Operation(dict[str, Any]):
|
|
|
276
276
|
op["oldValue"] = old_value
|
|
277
277
|
return op
|
|
278
278
|
|
|
279
|
+
_MISSING: Any = object()
|
|
280
|
+
|
|
281
|
+
@classmethod
|
|
282
|
+
def move(cls, from_path: str, path: str, **extensions: Any) -> Operation:
|
|
283
|
+
"""Create a ``move`` operation.
|
|
284
|
+
|
|
285
|
+
Args:
|
|
286
|
+
from_path: Source JSON Atom path.
|
|
287
|
+
path: Target JSON Atom path.
|
|
288
|
+
**extensions: Extension properties (``x_*`` keys).
|
|
289
|
+
"""
|
|
290
|
+
return cls(op="move", path=path, **{"from": from_path, **extensions})
|
|
291
|
+
|
|
292
|
+
@classmethod
|
|
293
|
+
def copy_op(cls, from_path: str, path: str, *, value: Any = _MISSING, **extensions: Any) -> Operation:
|
|
294
|
+
"""Create a ``copy`` operation.
|
|
295
|
+
|
|
296
|
+
Named ``copy_op`` to avoid conflict with ``dict.copy()``.
|
|
297
|
+
|
|
298
|
+
Args:
|
|
299
|
+
from_path: Source JSON Atom path.
|
|
300
|
+
path: Target JSON Atom path.
|
|
301
|
+
value: The copied value (optional, for reversibility).
|
|
302
|
+
**extensions: Extension properties (``x_*`` keys).
|
|
303
|
+
"""
|
|
304
|
+
op = cls(op="copy", path=path, **{"from": from_path, **extensions})
|
|
305
|
+
if value is not cls._MISSING:
|
|
306
|
+
op["value"] = value
|
|
307
|
+
return op
|
|
308
|
+
|
|
309
|
+
@property
|
|
310
|
+
def from_path(self) -> str | None:
|
|
311
|
+
"""The source path for ``move`` and ``copy`` operations."""
|
|
312
|
+
return self.get("from")
|
|
313
|
+
|
|
279
314
|
@property
|
|
280
315
|
def op(self) -> OpType:
|
|
281
|
-
"""The operation type
|
|
316
|
+
"""The operation type."""
|
|
282
317
|
return self["op"] # type: ignore[no-any-return]
|
|
283
318
|
|
|
284
319
|
@property
|
|
@@ -588,8 +623,10 @@ class Delta(dict[str, Any]):
|
|
|
588
623
|
|
|
589
624
|
@property
|
|
590
625
|
def is_reversible(self) -> bool:
|
|
591
|
-
"""``True`` if all ``replace`` and ``
|
|
592
|
-
return all(
|
|
626
|
+
"""``True`` if all ``replace``/``remove`` include ``oldValue`` and all ``copy`` include ``value``."""
|
|
627
|
+
return all(
|
|
628
|
+
"oldValue" in op for op in self.operations if op.op in ("replace", "remove")
|
|
629
|
+
) and all("value" in op for op in self.operations if op.op == "copy")
|
|
593
630
|
|
|
594
631
|
@property
|
|
595
632
|
def is_empty(self) -> bool:
|
|
@@ -13,7 +13,7 @@ from json_atom.models import ValidationResult
|
|
|
13
13
|
_ENVELOPE_REQUIRED = {"format", "version", "operations"}
|
|
14
14
|
|
|
15
15
|
# Known operation types
|
|
16
|
-
_VALID_OPS = {"add", "remove", "replace"}
|
|
16
|
+
_VALID_OPS = {"add", "remove", "replace", "move", "copy"}
|
|
17
17
|
|
|
18
18
|
|
|
19
19
|
def validate_delta(delta: Any) -> ValidationResult:
|
|
@@ -69,7 +69,7 @@ def _validate_operation(op: Any, index: int, errors: list[str]) -> None:
|
|
|
69
69
|
if "op" not in op:
|
|
70
70
|
errors.append(f"{prefix}: missing required field 'op'")
|
|
71
71
|
elif op["op"] not in _VALID_OPS:
|
|
72
|
-
errors.append(f"{prefix}: invalid op {op['op']!r}, must be one of:
|
|
72
|
+
errors.append(f"{prefix}: invalid op {op['op']!r}, must be one of: {', '.join(sorted(_VALID_OPS))}")
|
|
73
73
|
|
|
74
74
|
# Required: path
|
|
75
75
|
if "path" not in op:
|
|
@@ -95,3 +95,40 @@ def _validate_operation(op: Any, index: int, errors: list[str]) -> None:
|
|
|
95
95
|
if "value" not in op:
|
|
96
96
|
errors.append(f"{prefix}: 'replace' operation requires 'value'")
|
|
97
97
|
# Note: oldValue is OPTIONAL on replace per spec
|
|
98
|
+
|
|
99
|
+
elif op_type == "move":
|
|
100
|
+
if "from" not in op:
|
|
101
|
+
errors.append(f"{prefix}: 'move' operation requires 'from'")
|
|
102
|
+
elif not isinstance(op["from"], str):
|
|
103
|
+
errors.append(f"{prefix}: 'from' must be a string")
|
|
104
|
+
if "value" in op:
|
|
105
|
+
errors.append(f"{prefix}: 'move' operation must not include 'value'")
|
|
106
|
+
if "oldValue" in op:
|
|
107
|
+
errors.append(f"{prefix}: 'move' operation must not include 'oldValue'")
|
|
108
|
+
# Self-move prevention
|
|
109
|
+
from_path = op.get("from")
|
|
110
|
+
to_path = op.get("path")
|
|
111
|
+
if isinstance(from_path, str) and isinstance(to_path, str):
|
|
112
|
+
if from_path == to_path:
|
|
113
|
+
errors.append(f"{prefix}: 'move' 'from' must differ from 'path'")
|
|
114
|
+
elif _is_path_prefix(from_path, to_path):
|
|
115
|
+
errors.append(f"{prefix}: 'move' cannot move a value into its own subtree")
|
|
116
|
+
|
|
117
|
+
elif op_type == "copy":
|
|
118
|
+
if "from" not in op:
|
|
119
|
+
errors.append(f"{prefix}: 'copy' operation requires 'from'")
|
|
120
|
+
elif not isinstance(op["from"], str):
|
|
121
|
+
errors.append(f"{prefix}: 'from' must be a string")
|
|
122
|
+
if "oldValue" in op:
|
|
123
|
+
errors.append(f"{prefix}: 'copy' operation must not include 'oldValue'")
|
|
124
|
+
# Note: value is OPTIONAL on copy (for reversibility)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _is_path_prefix(from_path: str, target_path: str) -> bool:
|
|
128
|
+
"""Check if target_path is a descendant of from_path.
|
|
129
|
+
|
|
130
|
+
Root path ('$') is excluded — moving from root to a descendant is valid.
|
|
131
|
+
"""
|
|
132
|
+
if from_path == "$":
|
|
133
|
+
return False
|
|
134
|
+
return target_path.startswith(from_path + ".") or target_path.startswith(from_path + "[")
|
|
@@ -595,6 +595,175 @@ class TestCallableIdentityKeys:
|
|
|
595
595
|
)
|
|
596
596
|
|
|
597
597
|
|
|
598
|
+
class TestNestedPathIdentityKeys:
|
|
599
|
+
"""Nested dot-path identity keys (e.g. positionNumber.value → @.positionNumber.value)."""
|
|
600
|
+
|
|
601
|
+
def test_nested_path_string_key(self) -> None:
|
|
602
|
+
"""String key with dots resolves nested path."""
|
|
603
|
+
old = {"items": [
|
|
604
|
+
{"positionNumber": {"value": "001"}, "description": "alpha"},
|
|
605
|
+
{"positionNumber": {"value": "002"}, "description": "beta"},
|
|
606
|
+
]}
|
|
607
|
+
new = {"items": [
|
|
608
|
+
{"positionNumber": {"value": "001"}, "description": "alpha"},
|
|
609
|
+
]}
|
|
610
|
+
delta = diff_delta(old, new, array_identity_keys={"items": "positionNumber.value"})
|
|
611
|
+
remove_ops = [op for op in delta.operations if op.op == "remove"]
|
|
612
|
+
assert len(remove_ops) == 1
|
|
613
|
+
assert remove_ops[0].path == "$.items[?(@.positionNumber.value=='002')]"
|
|
614
|
+
|
|
615
|
+
def test_nested_path_resolver(self) -> None:
|
|
616
|
+
"""IdentityResolver with nested path produces dot notation."""
|
|
617
|
+
old = {"items": [
|
|
618
|
+
{"positionNumber": {"value": "001"}, "description": "alpha"},
|
|
619
|
+
{"positionNumber": {"value": "002"}, "description": "beta"},
|
|
620
|
+
]}
|
|
621
|
+
new = {"items": [
|
|
622
|
+
{"positionNumber": {"value": "001"}, "description": "alpha"},
|
|
623
|
+
{"positionNumber": {"value": "003"}, "description": "gamma"},
|
|
624
|
+
]}
|
|
625
|
+
delta = diff_delta(old, new, array_identity_keys={
|
|
626
|
+
"items": IdentityResolver(
|
|
627
|
+
"positionNumber.value",
|
|
628
|
+
lambda e: e["positionNumber"]["value"],
|
|
629
|
+
),
|
|
630
|
+
})
|
|
631
|
+
assert len(delta.operations) == 2
|
|
632
|
+
removes = [op for op in delta.operations if op.op == "remove"]
|
|
633
|
+
adds = [op for op in delta.operations if op.op == "add"]
|
|
634
|
+
assert len(removes) == 1
|
|
635
|
+
assert removes[0].path == "$.items[?(@.positionNumber.value=='002')]"
|
|
636
|
+
assert len(adds) == 1
|
|
637
|
+
assert adds[0].path == "$.items[?(@.positionNumber.value=='003')]"
|
|
638
|
+
|
|
639
|
+
def test_nested_path_update_within_element(self) -> None:
|
|
640
|
+
"""Update a property inside an element with nested identity path."""
|
|
641
|
+
old = {"items": [
|
|
642
|
+
{"positionNumber": {"value": "001"}, "description": "alpha"},
|
|
643
|
+
{"positionNumber": {"value": "002"}, "description": "beta"},
|
|
644
|
+
]}
|
|
645
|
+
new = {"items": [
|
|
646
|
+
{"positionNumber": {"value": "001"}, "description": "alpha"},
|
|
647
|
+
{"positionNumber": {"value": "002"}, "description": "updated"},
|
|
648
|
+
]}
|
|
649
|
+
delta = diff_delta(old, new, array_identity_keys={
|
|
650
|
+
"items": "positionNumber.value",
|
|
651
|
+
})
|
|
652
|
+
assert len(delta.operations) == 1
|
|
653
|
+
assert delta.operations[0].op == "replace"
|
|
654
|
+
assert delta.operations[0].path == "$.items[?(@.positionNumber.value=='002')].description"
|
|
655
|
+
assert delta.operations[0]["value"] == "updated"
|
|
656
|
+
|
|
657
|
+
def test_nested_path_roundtrip(self) -> None:
|
|
658
|
+
"""Nested path identity produces deltas that round-trip."""
|
|
659
|
+
old = {"items": [
|
|
660
|
+
{"positionNumber": {"value": "001"}, "description": "alpha"},
|
|
661
|
+
{"positionNumber": {"value": "002"}, "description": "beta"},
|
|
662
|
+
]}
|
|
663
|
+
new = {"items": [
|
|
664
|
+
{"positionNumber": {"value": "001"}, "description": "alpha"},
|
|
665
|
+
{"positionNumber": {"value": "003"}, "description": "gamma"},
|
|
666
|
+
]}
|
|
667
|
+
delta = diff_delta(old, new, array_identity_keys={
|
|
668
|
+
"items": "positionNumber.value",
|
|
669
|
+
})
|
|
670
|
+
result = apply_delta(deep_clone(old), delta)
|
|
671
|
+
assert result == new
|
|
672
|
+
|
|
673
|
+
def test_nested_path_invert_roundtrip(self) -> None:
|
|
674
|
+
"""Invert + apply round-trip with nested identity paths."""
|
|
675
|
+
old = {"items": [
|
|
676
|
+
{"positionNumber": {"value": "001"}, "description": "alpha"},
|
|
677
|
+
{"positionNumber": {"value": "002"}, "description": "beta"},
|
|
678
|
+
]}
|
|
679
|
+
new = {"items": [
|
|
680
|
+
{"positionNumber": {"value": "001"}, "description": "alpha"},
|
|
681
|
+
{"positionNumber": {"value": "002"}, "description": "updated"},
|
|
682
|
+
]}
|
|
683
|
+
delta = diff_delta(old, new, array_identity_keys={
|
|
684
|
+
"items": "positionNumber.value",
|
|
685
|
+
})
|
|
686
|
+
applied = apply_delta(deep_clone(old), delta)
|
|
687
|
+
assert applied == new
|
|
688
|
+
inverted = invert_delta(delta)
|
|
689
|
+
reverted = apply_delta(deep_clone(applied), inverted)
|
|
690
|
+
assert reverted == old
|
|
691
|
+
|
|
692
|
+
def test_nested_path_3_levels(self) -> None:
|
|
693
|
+
"""3-level nested identity path (a.b.c)."""
|
|
694
|
+
old = {"items": [
|
|
695
|
+
{"meta": {"org": {"id": "X"}}, "val": 10},
|
|
696
|
+
{"meta": {"org": {"id": "Y"}}, "val": 20},
|
|
697
|
+
]}
|
|
698
|
+
new = {"items": [
|
|
699
|
+
{"meta": {"org": {"id": "X"}}, "val": 10},
|
|
700
|
+
{"meta": {"org": {"id": "Y"}}, "val": 30},
|
|
701
|
+
]}
|
|
702
|
+
delta = diff_delta(old, new, array_identity_keys={
|
|
703
|
+
"items": IdentityResolver("meta.org.id", lambda e: e["meta"]["org"]["id"]),
|
|
704
|
+
})
|
|
705
|
+
assert len(delta.operations) == 1
|
|
706
|
+
assert delta.operations[0].path == "$.items[?(@.meta.org.id=='Y')].val"
|
|
707
|
+
result = apply_delta(deep_clone(old), delta)
|
|
708
|
+
assert result == new
|
|
709
|
+
reverted = apply_delta(deep_clone(result), invert_delta(delta))
|
|
710
|
+
assert reverted == old
|
|
711
|
+
|
|
712
|
+
def test_nested_path_numeric_value(self) -> None:
|
|
713
|
+
"""Nested identity path with numeric value."""
|
|
714
|
+
old = {"items": [
|
|
715
|
+
{"code": {"num": 1}, "name": "Widget"},
|
|
716
|
+
{"code": {"num": 2}, "name": "Gadget"},
|
|
717
|
+
]}
|
|
718
|
+
new = {"items": [
|
|
719
|
+
{"code": {"num": 1}, "name": "Widget"},
|
|
720
|
+
{"code": {"num": 2}, "name": "Updated"},
|
|
721
|
+
]}
|
|
722
|
+
delta = diff_delta(old, new, array_identity_keys={
|
|
723
|
+
"items": IdentityResolver("code.num", lambda e: e["code"]["num"]),
|
|
724
|
+
})
|
|
725
|
+
assert len(delta.operations) == 1
|
|
726
|
+
assert delta.operations[0].path == "$.items[?(@.code.num==2)].name"
|
|
727
|
+
result = apply_delta(deep_clone(old), delta)
|
|
728
|
+
assert result == new
|
|
729
|
+
|
|
730
|
+
def test_nested_path_boolean_value(self) -> None:
|
|
731
|
+
"""Nested identity path with boolean value."""
|
|
732
|
+
old = {"items": [
|
|
733
|
+
{"config": {"active": True}, "label": "on"},
|
|
734
|
+
{"config": {"active": False}, "label": "off"},
|
|
735
|
+
]}
|
|
736
|
+
new = {"items": [
|
|
737
|
+
{"config": {"active": True}, "label": "on"},
|
|
738
|
+
{"config": {"active": False}, "label": "disabled"},
|
|
739
|
+
]}
|
|
740
|
+
delta = diff_delta(old, new, array_identity_keys={
|
|
741
|
+
"items": IdentityResolver("config.active", lambda e: e["config"]["active"]),
|
|
742
|
+
})
|
|
743
|
+
assert len(delta.operations) == 1
|
|
744
|
+
assert delta.operations[0].path == "$.items[?(@.config.active==false)].label"
|
|
745
|
+
result = apply_delta(deep_clone(old), delta)
|
|
746
|
+
assert result == new
|
|
747
|
+
|
|
748
|
+
def test_nested_path_null_value(self) -> None:
|
|
749
|
+
"""Nested identity path with null value."""
|
|
750
|
+
old = {"items": [
|
|
751
|
+
{"status": {"code": None}, "label": "pending"},
|
|
752
|
+
{"status": {"code": "OK"}, "label": "done"},
|
|
753
|
+
]}
|
|
754
|
+
new = {"items": [
|
|
755
|
+
{"status": {"code": None}, "label": "waiting"},
|
|
756
|
+
{"status": {"code": "OK"}, "label": "done"},
|
|
757
|
+
]}
|
|
758
|
+
delta = diff_delta(old, new, array_identity_keys={
|
|
759
|
+
"items": IdentityResolver("status.code", lambda e: e["status"]["code"]),
|
|
760
|
+
})
|
|
761
|
+
assert len(delta.operations) == 1
|
|
762
|
+
assert delta.operations[0].path == "$.items[?(@.status.code==null)].label"
|
|
763
|
+
result = apply_delta(deep_clone(old), delta)
|
|
764
|
+
assert result == new
|
|
765
|
+
|
|
766
|
+
|
|
598
767
|
# ---------------------------------------------------------------------------
|
|
599
768
|
# Regex-based array key routing
|
|
600
769
|
# ---------------------------------------------------------------------------
|
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
"""Tests for move and copy operations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import copy
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from json_atom.apply import apply_delta
|
|
11
|
+
from json_atom.errors import ApplyError, InvertError
|
|
12
|
+
from json_atom.invert import invert_delta, revert_delta
|
|
13
|
+
from json_atom.models import Delta, Operation
|
|
14
|
+
from json_atom.validate import validate_delta
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _delta(*ops: Operation | dict[str, Any], **ext: Any) -> Delta:
|
|
18
|
+
return Delta.create(*ops, **ext)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# ---------------------------------------------------------------------------
|
|
22
|
+
# Validation
|
|
23
|
+
# ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class TestValidation:
|
|
27
|
+
def test_valid_move(self) -> None:
|
|
28
|
+
d = _delta(Operation.move("$.a", "$.b"))
|
|
29
|
+
assert validate_delta(d).valid
|
|
30
|
+
|
|
31
|
+
def test_valid_copy(self) -> None:
|
|
32
|
+
d = _delta(Operation.copy_op("$.a", "$.b"))
|
|
33
|
+
assert validate_delta(d).valid
|
|
34
|
+
|
|
35
|
+
def test_valid_copy_with_value(self) -> None:
|
|
36
|
+
d = _delta(Operation.copy_op("$.a", "$.b", value=42))
|
|
37
|
+
assert validate_delta(d).valid
|
|
38
|
+
|
|
39
|
+
def test_move_requires_from(self) -> None:
|
|
40
|
+
d = _delta({"op": "move", "path": "$.b"})
|
|
41
|
+
r = validate_delta(d)
|
|
42
|
+
assert not r.valid
|
|
43
|
+
assert any("requires 'from'" in e for e in r.errors)
|
|
44
|
+
|
|
45
|
+
def test_copy_requires_from(self) -> None:
|
|
46
|
+
d = _delta({"op": "copy", "path": "$.b"})
|
|
47
|
+
r = validate_delta(d)
|
|
48
|
+
assert not r.valid
|
|
49
|
+
assert any("requires 'from'" in e for e in r.errors)
|
|
50
|
+
|
|
51
|
+
def test_move_rejects_value(self) -> None:
|
|
52
|
+
d = _delta({"op": "move", "from": "$.a", "path": "$.b", "value": 1})
|
|
53
|
+
r = validate_delta(d)
|
|
54
|
+
assert not r.valid
|
|
55
|
+
assert any("must not include 'value'" in e for e in r.errors)
|
|
56
|
+
|
|
57
|
+
def test_move_rejects_old_value(self) -> None:
|
|
58
|
+
d = _delta({"op": "move", "from": "$.a", "path": "$.b", "oldValue": 1})
|
|
59
|
+
r = validate_delta(d)
|
|
60
|
+
assert not r.valid
|
|
61
|
+
assert any("must not include 'oldValue'" in e for e in r.errors)
|
|
62
|
+
|
|
63
|
+
def test_copy_rejects_old_value(self) -> None:
|
|
64
|
+
d = _delta({"op": "copy", "from": "$.a", "path": "$.b", "oldValue": 1})
|
|
65
|
+
r = validate_delta(d)
|
|
66
|
+
assert not r.valid
|
|
67
|
+
assert any("must not include 'oldValue'" in e for e in r.errors)
|
|
68
|
+
|
|
69
|
+
def test_move_rejects_self_move(self) -> None:
|
|
70
|
+
d = _delta({"op": "move", "from": "$.a", "path": "$.a"})
|
|
71
|
+
r = validate_delta(d)
|
|
72
|
+
assert not r.valid
|
|
73
|
+
assert any("must differ" in e for e in r.errors)
|
|
74
|
+
|
|
75
|
+
def test_move_rejects_subtree_move(self) -> None:
|
|
76
|
+
d = _delta({"op": "move", "from": "$.a", "path": "$.a.b"})
|
|
77
|
+
r = validate_delta(d)
|
|
78
|
+
assert not r.valid
|
|
79
|
+
assert any("subtree" in e for e in r.errors)
|
|
80
|
+
|
|
81
|
+
def test_move_allows_ancestor_target(self) -> None:
|
|
82
|
+
"""Moving from subtree to ancestor is valid (not self-move)."""
|
|
83
|
+
d = _delta({"op": "move", "from": "$.a.b", "path": "$.c"})
|
|
84
|
+
assert validate_delta(d).valid
|
|
85
|
+
|
|
86
|
+
def test_move_rejects_non_string_from(self) -> None:
|
|
87
|
+
d = _delta({"op": "move", "from": 42, "path": "$.b"})
|
|
88
|
+
r = validate_delta(d)
|
|
89
|
+
assert not r.valid
|
|
90
|
+
assert any("must be a string" in e for e in r.errors)
|
|
91
|
+
|
|
92
|
+
def test_mixed_ops_validate(self) -> None:
|
|
93
|
+
d = _delta(
|
|
94
|
+
Operation.replace("$.x", "new", old_value="old"),
|
|
95
|
+
Operation.move("$.a", "$.b"),
|
|
96
|
+
Operation.copy_op("$.c", "$.d"),
|
|
97
|
+
Operation.add("$.e", 1),
|
|
98
|
+
)
|
|
99
|
+
assert validate_delta(d).valid
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# ---------------------------------------------------------------------------
|
|
103
|
+
# Apply — Move
|
|
104
|
+
# ---------------------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class TestApplyMove:
|
|
108
|
+
def test_move_object_property(self) -> None:
|
|
109
|
+
obj = {"a": 1, "b": 2}
|
|
110
|
+
d = _delta(Operation.move("$.a", "$.c"))
|
|
111
|
+
result = apply_delta(obj, d)
|
|
112
|
+
assert result == {"b": 2, "c": 1}
|
|
113
|
+
|
|
114
|
+
def test_move_nested_property(self) -> None:
|
|
115
|
+
obj = {"user": {"name": "Alice", "role": "admin"}}
|
|
116
|
+
d = _delta(Operation.move("$.user.name", "$.user.displayName"))
|
|
117
|
+
result = apply_delta(obj, d)
|
|
118
|
+
assert result == {"user": {"displayName": "Alice", "role": "admin"}}
|
|
119
|
+
|
|
120
|
+
def test_move_array_element_by_index(self) -> None:
|
|
121
|
+
obj = {"arr": [10, 20, 30]}
|
|
122
|
+
# Remove index 0 (10), then add at index 2 — but after removal arr is [20,30]
|
|
123
|
+
# so index 2 appends: [20, 30, 10]
|
|
124
|
+
d = _delta(Operation.move("$.arr[0]", "$.arr[2]"))
|
|
125
|
+
result = apply_delta(obj, d)
|
|
126
|
+
assert result == {"arr": [20, 30, 10]}
|
|
127
|
+
|
|
128
|
+
def test_move_keyed_array_element(self) -> None:
|
|
129
|
+
obj = {
|
|
130
|
+
"items": [{"id": 1, "v": "a"}, {"id": 2, "v": "b"}],
|
|
131
|
+
"archive": [],
|
|
132
|
+
}
|
|
133
|
+
d = _delta(Operation.move("$.items[?(@.id==2)]", "$.archive[?(@.id==2)]"))
|
|
134
|
+
result = apply_delta(obj, d)
|
|
135
|
+
assert result == {
|
|
136
|
+
"items": [{"id": 1, "v": "a"}],
|
|
137
|
+
"archive": [{"id": 2, "v": "b"}],
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
def test_move_from_root(self) -> None:
|
|
141
|
+
obj = {"data": "hello"}
|
|
142
|
+
d = _delta(Operation.move("$.data", "$.backup"))
|
|
143
|
+
result = apply_delta(obj, d)
|
|
144
|
+
assert result == {"backup": "hello"}
|
|
145
|
+
|
|
146
|
+
def test_move_complex_object(self) -> None:
|
|
147
|
+
obj = {"a": {"nested": {"deep": [1, 2, 3]}}, "b": {}}
|
|
148
|
+
d = _delta(Operation.move("$.a.nested", "$.b.nested"))
|
|
149
|
+
result = apply_delta(obj, d)
|
|
150
|
+
assert result == {"a": {}, "b": {"nested": {"deep": [1, 2, 3]}}}
|
|
151
|
+
|
|
152
|
+
def test_move_error_nonexistent_source(self) -> None:
|
|
153
|
+
obj = {"a": 1}
|
|
154
|
+
d = _delta(Operation.move("$.missing", "$.b"))
|
|
155
|
+
with pytest.raises(ApplyError):
|
|
156
|
+
apply_delta(obj, d)
|
|
157
|
+
|
|
158
|
+
def test_move_error_existing_target(self) -> None:
|
|
159
|
+
obj = {"a": 1, "b": 2}
|
|
160
|
+
d = _delta(Operation.move("$.a", "$.b"))
|
|
161
|
+
with pytest.raises(ApplyError):
|
|
162
|
+
apply_delta(obj, d)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
# ---------------------------------------------------------------------------
|
|
166
|
+
# Apply — Copy
|
|
167
|
+
# ---------------------------------------------------------------------------
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class TestApplyCopy:
|
|
171
|
+
def test_copy_object_property(self) -> None:
|
|
172
|
+
obj = {"a": 1}
|
|
173
|
+
d = _delta(Operation.copy_op("$.a", "$.b"))
|
|
174
|
+
result = apply_delta(obj, d)
|
|
175
|
+
assert result == {"a": 1, "b": 1}
|
|
176
|
+
|
|
177
|
+
def test_copy_preserves_source(self) -> None:
|
|
178
|
+
obj = {"a": {"nested": 1}}
|
|
179
|
+
d = _delta(Operation.copy_op("$.a", "$.b"))
|
|
180
|
+
result = apply_delta(obj, d)
|
|
181
|
+
assert result["a"] == {"nested": 1}
|
|
182
|
+
assert result["b"] == {"nested": 1}
|
|
183
|
+
|
|
184
|
+
def test_copy_deep_clones(self) -> None:
|
|
185
|
+
obj = {"a": {"nested": [1, 2]}}
|
|
186
|
+
d = _delta(Operation.copy_op("$.a", "$.b"))
|
|
187
|
+
result = apply_delta(obj, d)
|
|
188
|
+
# Mutate the copy — source should be unchanged
|
|
189
|
+
result["b"]["nested"].append(3)
|
|
190
|
+
assert result["a"]["nested"] == [1, 2]
|
|
191
|
+
assert result["b"]["nested"] == [1, 2, 3]
|
|
192
|
+
|
|
193
|
+
def test_copy_from_root(self) -> None:
|
|
194
|
+
obj = {"x": 1}
|
|
195
|
+
d = _delta(Operation.copy_op("$", "$.snapshot"))
|
|
196
|
+
result = apply_delta(obj, d)
|
|
197
|
+
assert result == {"x": 1, "snapshot": {"x": 1}}
|
|
198
|
+
|
|
199
|
+
def test_copy_array_element(self) -> None:
|
|
200
|
+
obj = {"arr": [10, 20], "other": []}
|
|
201
|
+
d = _delta(Operation.copy_op("$.arr[0]", "$.other[0]"))
|
|
202
|
+
result = apply_delta(obj, d)
|
|
203
|
+
assert result == {"arr": [10, 20], "other": [10]}
|
|
204
|
+
|
|
205
|
+
def test_copy_error_nonexistent_source(self) -> None:
|
|
206
|
+
obj = {"a": 1}
|
|
207
|
+
d = _delta(Operation.copy_op("$.missing", "$.b"))
|
|
208
|
+
with pytest.raises(ApplyError):
|
|
209
|
+
apply_delta(obj, d)
|
|
210
|
+
|
|
211
|
+
def test_copy_error_existing_target(self) -> None:
|
|
212
|
+
obj = {"a": 1, "b": 2}
|
|
213
|
+
d = _delta(Operation.copy_op("$.a", "$.b"))
|
|
214
|
+
with pytest.raises(ApplyError):
|
|
215
|
+
apply_delta(obj, d)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
# ---------------------------------------------------------------------------
|
|
219
|
+
# Inversion
|
|
220
|
+
# ---------------------------------------------------------------------------
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
class TestInversion:
|
|
224
|
+
def test_move_inversion_swaps_paths(self) -> None:
|
|
225
|
+
d = _delta(Operation.move("$.a", "$.b"))
|
|
226
|
+
inv = invert_delta(d)
|
|
227
|
+
assert inv.operations[0]["op"] == "move"
|
|
228
|
+
assert inv.operations[0]["from"] == "$.b"
|
|
229
|
+
assert inv.operations[0]["path"] == "$.a"
|
|
230
|
+
|
|
231
|
+
def test_move_round_trip(self) -> None:
|
|
232
|
+
obj = {"a": 1, "b": 2}
|
|
233
|
+
d = _delta(Operation.move("$.a", "$.c"))
|
|
234
|
+
target = apply_delta(copy.deepcopy(obj), d)
|
|
235
|
+
assert target == {"b": 2, "c": 1}
|
|
236
|
+
restored = revert_delta(target, d)
|
|
237
|
+
assert restored == obj
|
|
238
|
+
|
|
239
|
+
def test_copy_inversion_produces_remove(self) -> None:
|
|
240
|
+
d = _delta(Operation.copy_op("$.a", "$.b", value=42))
|
|
241
|
+
inv = invert_delta(d)
|
|
242
|
+
assert inv.operations[0]["op"] == "remove"
|
|
243
|
+
assert inv.operations[0]["path"] == "$.b"
|
|
244
|
+
assert inv.operations[0]["oldValue"] == 42
|
|
245
|
+
|
|
246
|
+
def test_copy_round_trip(self) -> None:
|
|
247
|
+
obj = {"a": 42}
|
|
248
|
+
d = _delta(Operation.copy_op("$.a", "$.b", value=42))
|
|
249
|
+
target = apply_delta(copy.deepcopy(obj), d)
|
|
250
|
+
assert target == {"a": 42, "b": 42}
|
|
251
|
+
restored = revert_delta(target, d)
|
|
252
|
+
assert restored == obj
|
|
253
|
+
|
|
254
|
+
def test_copy_without_value_raises(self) -> None:
|
|
255
|
+
d = _delta(Operation.copy_op("$.a", "$.b"))
|
|
256
|
+
with pytest.raises(InvertError, match="missing 'value'"):
|
|
257
|
+
invert_delta(d)
|
|
258
|
+
|
|
259
|
+
def test_extension_preserved(self) -> None:
|
|
260
|
+
d = _delta({"op": "move", "from": "$.a", "path": "$.b", "x_tag": "test"})
|
|
261
|
+
inv = invert_delta(d)
|
|
262
|
+
assert inv.operations[0].get("x_tag") == "test"
|
|
263
|
+
|
|
264
|
+
def test_multi_op_inversion_order(self) -> None:
|
|
265
|
+
d = _delta(
|
|
266
|
+
Operation.move("$.a", "$.b"),
|
|
267
|
+
Operation.copy_op("$.c", "$.d", value=1),
|
|
268
|
+
)
|
|
269
|
+
inv = invert_delta(d)
|
|
270
|
+
# Order reversed
|
|
271
|
+
assert inv.operations[0]["op"] == "remove" # inverted copy
|
|
272
|
+
assert inv.operations[0]["path"] == "$.d"
|
|
273
|
+
assert inv.operations[1]["op"] == "move" # inverted move
|
|
274
|
+
assert inv.operations[1]["from"] == "$.b"
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
# ---------------------------------------------------------------------------
|
|
278
|
+
# Sequential semantics
|
|
279
|
+
# ---------------------------------------------------------------------------
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
class TestSequentialSemantics:
|
|
283
|
+
def test_move_then_operate_on_moved(self) -> None:
|
|
284
|
+
obj = {"a": {"v": 1}, "b": {}}
|
|
285
|
+
d = _delta(
|
|
286
|
+
Operation.move("$.a", "$.c"),
|
|
287
|
+
Operation.replace("$.c.v", 2, old_value=1),
|
|
288
|
+
)
|
|
289
|
+
result = apply_delta(obj, d)
|
|
290
|
+
assert result == {"b": {}, "c": {"v": 2}}
|
|
291
|
+
|
|
292
|
+
def test_copy_then_modify_copy(self) -> None:
|
|
293
|
+
obj = {"a": {"v": 1}}
|
|
294
|
+
d = _delta(
|
|
295
|
+
Operation.copy_op("$.a", "$.b"),
|
|
296
|
+
Operation.replace("$.b.v", 99, old_value=1),
|
|
297
|
+
)
|
|
298
|
+
result = apply_delta(obj, d)
|
|
299
|
+
assert result == {"a": {"v": 1}, "b": {"v": 99}}
|
|
300
|
+
|
|
301
|
+
def test_multiple_moves(self) -> None:
|
|
302
|
+
obj = {"a": 1, "b": 2, "c": 3}
|
|
303
|
+
d = _delta(
|
|
304
|
+
Operation.move("$.a", "$.x"),
|
|
305
|
+
Operation.move("$.b", "$.y"),
|
|
306
|
+
)
|
|
307
|
+
result = apply_delta(obj, d)
|
|
308
|
+
assert result == {"c": 3, "x": 1, "y": 2}
|
|
309
|
+
|
|
310
|
+
def test_mixed_ops(self) -> None:
|
|
311
|
+
obj = {"a": 1, "b": 2}
|
|
312
|
+
d = _delta(
|
|
313
|
+
Operation.copy_op("$.a", "$.c"),
|
|
314
|
+
Operation.move("$.b", "$.d"),
|
|
315
|
+
Operation.add("$.e", 5),
|
|
316
|
+
)
|
|
317
|
+
result = apply_delta(obj, d)
|
|
318
|
+
assert result == {"a": 1, "c": 1, "d": 2, "e": 5}
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
# ---------------------------------------------------------------------------
|
|
322
|
+
# Edge cases
|
|
323
|
+
# ---------------------------------------------------------------------------
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
class TestEdgeCases:
|
|
327
|
+
def test_move_null_value(self) -> None:
|
|
328
|
+
obj = {"a": None, "b": 1}
|
|
329
|
+
d = _delta(Operation.move("$.a", "$.c"))
|
|
330
|
+
result = apply_delta(obj, d)
|
|
331
|
+
assert result == {"b": 1, "c": None}
|
|
332
|
+
|
|
333
|
+
def test_copy_null_value(self) -> None:
|
|
334
|
+
obj = {"a": None}
|
|
335
|
+
d = _delta(Operation.copy_op("$.a", "$.b"))
|
|
336
|
+
result = apply_delta(obj, d)
|
|
337
|
+
assert result == {"a": None, "b": None}
|
|
338
|
+
|
|
339
|
+
def test_move_empty_object(self) -> None:
|
|
340
|
+
obj = {"a": {}, "b": 1}
|
|
341
|
+
d = _delta(Operation.move("$.a", "$.c"))
|
|
342
|
+
result = apply_delta(obj, d)
|
|
343
|
+
assert result == {"b": 1, "c": {}}
|
|
344
|
+
|
|
345
|
+
def test_move_empty_array(self) -> None:
|
|
346
|
+
obj = {"a": [], "b": 1}
|
|
347
|
+
d = _delta(Operation.move("$.a", "$.c"))
|
|
348
|
+
result = apply_delta(obj, d)
|
|
349
|
+
assert result == {"b": 1, "c": []}
|
|
350
|
+
|
|
351
|
+
def test_copy_with_value_matches_actual(self) -> None:
|
|
352
|
+
"""The optional value field on copy should match what's actually copied."""
|
|
353
|
+
obj = {"a": 42}
|
|
354
|
+
d = _delta(Operation.copy_op("$.a", "$.b", value=42))
|
|
355
|
+
result = apply_delta(obj, d)
|
|
356
|
+
# Apply ignores value, reads from source
|
|
357
|
+
assert result == {"a": 42, "b": 42}
|
|
358
|
+
|
|
359
|
+
def test_move_boolean_value(self) -> None:
|
|
360
|
+
obj = {"a": False, "b": 1}
|
|
361
|
+
d = _delta(Operation.move("$.a", "$.c"))
|
|
362
|
+
result = apply_delta(obj, d)
|
|
363
|
+
assert result == {"b": 1, "c": False}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|