Skip to content

Commit 0b0fa76

Browse files
authored
Merge pull request #352 from maykinmedia/feature/json-merge-patch
✨ Use JSON Merge Patch when doing a partial update on records
2 parents 6d185f9 + cdeb100 commit 0b0fa76

10 files changed

+98
-11
lines changed

src/objects/api/serializers.py

+5-1
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from objects.utils.serializers import DynamicFieldsMixin
1010

1111
from .fields import ObjectSlugRelatedField, ObjectTypeField, ObjectUrlField
12+
from .utils import merge_patch
1213
from .validators import GeometryValidator, IsImmutableValidator, JsonSchemaValidator
1314

1415

@@ -126,9 +127,12 @@ def update(self, instance, validated_data):
126127
# object_data is not used since all object attributes are immutable
127128
object_data = validated_data.pop("object", None)
128129
validated_data["object"] = instance.object
129-
# in case of PATCH
130+
# version should be set
130131
if "version" not in validated_data:
131132
validated_data["version"] = instance.version
133+
if self.partial and "data" in validated_data:
134+
# Apply JSON Merge Patch for record data
135+
validated_data["data"] = merge_patch(instance.data, validated_data["data"])
132136

133137
record = super().create(validated_data)
134138
return record

src/objects/api/utils.py

+24-1
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
from datetime import date
2-
from typing import Union
2+
from typing import Dict, Union
33

44
from djchoices import DjangoChoices
55

6+
from objects.typing import JSONValue
7+
68

79
def string_to_value(value: str) -> Union[str, float, date]:
810
if is_number(value):
@@ -43,3 +45,24 @@ def display_choice_values_for_help_text(choices: DjangoChoices) -> str:
4345
items.append(item)
4446

4547
return "\n".join(items)
48+
49+
50+
def merge_patch(target: JSONValue, patch: JSONValue) -> Dict[str, JSONValue]:
51+
"""Merge two objects together recursively.
52+
53+
This is inspired by https://datatracker.ietf.org/doc/html/rfc7396 - JSON Merge Patch,
54+
but deviating in some cases to suit our needs.
55+
"""
56+
57+
if not isinstance(patch, dict):
58+
return patch
59+
60+
if not isinstance(target, dict):
61+
# Ignore the contents and set it to an empty dict
62+
target = {}
63+
for k, v in patch.items():
64+
# According to RFC, we should remove k from target
65+
# if v is None. This is where we deviate.
66+
target[k] = merge_patch(target.get(k), v)
67+
68+
return target

src/objects/api/v1/openapi.yaml

+2
Original file line numberDiff line numberDiff line change
@@ -349,6 +349,8 @@ paths:
349349
patch:
350350
operationId: object_partial_update
351351
description: Update the OBJECT by creating a new RECORD with the updates values.
352+
The provided `record.data` value will be merged recursively with the existing
353+
record data.
352354
parameters:
353355
- in: header
354356
name: Accept-Crs

src/objects/api/v1/views.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,8 @@
3939
description="Update the OBJECT by creating a new RECORD with the updates values."
4040
),
4141
partial_update=extend_schema(
42-
description="Update the OBJECT by creating a new RECORD with the updates values."
42+
description="Update the OBJECT by creating a new RECORD with the updates values. "
43+
"The provided `record.data` value will be merged recursively with the existing record data."
4344
),
4445
destroy=extend_schema(
4546
description="Delete an OBJECT and all RECORDs belonging to it.",

src/objects/api/v2/openapi.yaml

+2
Original file line numberDiff line numberDiff line change
@@ -377,6 +377,8 @@ paths:
377377
patch:
378378
operationId: object_partial_update
379379
description: Update the OBJECT by creating a new RECORD with the updates values.
380+
The provided `record.data` value will be merged recursively with the existing
381+
record data.
380382
parameters:
381383
- in: header
382384
name: Accept-Crs

src/objects/api/v2/views.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,8 @@
4242
description="Update the OBJECT by creating a new RECORD with the updates values."
4343
),
4444
partial_update=extend_schema(
45-
description="Update the OBJECT by creating a new RECORD with the updates values."
45+
description="Update the OBJECT by creating a new RECORD with the updates values. "
46+
"The provided `record.data` value will be merged recursively with the existing record data."
4647
),
4748
destroy=extend_schema(
4849
description="Delete an OBJECT and all RECORDs belonging to it.",

src/objects/tests/test_merge_patch.py

+34
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
from unittest import TestCase
2+
3+
from objects.api.utils import merge_patch
4+
5+
6+
class MergePatchTests(TestCase):
7+
def test_merge_patch(self):
8+
9+
test_data = [
10+
({"a": "b"}, {"a": "c"}, {"a": "c"}),
11+
({"a": "b"}, {"b": "c"}, {"a": "b", "b": "c"}),
12+
({"a": "b"}, {"a": None}, {"a": None}),
13+
({"a": "b", "b": "c"}, {"a": None}, {"a": None, "b": "c"}),
14+
({"a": ["b"]}, {"a": "c"}, {"a": "c"}),
15+
({"a": "c"}, {"a": ["b"]}, {"a": ["b"]}),
16+
(
17+
{"a": {"b": "c"}},
18+
{"a": {"b": "d", "c": None}},
19+
{"a": {"b": "d", "c": None}},
20+
),
21+
({"a": [{"b": "c"}]}, {"a": [1]}, {"a": [1]}),
22+
(["a", "b"], ["c", "d"], ["c", "d"]),
23+
({"a": "b"}, ["c"], ["c"]),
24+
({"a": "foo"}, None, None),
25+
({"a": "foo"}, "bar", "bar"),
26+
({"e": None}, {"a": 1}, {"e": None, "a": 1}),
27+
([1, 2], {"a": "b", "c": None}, {"a": "b", "c": None}),
28+
({}, {"a": {"bb": {"ccc": None}}}, {"a": {"bb": {"ccc": None}}}),
29+
({"a": "b"}, {"a": "b"}, {"a": "b"}),
30+
]
31+
32+
for target, patch, expected in test_data:
33+
with self.subTest():
34+
self.assertEqual(merge_patch(target, patch), expected)

src/objects/tests/v1/test_object_api.py

+7-2
Original file line numberDiff line numberDiff line change
@@ -205,7 +205,10 @@ def test_patch_object_record(self, m):
205205
)
206206

207207
initial_record = ObjectRecordFactory.create(
208-
version=1, object__object_type=self.object_type, start_at=date.today()
208+
version=1,
209+
object__object_type=self.object_type,
210+
start_at=date.today(),
211+
data={"name": "Name", "diameter": 20},
209212
)
210213
object = initial_record.object
211214

@@ -229,8 +232,10 @@ def test_patch_object_record(self, m):
229232
current_record = object.current_record
230233

231234
self.assertEqual(current_record.version, initial_record.version)
235+
# The actual behavior of the data merging is in test_merge_patch.py:
232236
self.assertEqual(
233-
current_record.data, {"plantDate": "2020-04-12", "diameter": 30}
237+
current_record.data,
238+
{"plantDate": "2020-04-12", "diameter": 30, "name": "Name"},
234239
)
235240
self.assertEqual(current_record.start_at, date(2020, 1, 1))
236241
self.assertEqual(current_record.registration_at, date(2020, 8, 8))

src/objects/tests/v2/test_object_api.py

+13-5
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
import json
22
import uuid
33
from datetime import date, timedelta
4+
from typing import cast
45

56
import requests_mock
67
from freezegun import freeze_time
78
from rest_framework import status
89
from rest_framework.test import APITestCase
910

10-
from objects.core.models import Object
11+
from objects.core.models import Object, ObjectType
1112
from objects.core.tests.factories import (
1213
ObjectFactory,
1314
ObjectRecordFactory,
@@ -33,7 +34,9 @@ class ObjectApiTests(TokenAuthMixin, APITestCase):
3334
def setUpTestData(cls):
3435
super().setUpTestData()
3536

36-
cls.object_type = ObjectTypeFactory(service__api_root=OBJECT_TYPES_API)
37+
cls.object_type = cast(
38+
ObjectType, ObjectTypeFactory(service__api_root=OBJECT_TYPES_API)
39+
)
3740
PermissionFactory.create(
3841
object_type=cls.object_type,
3942
mode=PermissionModes.read_and_write,
@@ -227,14 +230,17 @@ def test_patch_object_record(self, m):
227230
)
228231

229232
initial_record = ObjectRecordFactory.create(
230-
version=1, object__object_type=self.object_type, start_at=date.today()
233+
version=1,
234+
object__object_type=self.object_type,
235+
start_at=date.today(),
236+
data={"name": "Name", "diameter": 20},
231237
)
232238
object = initial_record.object
233239

234240
url = reverse("object-detail", args=[object.uuid])
235241
data = {
236242
"record": {
237-
"data": {"plantDate": "2020-04-12", "diameter": 30},
243+
"data": {"plantDate": "2020-04-12", "diameter": 30, "name": None},
238244
"startAt": "2020-01-01",
239245
"correctionFor": initial_record.index,
240246
},
@@ -251,8 +257,10 @@ def test_patch_object_record(self, m):
251257
current_record = object.current_record
252258

253259
self.assertEqual(current_record.version, initial_record.version)
260+
# The actual behavior of the data merging is in test_merge_patch.py:
254261
self.assertEqual(
255-
current_record.data, {"plantDate": "2020-04-12", "diameter": 30}
262+
current_record.data,
263+
{"plantDate": "2020-04-12", "diameter": 30, "name": None},
256264
)
257265
self.assertEqual(current_record.start_at, date(2020, 1, 1))
258266
self.assertEqual(current_record.registration_at, date(2020, 8, 8))

src/objects/typing.py

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
from typing import Dict, List, Union
2+
3+
JSONPrimitive = Union[str, int, None, float, bool]
4+
5+
JSONValue = Union[JSONPrimitive, "JSONObject", List["JSONValue"]]
6+
7+
JSONObject = Dict[str, JSONValue]

0 commit comments

Comments
 (0)