jackson-databind icon indicating copy to clipboard operation
jackson-databind copied to clipboard

@JsonMerge deep update doesn't call setter for Collection/Map

Open cmolodo opened this issue 4 years ago • 4 comments

If an object property is serialized/deserialized with accessor methods and annotated with @JsonMerge, the setter is never called, because the "newValue" is always the same object as the "oldValue" in MergingSettableBeanProperty.

This means that if your setter normally does some additional logic, the merge won't be propagated/handled correctly. I ran into this with a Map for which I wanted the setter to be called after update, but it applies to any object. (If curious, I was trying to set up a system to allow both merge and deletion of Map entries, by explicitly setting the value to null in the JSON, and then removing any entries with null values in the setter. Currently, there's no way to handle removal of Map entries if the property is marked with @JsonMerge.)

Note that it also means that if the property isn't a Collection/Map and the getter returns a copy of the underlying object, the merge will also fail, though that's probably not a common use case.

Contrived self-contained example:

public class JsonMergeTest {
    @JsonAutoDetect(fieldVisibility = Visibility.NONE, isGetterVisibility = Visibility.ANY,
            getterVisibility = Visibility.ANY, setterVisibility = Visibility.ANY)
    public static class JsonMergeContainer {
        private JsonMergeChild child1;
        private Map<Integer, JsonMergeChild> childMap;
        private List<JsonMergeChild> childList;

        @JsonMerge
        public JsonMergeChild getChild1() {
            // By returning a newly-generated object, the merge will fail
            // because setter isn't called
            return new JsonMergeChild(child1);
        }

        @JsonMerge
        public void setChild1(JsonMergeChild child1) {
            this.child1 = child1;
        }

        @JsonMerge
        public Map<Integer, JsonMergeChild> getChildMap() {
            return childMap;
        }

        @JsonMerge
        public void setChildMap(Map<Integer, JsonMergeChild> childMap) {
            // This Map will be updated correctly because the merge modifies the
            // returned Map, but the additional logic to update the List
            // won't be called
            this.childMap = childMap;
            if (childMap != null) {
                // Filter out any children with id 10+, contrived logic to illustrate issue
                this.childList = childMap.values().stream().filter(child -> child.getId() < 10).collect(Collectors.toList());
            }
            else {
                this.childList = null;
            }
        }

        @JsonIgnore
        public List<JsonMergeChild> getChildList() {
            return childList;
        }
    }

    public static class JsonMergeChild {
        private int id;
        private String name;

        public JsonMergeChild() {
        }

        public JsonMergeChild(int id, String name) {
            this.id = id;
            this.name = name;
        }

        public JsonMergeChild(JsonMergeChild copy) {
            this.id = copy.id;
            this.name = copy.name;
        }

        public int getId() {
            return id;
        }

        public void setId(int id) {
            this.id = id;
        }

        public String getName() {
            return name;
        }

        public void setName(String name) {
            this.name = name;
        }

        // Methods added to make test and test output cleaner
        @Override
        public String toString() {
            return "JsonMergeChild{id=" + id + ", name='" + name + "'}";
        }

        @Override
        public boolean equals(Object o) {
            if (this == o) {
                return true;
            }
            if (o == null || getClass() != o.getClass()) {
                return false;
            }
            JsonMergeChild that = (JsonMergeChild) o;
            return id == that.id &&
                   Objects.equals(name, that.name);
        }

        @Override
        public int hashCode() {
            return Objects.hash(id, name);
        }
    }

    @Test
    public void testMerge() throws Exception {
        ObjectMapper mapper = new ObjectMapper();
        JsonMergeContainer container = new JsonMergeContainer();
        container.setChild1(new JsonMergeChild(1, "Child 1"));
        Map<Integer, JsonMergeChild> map = new HashMap<>();
        for (int i = 2; i < 5; i++) {
            map.put(i, new JsonMergeChild(i, "Child " + i));
        }
        container.setChildMap(map);

        String json = "{\"child1\":{\"id\":10,\"name\":\"child 1 updated\"},"
                      + "\"childMap\":{\"2\":{\"id\":20},\"3\":{\"name\":\"child 3 updated\"}}}";
        JsonMergeContainer result = mapper.readerForUpdating(container).readValue(json);

        // Passing tests based on current logic
        assertThat(result.getChildMap(), allOf(hasKey(2), hasKey(3), hasKey(4)));
        assertThat(result.getChildMap().get(2), is(new JsonMergeChild(20, "Child 2")));
        assertThat(result.getChildMap().get(3), is(new JsonMergeChild(3, "child 3 updated")));
        assertThat(result.getChildMap().get(4), is(new JsonMergeChild(4, "Child 4")));

        // Failing test because getter generated new object, so underlying object not updated
        assertThat(result.getChild1(), is(new JsonMergeChild(10, "child 1 updated")));

        // Failing tests because childMap setter not called, so childList not updated to filter out the modified child
        assertThat(result.getChildList(), hasSize(2));
        assertThat(result.getChildList(), containsInAnyOrder(new JsonMergeChild(3, "child 3 updated"),
                                                             new JsonMergeChild(4, "Child 4")));
    }
}

cmolodo avatar Jan 02 '20 22:01 cmolodo

If I understand your description correctly, yes, this is how things work and are designed to work. Merging does mean that an existing Object will be updated, and since no change for identity occurs, neither assignment (to field), nor setting (via setter) is called.

I assume this issue can then be considered as an RFE to have a mechanism to alternatively call setter regardless of change (or not), in case there is a setter (no point in re-assigning). It would probably have to be a global MapperFeature since there are no other customization mechanisms via @JsonMerge handling (it is simple Boolean valued setting, essentially).

cowtowncoder avatar Jan 03 '20 00:01 cowtowncoder

That makes sense, thank you! I definitely take your point about the distinction between merging and standard deserialization, so this isn't a bug, sorry. I was just naively expecting that since non-merges always call the setter, merges would as well. I understand why you wouldn't "set" the value for field assignment, but I guess I'm still not sure why merges shouldn't always call a setter if using accessor methods instead? (Not trying to be argumentative, just curious!)

In any case, yes, please consider this an RFE. It would definitely be very useful, at least for me, to have some option such as a global MapperFeature to force use of the setter. But I recognize it may not be a very common use case.

cmolodo avatar Jan 03 '20 14:01 cmolodo

@cmolodo No problem at all -- I can see how one could think setter would still be involved. I just hadn't even thought of that myself.

As to why not calling setter: performance (or, more generally, avoiding doing things that are/seem unnecessary). Calls to methods via reflection are significant part of data-binding overhead, so in general things are only done minimal number of times deemed necessary. Plus I honestly did not think of use case where setter has validation logic. If I had, I might have added support for deciding on this earlier.

cowtowncoder avatar Jan 03 '20 19:01 cowtowncoder

Looks like in that case @JsonSetter with null skips will not work in conjunction with @JsonMerge.

smoomrik avatar Feb 17 '22 12:02 smoomrik