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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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