Skip to content

Commit 4391e24

Browse files
committed
DATAREST-931 - DomainObjectMerger now handles arrays with complex objects correctly.
We now explicitly manually merge array nodes that contain complex objects. Previously arrays would've been skipped and the subsequent Jackson update would wipe out all properties not contained in the original document even on PATCH requests.
1 parent 27e0e74 commit 4391e24

File tree

3 files changed

+125
-13
lines changed

3 files changed

+125
-13
lines changed

spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/config/JsonPatchHandler.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ <T> T applyPatch(InputStream source, T target) throws Exception {
123123
}
124124
}
125125

126-
return reader.merge((ObjectNode) patchedNode, target, mapper);
126+
return mapper.readerForUpdating(target).readValue(patchedNode);
127127
}
128128

129129
<T> T applyMergePatch(InputStream source, T existingObject) throws Exception {

spring-data-rest-webmvc/src/main/java/org/springframework/data/rest/webmvc/json/DomainObjectReader.java

Lines changed: 89 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,10 @@
1616
package org.springframework.data.rest.webmvc.json;
1717

1818
import java.io.InputStream;
19+
import java.util.Arrays;
1920
import java.util.HashMap;
21+
import java.util.Collection;
22+
import java.util.Collections;
2023
import java.util.Iterator;
2124
import java.util.Map;
2225
import java.util.Map.Entry;
@@ -35,6 +38,7 @@
3538
import com.fasterxml.jackson.databind.JsonNode;
3639
import com.fasterxml.jackson.databind.ObjectMapper;
3740
import com.fasterxml.jackson.databind.introspect.BasicClassIntrospector;
41+
import com.fasterxml.jackson.databind.node.ArrayNode;
3842
import com.fasterxml.jackson.databind.introspect.BeanPropertyDefinition;
3943
import com.fasterxml.jackson.databind.introspect.ClassIntrospector;
4044
import com.fasterxml.jackson.databind.node.ObjectNode;
@@ -155,6 +159,7 @@ public <T> T merge(ObjectNode source, T target, ObjectMapper mapper) {
155159
* @return
156160
* @throws Exception
157161
*/
162+
@SuppressWarnings("unchecked")
158163
private <T> T doMerge(ObjectNode root, T target, ObjectMapper mapper) throws Exception {
159164

160165
Assert.notNull(root, "Root ObjectNode must not be null!");
@@ -173,29 +178,34 @@ private <T> T doMerge(ObjectNode root, T target, ObjectMapper mapper) throws Exc
173178

174179
Entry<String, JsonNode> entry = i.next();
175180
JsonNode child = entry.getValue();
181+
String fieldName = entry.getKey();
176182

177-
if (child.isArray()) {
183+
if (!mappedProperties.hasPersistentPropertyForField(fieldName)) {
184+
i.remove();
178185
continue;
179186
}
180187

181-
String fieldName = entry.getKey();
188+
PersistentProperty<?> property = mappedProperties.getPersistentProperty(fieldName);
189+
PersistentPropertyAccessor accessor = entity.getPropertyAccessor(target);
190+
Object rawValue = accessor.getProperty(property);
191+
192+
if (child.isArray()) {
193+
194+
boolean nestedObjectFound = handleArrayNode((ArrayNode) child, asCollection(rawValue), mapper);
195+
196+
if (nestedObjectFound) {
197+
i.remove();
198+
}
182199

183-
if (!mappedProperties.hasPersistentPropertyForField(fieldName)) {
184-
i.remove();
185200
continue;
186201
}
187202

188203
if (child.isObject()) {
189204

190-
PersistentProperty<?> property = mappedProperties.getPersistentProperty(fieldName);
191-
192205
if (associationLinks.isLinkableAssociation(property)) {
193206
continue;
194207
}
195208

196-
PersistentPropertyAccessor accessor = entity.getPropertyAccessor(target);
197-
Object nested = accessor.getProperty(property);
198-
199209
ObjectNode objectNode = (ObjectNode) child;
200210

201211
if (property.isMap()) {
@@ -205,7 +215,7 @@ private <T> T doMerge(ObjectNode root, T target, ObjectMapper mapper) throws Exc
205215
continue;
206216
}
207217

208-
doMergeNestedMap((Map<String, Object>) nested, objectNode, mapper);
218+
doMergeNestedMap((Map<String, Object>) rawValue, objectNode, mapper);
209219

210220
// Remove potentially emptied Map as values have been handled recursively
211221
if (!objectNode.fieldNames().hasNext()) {
@@ -215,15 +225,55 @@ private <T> T doMerge(ObjectNode root, T target, ObjectMapper mapper) throws Exc
215225
continue;
216226
}
217227

218-
if (nested != null && property.isEntity()) {
219-
doMerge(objectNode, nested, mapper);
228+
if (rawValue != null && property.isEntity()) {
229+
doMerge(objectNode, rawValue, mapper);
220230
}
221231
}
222232
}
223233

224234
return mapper.readerForUpdating(target).readValue(root);
225235
}
226236

237+
/**
238+
* Applies the diff handling to {@link ArrayNode}s, potentially recursing into nested ones.
239+
*
240+
* @param array the source {@link ArrayNode}m, must not be {@literal null}.
241+
* @param collection the actual collection values, must not be {@literal null}.
242+
* @param mapper the {@link ObjectMapper} to use, must not be {@literal null}.
243+
* @return whether an object merge has been applied to the {@link ArrayNode}.
244+
*/
245+
private boolean handleArrayNode(ArrayNode array, Collection<Object> collection, ObjectMapper mapper)
246+
throws Exception {
247+
248+
Assert.notNull(array, "ArrayNode must not be null!");
249+
Assert.notNull(collection, "Source collection must not be null!");
250+
Assert.notNull(mapper, "ObjectMapper must not be null!");
251+
252+
Iterator<Object> value = collection.iterator();
253+
boolean nestedObjectFound = false;
254+
255+
for (JsonNode jsonNode : array) {
256+
257+
if (!value.hasNext()) {
258+
return nestedObjectFound;
259+
}
260+
261+
Object next = value.next();
262+
263+
if (ArrayNode.class.isInstance(jsonNode)) {
264+
return handleArrayNode(array, asCollection(next), mapper);
265+
}
266+
267+
if (ObjectNode.class.isInstance(jsonNode)) {
268+
269+
nestedObjectFound = true;
270+
doMerge((ObjectNode) jsonNode, next, mapper);
271+
}
272+
}
273+
274+
return nestedObjectFound;
275+
}
276+
227277
/**
228278
* Merges nested {@link Map} values for the given source {@link Map}, the {@link ObjectNode} and {@link ObjectMapper}.
229279
*
@@ -253,11 +303,38 @@ private void doMergeNestedMap(Map<String, Object> source, ObjectNode node, Objec
253303
}
254304
}
255305

306+
/**
307+
* Returns the given source instance as {@link Collection}.
308+
*
309+
* @param source can be {@literal null}.
310+
* @return
311+
*/
312+
@SuppressWarnings("unchecked")
313+
private static Collection<Object> asCollection(Object source) {
314+
315+
if (source == null) {
316+
return Collections.emptyList();
317+
}
318+
319+
if (source instanceof Collection) {
320+
return (Collection<Object>) source;
321+
}
322+
323+
if (source.getClass().isArray()) {
324+
return Arrays.asList((Object[]) source);
325+
}
326+
327+
return Collections.singleton(source);
328+
}
329+
256330
/**
257331
* Simple value object to capture a mapping of Jackson mapped field names and {@link PersistentProperty} instances.
332+
*
333+
* @param source can be {@literal null}.
258334
*
259335
* @author Oliver Gierke
260336
*/
337+
@SuppressWarnings("unchecked")
261338
static class MappedProperties {
262339

263340
private static final ClassIntrospector INTROSPECTOR = new BasicClassIntrospector();

spring-data-rest-webmvc/src/test/java/org/springframework/data/rest/webmvc/json/DomainObjectReaderUnitTests.java

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,14 @@
1818
import static org.hamcrest.CoreMatchers.*;
1919
import static org.junit.Assert.*;
2020

21+
import java.io.ByteArrayInputStream;
22+
import java.util.ArrayList;
23+
import java.util.Calendar;
2124
import java.util.Collections;
2225
import java.util.Date;
26+
import java.util.GregorianCalendar;
2327
import java.util.HashMap;
28+
import java.util.List;
2429
import java.util.Map;
2530

2631
import org.junit.Before;
@@ -43,6 +48,7 @@
4348
import com.fasterxml.jackson.databind.ObjectMapper;
4449
import com.fasterxml.jackson.databind.PropertyNamingStrategy;
4550
import com.fasterxml.jackson.databind.node.ObjectNode;
51+
import com.google.common.base.Charsets;
4652

4753
/**
4854
* Unit tests for {@link DomainObjectReader}.
@@ -65,6 +71,7 @@ public void setUp() {
6571
mappingContext.getPersistentEntity(TypeWithGenericMap.class);
6672
mappingContext.getPersistentEntity(VersionedType.class);
6773
mappingContext.getPersistentEntity(SampleWithCreatedDate.class);
74+
mappingContext.getPersistentEntity(User.class);
6875
mappingContext.afterPropertiesSet();
6976

7077
PersistentEntities entities = new PersistentEntities(Collections.singleton(mappingContext));
@@ -190,6 +197,23 @@ public void doesNotApplyInputToReadOnlyFields() throws Exception {
190197
assertThat(reader.readPut(node, sample, mapper).createdDate, is(reference));
191198
}
192199

200+
@Test
201+
public void readsPatchForEntityNestedInCollection() throws Exception {
202+
203+
Phone phone = new Phone();
204+
phone.creationDate = new GregorianCalendar();
205+
206+
User user = new User();
207+
user.phones.add(phone);
208+
209+
ByteArrayInputStream source = new ByteArrayInputStream(
210+
"{ \"phones\" : [ { \"label\" : \"some label\" } ] }".getBytes(Charsets.UTF_8));
211+
212+
User result = reader.read(source, user, new ObjectMapper());
213+
214+
assertThat(result.phones.get(0).creationDate, is(notNullValue()));
215+
}
216+
193217
@JsonAutoDetect(fieldVisibility = Visibility.ANY)
194218
static class SampleUser {
195219

@@ -239,4 +263,15 @@ static class SampleWithCreatedDate {
239263
@ReadOnlyProperty //
240264
Date createdDate;
241265
}
266+
267+
static class User {
268+
269+
public List<Phone> phones = new ArrayList<Phone>();
270+
}
271+
272+
static class Phone {
273+
274+
public Calendar creationDate;
275+
public String label;
276+
}
242277
}

0 commit comments

Comments
 (0)