3

I have a rest service that consume json from an Angular UI and also from other rest clients. The data based on a complex structure of entities ~50 that are stored in a database with ~50 tables. The problem are the optional OneToOne relations, because Angular send the optional objects as empty definitions like "car": {},. The spring data repository saves them as empty entries and I got a Json response like "car": {"id": 545234, "version": 0} back. I found no Jackson annotation to ignore empty objects, only empty or null properties.

The Employee Entity has the following form:

@Entity
public class Employee {
  @Id 
  @GeneratedValue
  private Long id;
  
  @Version
  private Long version;

  private String name;

  @OneToOne(cascade = CascadeType.ALL)
  @JoinColumn(name = "car_id")
  @JsonManagedReference
  private Car car;

  .
  .   Desk, getters and setters
  . 
}

and the other side of the OneToOne Reference

@Entity
public class Car{
  @Id
  @GeneratedValue
  private Long id;

  @Version
  private Long version;

  private String name;

  @OneToOne(fetch = FetchType.LAZY, mappedBy = "employee")
  @JsonBackReference
  private Employee employee;


  .
  .   getters and setters
  . 
}

For example, I send this to my service as post operation

{
  "name": "ACME",
      .
      .
      .
  "employee": {
    "name": "worker 1",
    "car": {},
    "desk": {
      floor: 3,
      number: 4,
      phone: 444
    }
      .
      .
      .
  },
  "addresses": [],
  "building": {},
      .
      .
      .
}

and I got as response the saved data

{
  "id": 34534,
  "version": 0,
  "name": "ACME",
      .
      .
      .
  "employee": {
    "id": 34535,
    "version":0,
    "name": "worker 1",
    "car": {"id": 34536, "version": 0},
    "desk": {
      "id": 34538,
      "version":0,
      "floor": 3,
      "number": 4,
      "phone": 444
    }
      .
      .
      .
  },
  "addresses": [],
  "building": {"id": 34539, "version": 0},
      .
      .
      .
}

As seen in the response I got empty table rows with an id, a version, many null values and empty strings, because when I save (persist) the main deserialized company class, the other entity are also saved, because the are annotated as cascading.

I found many examples like Do not include empty object to Jackson , with a concrete pojo and a concrete deserializer that are working, but every entity needs his own Deserializer. This causes many work for the current entities and the new ones in the future (only the optional entities).

I tried the folowing, I write a BeanDeserializerModifier and try to wrap an own deserializer over the standard beandeserializer:

    SimpleModule module = new SimpleModule();
    module.setDeserializerModifier(new BeanDeserializerModifier() {
        @Override
        public List<BeanPropertyDefinition> updateProperties(DeserializationConfig config,
                                                             BeanDescription beanDesc,
                                                             List<BeanPropertyDefinition> propDefs) {
            logger.debug("update properties, beandesc: {}", beanDesc.getBeanClass().getSimpleName());
            return super.updateProperties(config, beanDesc, propDefs);
        }

        @Override
        public JsonDeserializer<?> modifyDeserializer(DeserializationConfig config,
                                                      BeanDescription beanDesc,
                                                      JsonDeserializer<?> deserializer) {
            
            logger.debug("modify deserializer {}",beanDesc.getBeanClass().getSimpleName());
            // This fails:
           // return new DeserializationWrapper(deserializer, beanDesc);
            return deserializer; // This works, but it is the standard behavior
        }
    });

And here is the wrapper (and the mistake):

public class DeserializationWrapper extends JsonDeserializer<Object> {
private static final Logger logger = LoggerFactory.getLogger( DeserializationWrapper.class );

    private final JsonDeserializer<?> deserializer;
    private final BeanDescription beanDesc;

    public DeserializationWrapper(JsonDeserializer<?> deserializer, BeanDescription beanDesc) {
        this.deserializer = deserializer;
        this.beanDesc = beanDesc;
    }

    @Override
    public Object deserialize(JsonParser p, DeserializationContext ctxt) throws IOException, JsonProcessingException {
        logger.debug("deserialize in wrapper {} ",beanDesc.getBeanClass().getSimpleName());
        final Object deserialized = deserializer.deserialize(p, ctxt);
        ObjectCodec codec = p.getCodec();
        JsonNode node = codec.readTree(p);
        
         // some logig that not work
         // here. The Idea is to detect with the json parser that the node is empty.
         // If it is empty I will return null here and not the deserialized pojo

        return deserialized;
    }

    @Override
    public Object deserialize(JsonParser p, DeserializationContext ctxt, Object intoValue) throws IOException {
        logger.debug("deserializer - method 2");
        intoValue = deserializer.deserialize(p, ctxt);
        return intoValue;
    }

    @Override
    public boolean isCachable() {
        return deserializer.isCachable();
    }
  .
  .     I try to wrap the calls to the deserializer
  .

The Deserialization Wrapper does not work and crash after the first call with an exception com.fasterxml.jackson.databind.exc.MismatchedInputException: No _valueDeserializer assigned at [Source: (PushbackInputStream); line: 2, column: 11] (through reference chain: ... Company["name"])

My question: is there a way to extend the behavior of the working standard deserializer in the way, that the deserializer detect while parsing, that the current jsonNode is empty and return null instead the empty class instance? Perhaps my Idea is wrong and there is a completely other solution?

Solving it on the Angular UI side is no option. We use Jackson 2.9.5.

Jochen Buchholz
  • 370
  • 1
  • 16
  • Are you using a spring-boot rest controller? Why do you need a customer deserializer? Can you share a demo GIT repo for a better understanding? – Tushar Dec 03 '20 at 17:02
  • I wrote a POC and pushed it to https://github.com/joe-bookwood/empty-objects-poc – Jochen Buchholz Dec 11 '20 at 00:03

1 Answers1

1

Using BeanDeserializerModifier with custom deserialiser is a good idea. You need to improve only a deserialiser implementation. In your example problem is with these lines:

final Object deserialized = deserializer.deserialize(p, ctxt); //1.
ObjectCodec codec = p.getCodec(); //2.
JsonNode node = codec.readTree(p); //3.

Line 1. reads JSON Object. In lines 2. and 3. you want to create a JsonNode but empty JSON Object was already read in line 1.. Next two lines will try to read rest of payload as JsonNode.

Jackson by default uses BeanDeserializer to deserialise regular POJO classes. We can extend this class and provide our own implementation. In version 2.10.1 deserialise method looks like this:

@Override
public Object deserialize(JsonParser p, DeserializationContext ctxt) throws IOException
{
    // common case first
    if (p.isExpectedStartObjectToken()) {
        if (_vanillaProcessing) {
            return vanillaDeserialize(p, ctxt, p.nextToken());
        }
        // 23-Sep-2015, tatu: This is wrong at some many levels, but for now... it is
        //    what it is, including "expected behavior".
        p.nextToken();
        if (_objectIdReader != null) {
            return deserializeWithObjectId(p, ctxt);
        }
        return deserializeFromObject(p, ctxt);
    }
    return _deserializeOther(p, ctxt, p.getCurrentToken());
}

In most cases, where there is no special treatment needed vanillaDeserialize method will be invoked. Let's look on it:

private final Object vanillaDeserialize(JsonParser p, DeserializationContext ctxt, JsonToken t) throws IOException {
    final Object bean = _valueInstantiator.createUsingDefault(ctxt);
    // [databind#631]: Assign current value, to be accessible by custom serializers
    p.setCurrentValue(bean);
    if (p.hasTokenId(JsonTokenId.ID_FIELD_NAME)) {
        String propName = p.getCurrentName();
        do {
            p.nextToken();
            SettableBeanProperty prop = _beanProperties.find(propName);

            if (prop != null) { // normal case
                try {
                    prop.deserializeAndSet(p, ctxt, bean);
                } catch (Exception e) {
                    wrapAndThrow(e, bean, propName, ctxt);
                }
                continue;
            }
            handleUnknownVanilla(p, ctxt, bean, propName);
        } while ((propName = p.nextFieldName()) != null);
    }
    return bean;
}

As you can see, it does almost everything what we want, except it creates new object even for empty JSON Object. It checks whether field exists just after it creates new objects. One line too far. Unfortunately, this method is private and we can not override it. Let's copy it to our class and modify a little bit:

class EmptyObjectIsNullBeanDeserializer extends BeanDeserializer {

    EmptyObjectIsNullBeanDeserializer(BeanDeserializerBase src) {
        super(src);
    }

    @Override
    public Object deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
        if (_vanillaProcessing) {
            return vanillaDeserialize(p, ctxt);
        }

        return super.deserialize(p, ctxt);
    }

    private Object vanillaDeserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
        p.nextToken();
        if (p.hasTokenId(JsonTokenId.ID_FIELD_NAME)) {
            final Object bean = _valueInstantiator.createUsingDefault(ctxt);
            // [databind#631]: Assign current value, to be accessible by custom serializers
            p.setCurrentValue(bean);
            String propName = p.getCurrentName();
            do {
                p.nextToken();
                SettableBeanProperty prop = _beanProperties.find(propName);

                if (prop != null) { // normal case
                    try {
                        prop.deserializeAndSet(p, ctxt, bean);
                    } catch (Exception e) {
                        wrapAndThrow(e, bean, propName, ctxt);
                    }
                    continue;
                }
                handleUnknownVanilla(p, ctxt, bean, propName);
            } while ((propName = p.nextFieldName()) != null);
            return bean;
        }
        return null;
    }
}

You can register it like below:

class EmptyObjectIsNullBeanDeserializerModifier extends BeanDeserializerModifier {
    @Override
    public JsonDeserializer<?> modifyDeserializer(DeserializationConfig config, BeanDescription beanDesc, JsonDeserializer<?> deserializer) {
        if (beanDesc.getBeanClass() == Car.class) { //TODO: change this condition
            return new EmptyObjectIsNullBeanDeserializer((BeanDeserializerBase) deserializer);
        }
        return super.modifyDeserializer(config, beanDesc, deserializer);
    }
}

Simple POC:

import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.core.JsonTokenId;
import com.fasterxml.jackson.databind.BeanDescription;
import com.fasterxml.jackson.databind.DeserializationConfig;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JsonDeserializer;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.deser.BeanDeserializer;
import com.fasterxml.jackson.databind.deser.BeanDeserializerBase;
import com.fasterxml.jackson.databind.deser.BeanDeserializerModifier;
import com.fasterxml.jackson.databind.deser.SettableBeanProperty;
import com.fasterxml.jackson.databind.module.SimpleModule;
import lombok.Data;

import java.io.File;
import java.io.IOException;

public class EmptyObjectIsNullApp {

    public static void main(String[] args) throws IOException {
        File jsonFile = new File("./resource/test.json").getAbsoluteFile();

        SimpleModule emptyObjectIsNullModule = new SimpleModule();
        emptyObjectIsNullModule.setDeserializerModifier(new EmptyObjectIsNullBeanDeserializerModifier());

        ObjectMapper mapper = new ObjectMapper();
        mapper.registerModule(emptyObjectIsNullModule);

        Wrapper wrapper = mapper.readValue(jsonFile, Wrapper.class);
        System.out.println(wrapper);
    }
}

@Data
class Wrapper {
    private Car car;
}

@Data
class Car {
    private int id;
}

For "empty object" JSON payload:

{
  "car": {}
}

Above code prints:

Wrapper(car=null)

For JSON payload with some fields:

{
  "car": {
    "id": 1
  }
}

Above code prints:

Wrapper(car=Car(id=1))
Michał Ziober
  • 37,175
  • 18
  • 99
  • 146
  • 1
    The extension of the Beandeserializer is the magic. Great - no error anymore. I write an isEmpty interface for my entity classes. So I can in every entity define what is empty and all OnToOne entities are marked on this way. I will inform you here with my results when I'm ready. Thx!! – Jochen Buchholz Dec 04 '20 at 17:34
  • 1
    "isEmpty interface" is much coherent with `OOP`. It is also a good idea nad maybe much reliable in this case. Tweaking libraries like `Jackson` sometimes is really hard and should be done with a care. – Michał Ziober Dec 04 '20 at 17:39
  • 1
    In my case, `vanillaDeserialize` ist never called and `__vanillaProcessing` is always `false`, in the original `BeanDeserializer`. In my case the `BeanDeserializer` jump into `deserializeFromObject`. I start to analyse it now. I also kicked away the isEmpty Aproach, it is to complex. I think it is easier to depend it on the zero count of properties(children). – Jochen Buchholz Dec 07 '20 at 15:05
  • @JochenBuchholz, do you use any annotations with `POJO` classes? Sometimes extra annotation could change behavior and vanilla code is not used - special treatment is needed. Could you create `POC` test which shows the problem? – Michał Ziober Dec 07 '20 at 16:03
  • 1
    I added `Employee` and the `Car` entities with a bidirectional OneToOne relation. I use `@JsonManagedReference` and `@JsonBackreference` in my bidirectional relations. This is needed to prevent endless loops, but when I think about, this can be the reason that Jackson use the `deserializeFromObject`. – Jochen Buchholz Dec 07 '20 at 17:52
  • 1
    @JochenBuchholz, it looks like we need to override more methods: including `deserializeFromObject`. We can check the same condition as in vanilla method. Add this to `EmptyObjectIsNullBeanDeserializer` class: `public Object deserializeFromObject(JsonParser p, DeserializationContext ctxt) throws IOException { if (p.hasTokenId(JsonTokenId.ID_FIELD_NAME)) { return super.deserializeFromObject(p, ctxt);}return null;}`. – Michał Ziober Dec 07 '20 at 23:14
  • 1
    The POC is ready now and I pushed it to github. @MichalZober you find it in my question or in the link list. It's a maven project, all instructions how to build are in the README.md. I use the newest Spring Boot version - so it differ from the versions at my company. https://github.com/joe-bookwood/empty-objects-poc – Jochen Buchholz Dec 10 '20 at 20:28
  • 1
    This solution works when I set `_vanillaProcessing = true;`. I call this modified deserializer only for the OneToOne classes. I updated the POC. – Jochen Buchholz Dec 16 '20 at 20:00
  • @JochenBuchholz, I'm glad to hear that! Sorry, I did not have time to take a look on `POC` project you made. If I find a better solution I will let you know. But setting `_vanillaProcessing` seems as a good idea. – Michał Ziober Dec 16 '20 at 20:13
  • 1
    I extended the solution with a self written reflection isEmpty method, to detect also nested empty objects like `"employee" : { "car" : {} }` you find it [here](https://github.com/joe-bookwood/empty-objects-poc/blob/master/src/main/java/de/bitc/jackson/IsEmptyDeserializationWrapper.java) – Jochen Buchholz Dec 21 '20 at 20:44