Overview
In this article, I will show you, how to perform a partial update with explicit null
values using JsonNullable and mapstruct.
The example is available on Github.
Below, only the necessary code snippets are presented.
05.08.2022: I’ve updated the article, because mapstruct 1.5.2.Final
makes it much easier, thanks to @Condition feature.
If you want to know how to do it with 1.4.1.Final
,
check out this branch.
The Idea of Partial Updates
The appropriate HTTP verb for this task is PATCH (RFC 5789). The difference between PATCH and PUT (RFC 7231 4.3.4) is that PUT method allows only a complete replacement of an entity and with PATCH you can perform JSON Merge Patch (RFC 7386).
So, given the entity:
public class Product {
private Long id;
private String name;
private Integer quantity;
private String description;
private String manufacturer;
// getters, setters, constructors...
}
we can update only the description
field with:
// PATCH http://kdrozd.pl/api/product/3
// Body:
{
"description": "Some description of product "
}
Other fields are not present, so they will be evaluated to null
and ignored.
But, what if we want to update the description
with null
? It is not possible, because there is no way to distinguish the missing field (evaluated to null
) from the field with value set explicitly to null
. String class has only two states available:
- value is present and is not
null
, e.g.''
,'something'
,'123'
- value is not present ->
null
{ "description": null }
is interpreted the same as { }
.
In both cases the description
field will not be updated.
If you still don’t fully understand what I mean, check this out: JSON Merge Patch (RFC 7386).
Some may try to use cruelly dirty workarounds.
For example, in the case of String
, you can interpret 'null'
as a command: update with a real null
.
But what in case of Integer
? 0
would be your null
? How about Boolean
?
Do not do that!
The correct way is to use a wrapper, for example in the form of a JsonNullable class. So instead of String description
we get JsonNullable<String> description
. Additionally, to avoid writing a lot of mapping code (because you need an additional layer of DTOs), you can combine it with a mapper, e.g. mapstruct.
How to Implement Partial Update With JsonNullable and MapStruct?
Why JsonNullable?
I chose this library because it is simple and made exactly for this purpose. Some people use Optional
and it will work, but it’s not made for that purpose. On this point, I agree with the makers of JsonNullable:
A lot of people use Optional to bring this behaviour. Although it kinda works, it’s not a good idea because:
- Beans shouldn’t have Optional fields. Optional was designed to be used only as method return value.
- Optional should never be null. The goal of Optional is to wrap the null and prevent NPE so the code should be designed to never assign null to an Optional. A code invoking a method returning an Optional should be confident that this Optional is not null.
Why mapstruct?
- It is very flexible, and even if it doesn’t do something automatically, there is no problem integrating some customization in the mapping process.
mapstruct
is a compile-time code generator. It means that the mapper class is generated before starting the application (just as if the mapper was hand-written). This makes debugging a lot easier as the code is very easy to understand.- Due to the fact that it does not work in runtime, it is much faster than other libraries, because it does not use any complicated mechanisms (e.g. reflection)
I have once used a modelmapper library that works in a runtime. It was incredibly difficult to find the cause of the mapper’s malfunction. I had to delve into the complicated mechanisms of the library, even though I didn’t need any sophisticated customizations. Luckily I found out about mapstruct library and migration took me less time than debugging modelmapper.
Configuration
For all this to work we need to configure some dependencies. I used Maven
but everything should work fine with Gradle
as well. Here are the changes in the pom.xml
:
<dependencies>
<!-- other dependencies omitted (spring, h2, tests...) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-json</artifactId>
</dependency>
<dependency>
<groupId>org.openapitools</groupId>
<artifactId>jackson-databind-nullable</artifactId>
<version>0.2.1</version>
</dependency>
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct</artifactId>
<version>1.5.2.Final</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.8.1</version>
<configuration>
<source>11</source>
<target>11</target>
<annotationProcessorPaths>
<path>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct-processor</artifactId>
<version>1.4.1.Final</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
</plugins>
</build>
Additionally to inform Jackson
how to handle JsonNullable
, we have to register an additional module:
import com.fasterxml.jackson.annotation.JsonInclude;
import org.openapitools.jackson.nullable.JsonNullableModule;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder;
@Configuration
public class JacksonConfig {
@Bean
Jackson2ObjectMapperBuilder objectMapperBuilder() {
Jackson2ObjectMapperBuilder builder = new Jackson2ObjectMapperBuilder();
builder.serializationInclusion(JsonInclude.Include.ALWAYS)
.modulesToInstall(new JsonNullableModule());
return builder;
}
}
The example is based on an entity named Product
. For simplicity, the ProductDTO
class has exactly the same fields. The only difference is that some fields are wrapped in a JsonNullable<>
type to be able to perform JSON Merge Patch (RFC 7386) on them.
package pl.kdrozd.examples.model;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import java.util.Objects;
@Entity
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private Long id;
private String name;
private Integer quantity;
private String description;
private String manufacturer;
// constructors, getters, setters...
package pl.kdrozd.examples.dto;
import org.openapitools.jackson.nullable.JsonNullable;
import java.util.Objects;
public class ProductDTO {
private Long id;
private String name;
private Integer quantity;
private JsonNullable<String> description;
private JsonNullable<String> manufacturer;
// constructor, getters, setters...
Let’s see if Jackson deserializes our entity correctly:
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.openapitools.jackson.nullable.JsonNullable;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import pl.kdrozd.examples.dto.ProductDTO;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
@SpringBootTest
class JacksonConfigTest {
@Autowired
private ObjectMapper mapper;
@Test
void should_use_json_nullable_module() throws JsonProcessingException {
assertEquals(JsonNullable.of("some description"), mapper.readValue("{\"description\":\"some description\"}", ProductDTO.class).getDescription());
assertEquals(JsonNullable.of(null), mapper.readValue("{\"description\":null}", ProductDTO.class).getDescription());
assertNull(mapper.readValue("{}", ProductDTO.class).getDescription());
}
}
Mapping DTO and Model classes
Instead of rewriting fields between the model and DTO manually, we will use the mapstruct library.
In our case this is all you would need if we didn’t use JsonNullable
:
package pl.kdrozd.examples.mapper;
import org.mapstruct.*;
import pl.kdrozd.examples.dto.ProductDTO;
import pl.kdrozd.examples.model.Product;
@Mapper(nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE,
componentModel = "spring")
public interface ProductMapper {
@Mapping(target = "id", ignore = true)
Product map(ProductDTO entity);
ProductDTO map(Product entity);
@InheritConfiguration
void update(ProductDTO update, @MappingTarget Product destination);
}
Unfortunately, mapstruct
has no built-in support for the JsonNullable
class
and when trying to compile the project we will get the error:
java: Can't map property "JsonNullable<string> description" to "String description". Consider to declare/implement a mapping method: "String map(JsonNullable<string> value)".
To get rid of this error, we need to define a separate mapper
for the JsonNullable
class.
So we get:
package pl.kdrozd.examples.mapper;
import org.mapstruct.Condition;
import org.mapstruct.Mapper;
import org.mapstruct.Named;
import org.openapitools.jackson.nullable.JsonNullable;
@Mapper(componentModel = "spring")
public interface JsonNullableMapper {
default <T> JsonNullable<T> wrap(T entity) {
return JsonNullable.of(entity);
}
default <T> T unwrap(JsonNullable<T> jsonNullable) {
return jsonNullable == null ? null : jsonNullable.orElse(null);
}
}
Then you need to instruct ProductMapper
to use your new JsonNullableMapper
:
package pl.kdrozd.examples.mapper;
import org.mapstruct.*;
import pl.kdrozd.examples.dto.ProductDTO;
import pl.kdrozd.examples.model.Product;
@Mapper(uses = JsonNullableMapper.class,
nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE,
componentModel = "spring")
public interface ProductMapper {
@Mapping(target = "id", ignore = true)
Product map(ProductDTO entity);
ProductDTO map(Product entity);
@InheritConfiguration
void update(ProductDTO update, @MappingTarget Product destination);
}
Now the project should compile and mappers sources
are generated into target\generated-sources\annotations
directory.
package pl.kdrozd.examples.mapper;
import javax.annotation.processing.Generated;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import pl.kdrozd.examples.dto.ProductDTO;
import pl.kdrozd.examples.model.Product;
@Generated(
value = "org.mapstruct.ap.MappingProcessor",
date = "2022-08-05T17:54:15+0000",
comments = "version: 1.5.2.Final, compiler: javac, environment: Java 11.0.15 (Private Build)"
)
@Component
public class ProductMapperImpl implements ProductMapper {
@Autowired
private JsonNullableMapper jsonNullableMapper;
@Override
public Product map(ProductDTO entity) {
if ( entity == null ) {
return null;
}
Product product = new Product();
product.setName( entity.getName() );
product.setQuantity( entity.getQuantity() );
product.setDescription( jsonNullableMapper.unwrap( entity.getDescription() ) );
product.setManufacturer( jsonNullableMapper.unwrap( entity.getManufacturer() ) );
return product;
}
@Override
public ProductDTO map(Product entity) {
if ( entity == null ) {
return null;
}
ProductDTO productDTO = new ProductDTO();
productDTO.setId( entity.getId() );
productDTO.setName( entity.getName() );
productDTO.setQuantity( entity.getQuantity() );
productDTO.setDescription( jsonNullableMapper.wrap( entity.getDescription() ) );
productDTO.setManufacturer( jsonNullableMapper.wrap( entity.getManufacturer() ) );
return productDTO;
}
@Override
public void update(ProductDTO update, Product destination) {
if ( update == null ) {
return;
}
if ( update.getName() != null ) {
destination.setName( update.getName() );
}
if ( update.getQuantity() != null ) {
destination.setQuantity( update.getQuantity() );
}
if ( update.getDescription() != null ) {
destination.setDescription( jsonNullableMapper.unwrap( update.getDescription() ) );
}
if ( update.getManufacturer() != null ) {
destination.setManufacturer( jsonNullableMapper.unwrap( update.getManufacturer() ) );
}
}
}
Let’s create some tests to check if mapper.update(...)
works as expected:
- When all fields are present, should update all of them, except
id
. - When explicit
null
is passed to the nullable field (JsonNullable.of(null)
), it should update the field withnull
- When the field is
null
and is not wrapped withJsonNullable
, should not update the field - When the nullable field is not present (
JsonNullable.undefined()
) should not update the field.
@Test // 1 - success
void should_update_all_entities_in_product_except_id() {
ProductDTO update = new ProductDTO(3L, "Updated name", 2, JsonNullable.of("Updated description"), JsonNullable.of("UpdateCompany"));
Product destination = new Product(1L, "RTX3080", 0, "Great GPU", "NVIDIA");
Product expected = new Product(1L, "Updated name", 2, "Updated description", "UpdateCompany");
mapper.update(update, destination);
assertEquals(expected.getId(), destination.getId());
assertEquals(expected.getDescription(), destination.getDescription());
assertEquals(expected.getManufacturer(), destination.getManufacturer());
assertEquals(expected.getName(), destination.getName());
assertEquals(expected.getQuantity(), destination.getQuantity());
}
@Test // 2 - success
void should_update_only_nullable_fields_in_product() {
ProductDTO update = new ProductDTO(1L, null, null, JsonNullable.of(null), JsonNullable.of(null));
Product destination = new Product(1L, "RTX3080", 0, "Great GPU", "NVIDIA");
Product expected = new Product(1L, "RTX3080", 0, null, null);
mapper.update(update, destination);
assertEquals(expected.getId(), destination.getId());
assertEquals(expected.getDescription(), destination.getDescription());
assertEquals(expected.getManufacturer(), destination.getManufacturer());
assertEquals(expected.getName(), destination.getName());
assertEquals(expected.getQuantity(), destination.getQuantity());
}
@Test // 3 - success
void should_not_update_any_field_in_product_2() {
ProductDTO update = new ProductDTO(null, null, null, null, null);
Product destination = new Product(1L, "RTX3080", 0, "Great GPU", "NVIDIA");
Product expected = new Product(1L, "RTX3080", 0, "Great GPU", "NVIDIA");
mapper.update(update, destination);
assertEquals(expected.getId(), destination.getId());
assertEquals(expected.getDescription(), destination.getDescription());
assertEquals(expected.getManufacturer(), destination.getManufacturer());
assertEquals(expected.getName(), destination.getName());
assertEquals(expected.getQuantity(), destination.getQuantity());
}
@Test // 4 - failure
void should_not_update_any_field_in_product() {
ProductDTO update = new ProductDTO(null, null, null, JsonNullable.undefined(), JsonNullable.undefined());
Product destination = new Product(1L, "RTX3080", 0, "Great GPU", "NVIDIA");
Product expected = new Product(1L, "RTX3080", 0, "Great GPU", "NVIDIA");
mapper.update(update, destination);
assertEquals(expected.getId(), destination.getId()); // org.opentest4j.AssertionFailedError: Expected:"Great GPU" Actual:null
assertEquals(expected.getDescription(), destination.getDescription());
assertEquals(expected.getManufacturer(), destination.getManufacturer());
assertEquals(expected.getName(), destination.getName());
assertEquals(expected.getQuantity(), destination.getQuantity());
}
All of our tests work except test number 4.
It looks like our mapper does not handle JsonNullable.undefined()
properly.
The problem is that mapper assumed that if the field is not null
it should update it.
In our case JsonNullable.undefined()
is not null
indeed,
but it also has no value present, so it evaluates to null
anyway.
In the case of the JsonNullable
field, mapper should check whether
the value isPresent()
.
So instead of:
if ( update.getDescription() != null ) {
destination.setDescription( jsonNullableMapper.unwrap( update.getDescription() ) );
}
we want:
if ( update.getDescription() != null && update.getDescription().isPresent()) {
destination.setDescription( jsonNullableMapper.unwrap( update.getDescription() ) );
}
Fortunately, mapstruct
can do this very easily.
We just need to create a new method annotated with @Condition
in our JsonNullableMapper
:
package pl.kdrozd.examples.mapper;
import org.mapstruct.Condition;
import org.mapstruct.Mapper;
import org.mapstruct.Named;
import org.openapitools.jackson.nullable.JsonNullable;
@Mapper(componentModel = "spring")
public interface JsonNullableMapper {
default <T> JsonNullable<T> wrap(T entity) {
return JsonNullable.of(entity);
}
default <T> T unwrap(JsonNullable<T> jsonNullable) {
return jsonNullable == null ? null : jsonNullable.orElse(null);
}
/**
* Checks whether nullable parameter was passed explicitly.
* @return true if value was set explicitly, false otherwise
*/
@Condition
default <T> boolean isPresent(JsonNullable<T> nullable) {
return nullable != null && nullable.isPresent();
}
}
Mapstruct
generated exactly what we need and now the 4th test passes.
@Override // generated by mapstruct
public void update(ProductDTO update, Product destination) {
if ( update == null ) {
return;
}
if ( update.getName() != null ) {
destination.setName( update.getName() );
}
if ( update.getQuantity() != null ) {
destination.setQuantity( update.getQuantity() );
}
if ( jsonNullableMapper.isPresent( update.getDescription() ) ) {
destination.setDescription( jsonNullableMapper.unwrap( update.getDescription() ) );
}
if ( jsonNullableMapper.isPresent( update.getManufacturer() ) ) {
destination.setManufacturer( jsonNullableMapper.unwrap( update.getManufacturer() ) );
}
}
Summary
In this article, I have presented you how to implement a partial update
with JsonNullable
and mapstruct
.
The source code can be found on my Github.