Skip to content

Commit

Permalink
Reinstate new object addition for collections in patch operations.
Browse files Browse the repository at this point in the history
The removal of manual collection value appendance for primitives in the context of GH-2261 lead to complex object to be appended onto a collection not being deserialized at all. This deserialization is now reinstantiated with the value to be added looked up by reading the parent node into the collections element type using a JSON Pointer expression of /$propertyName/$index. This is done to make sure that @JsonDeserialize annotations on the property kick in for the deserialization of the individual elements.

We now also shortcut the entire merge algorithm for collections that are empty, contain primitives or enums.

Fixes GH-2287.
  • Loading branch information
odrotbohm committed Aug 30, 2023
1 parent 549121e commit 1fadf16
Show file tree
Hide file tree
Showing 2 changed files with 76 additions and 6 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
*/
package org.springframework.data.rest.webmvc.json;

import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.Arrays;
Expand All @@ -24,6 +25,7 @@
import java.util.Map;
import java.util.Map.Entry;
import java.util.Optional;
import java.util.function.IntFunction;

import org.springframework.beans.PropertyAccessor;
import org.springframework.beans.PropertyAccessorFactory;
Expand All @@ -44,6 +46,7 @@
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
import org.springframework.util.ClassUtils;
import org.springframework.util.ObjectUtils;

import com.fasterxml.jackson.databind.JsonNode;
Expand Down Expand Up @@ -264,7 +267,11 @@ <T> T doMerge(ObjectNode root, T target, ObjectMapper mapper) throws Exception {

if (child.isArray()) {

if (handleArray(child, it, mapper, property.getTypeInformation())) {
IntFunction<Object> rawValues = index -> readRawCollectionElement(property.getComponentType(), fieldName, index,
root,
mapper);

if (handleArray(child, it, mapper, property.getTypeInformation(), rawValues)) {
i.remove();
}

Expand Down Expand Up @@ -304,6 +311,16 @@ <T> T doMerge(ObjectNode root, T target, ObjectMapper mapper) throws Exception {
return mapper.readerForUpdating(target).readValue(root);
}

private static Object readRawCollectionElement(Class<?> elementType, String fieldName, int index, JsonNode root,
ObjectMapper mapper) {

try {
return mapper.readerFor(elementType).at("/" + fieldName + "/" + index).readValue(root);
} catch (IOException o_O) {
throw new RuntimeException(o_O);
}
}

/**
* Handles the given {@link JsonNode} by treating it as {@link ArrayNode} and the given source value as
* {@link Collection}-like value. Looks up the actual type to handle from the potentially available first element,
Expand All @@ -316,15 +333,17 @@ <T> T doMerge(ObjectNode root, T target, ObjectMapper mapper) throws Exception {
* @return
* @throws Exception
*/
private boolean handleArray(JsonNode node, Object source, ObjectMapper mapper, TypeInformation<?> collectionType) {
private boolean handleArray(JsonNode node, Object source, ObjectMapper mapper, TypeInformation<?> collectionType,
IntFunction<Object> rawValues) {

Collection<Object> collection = ifCollection(source);

if (collection == null) {
return false;
}

return execute(() -> handleArrayNode((ArrayNode) node, collection, mapper, collectionType.getComponentType()));
return execute(
() -> handleArrayNode((ArrayNode) node, collection, mapper, collectionType.getComponentType(), rawValues));
}

/**
Expand All @@ -337,27 +356,47 @@ private boolean handleArray(JsonNode node, Object source, ObjectMapper mapper, T
* @return whether an object merge has been applied to the {@link ArrayNode}.
*/
private boolean handleArrayNode(ArrayNode array, Collection<Object> collection, ObjectMapper mapper,
TypeInformation<?> componentType) throws Exception {
TypeInformation<?> componentType, IntFunction<Object> rawValues) throws Exception {

Assert.notNull(array, "ArrayNode must not be null");
Assert.notNull(collection, "Source collection must not be null");
Assert.notNull(mapper, "ObjectMapper must not be null");

// Empty collection? Primitive? Enum? No need to merge.
if (array.isEmpty()
|| collection.isEmpty()
|| ClassUtils.isPrimitiveOrWrapper(componentType.getType())
|| componentType.getType().isEnum()) {
return false;
}

// We need an iterator for the original collection.
// We might modify it but we want to keep iterating over the original collection.
Iterator<Object> value = new ArrayList<Object>(collection).iterator();
boolean nestedObjectFound = false;
int i = 0;

for (JsonNode jsonNode : array) {

int current = i++;

// We need to append new elements
if (!value.hasNext()) {

nestedObjectFound = true;

// Use pre-read values if available. Deserialize node otherwise.
collection.add(rawValues != null
? rawValues.apply(current)
: mapper.treeToValue(jsonNode, componentType.getType()));

break;
}

Object next = value.next();

if (ArrayNode.class.isInstance(jsonNode)) {
return handleArray(jsonNode, next, mapper, getTypeToMap(value, componentType));
return handleArray(jsonNode, next, mapper, getTypeToMap(value, componentType), null);
}

if (ObjectNode.class.isInstance(jsonNode)) {
Expand Down Expand Up @@ -410,7 +449,7 @@ private void doMergeNestedMap(Map<Object, Object> source, ObjectNode node, Objec

} else if (value instanceof ArrayNode && sourceValue != null) {

handleArray(value, sourceValue, mapper, getTypeToMap(sourceValue, typeToMap));
handleArray(value, sourceValue, mapper, getTypeToMap(sourceValue, typeToMap), null);

} else {

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ void setUp() {
mappingContext.getPersistentEntity(Apple.class);
mappingContext.getPersistentEntity(Pear.class);
mappingContext.getPersistentEntity(WithCustomMappedPrimitiveCollection.class);
mappingContext.getPersistentEntity(BugModel.class);
mappingContext.afterPropertiesSet();

this.entities = new PersistentEntities(Collections.singleton(mappingContext));
Expand Down Expand Up @@ -678,6 +679,26 @@ void nestedEntitiesAreCreatedWhenMissingForPut() throws Exception {
assertThat(result.inner.hidden).isNull();
}

@Test
void deserializesNewNestedEntitiesCorrectly() throws Exception {

var mapper = new ObjectMapper();
var node = mapper.readTree("{ \"list\" : [ { \"value\" : \"Foo\" }, { \"value\" : \"Bar\" }] }");

This comment has been minimized.

Copy link
@jeremymailen

jeremymailen Oct 3, 2023

If you make this

"{ \"list\" : [{ \"value\" : \"Foo\" }, { \"value\" : \"Bar\" }, { \"value\" : \"Baz\" }] }"

and expect .containsExactly("Foo", "Bar", "Baz")
I believe the test will fail.
I think there's still a bug handling more than one new objects in the collection.

This comment has been minimized.

Copy link
@beardy247

beardy247 May 2, 2024

@odrotbohm I'm finding the same (tested in 3.7.13 - 3.7.18), reverting spring-data-rest-webmvc to 3.7.12 resolves the issue.


var nested = new BugModel.NestedModel();
nested.value = "FooBar";

var model = new BugModel();
model.list = new ArrayList<>();
model.list.add(nested);

var result = reader.doMerge((ObjectNode) node, model, mapper);

assertThat(result.list)
.extracting(it -> it.value)
.containsExactly("Foo", "Bar");
}

@SuppressWarnings("unchecked")
private static <T> T as(Object source, Class<T> type) {

Expand Down Expand Up @@ -1040,4 +1061,14 @@ public Long deserialize(JsonParser p, DeserializationContext ctxt) throws IOExce
}
}
}

// GH-2287
static class BugModel {

public List<NestedModel> list;

static class NestedModel {
public String value;
}
}
}

0 comments on commit 1fadf16

Please sign in to comment.