Skip to content

Non-mutating JSONPatch #131

Description

@jg-rp

Patch application

Methods JSONPatch.apply(data) and JSONPatch.atomic(data) - and their function equivalents patch.apply(ops, data) and patch.atomic(ops, data) - are mutating operations. They modify their data argument in-place.

apply(data) updates data directly. In the event of a failed patch operation, all preceding patch operations are still applied to data and a JSONPatchError exception is raised. apply is the lowest level form of JSONPatch application.

atomic(data) is defined in terms of apply. It applies patch operations to a deep copy of data, and only mutates data after all patch operations succeed.

    def atomic(self, data: dict[str, Any] | list[Any]) -> dict[str, Any] | list[Any]:
        data_ = copy.deepcopy(data)
        self.apply(data_)  # This could raise a JSONPatchError.
        data.clear()

        if isinstance(data, dict):
            data.update(data_)
        else:
            data.extend(data_)

        return data

Both apply and atomic return mutated data too. That is important for apply in the case of a patch operation replacing the document root.

Using atomic to apply patch operations that replace the document root leads to undefined behaviour. (This is a separate issue. We should probably raise an exception.)

Non-mutating patch application

We are considering adding a third, non-mutating form of patch application, also defined in terms of apply.

    def patch(self, data: dict[str, Any] | list[Any]) -> dict[str, Any] | list[Any]:
        return self.apply(copy.deepcopy(data))

Data passed to patch is never mutated, so failed patch operations cannot leave partial changes visible to the caller. You either get a new object with all patch ops applied, or an exception is raised. The same behaviour can be achieved with an explicit deep copy and the existing apply method/function.

patch = JSONPatch([
    { "op": "replace", "path": "/a/b/c", "value": 43 },
    { "op": "test", "path": "/a/b/c", "value": 43 }
 ])

data = {'a': {'b': {'c': 1}}}
patched_data = patch.apply(copy.deepcopy(data))

assert data == {'a': {'b': {'c': 1}}}
assert patched_data == {'a': {'b': {'c': 43}}}

That is, atomic(data) mutates data, but doesn't partially apply. patch(data) doesn't mutate data, it always returns a new patched object.

Objections to the name patch

patch is not an ideal name for our non-mutating method and module-level function:

  • patch does not immediately convey the fact that a potentially expensive deep copy of data occurs.
  • We end up with "stuttering" when qualifying patch - patch.patch(ops, data) and JSONPatch.patch(data).

An alternative name under consideration is apply_copy.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions