6

I have an app that uses Gson to serialize/deserialize data classes and persist data between runs.

My code uses @NonNull annotations for many fields/parameters/method returns and one thing that was What if I (or another developer) adds a new @NonNull annotated field (or even just annotates an existing field) to a data class and Gson sets it to null because the JSON was saved in a previous version of the app?

Well I answered that question today. As the user guide says and my tests confirm:

  • This implementation handles nulls correctly
    • While serialization, a null field is skipped from the output
    • While deserialization, a missing entry in JSON results in setting the corresponding field in the object to null

Indeed, if I add a new field to my data class, it might be initialized to null.

So I looked for a solution. I found that if I add a private no-args constructor to the data class, there is enough information for the IDE to flag that the field could indeed be null. An example of this implementation is listed below.

public void testDataClass1() throws Exception {
    int value1 = 23;
    int value2 = 48;
    final DataClass1Version1 inputClass = new DataClass1Version1(value1, value2);
    final String dataClass1Json = getGson().toJson(inputClass);
    final DataClass1Version2 outputClass = getGson().fromJson(dataClass1Json, DataClass1Version2.class);
    assertEquals(outputClass.getValue1(), value1);
    assertEquals(outputClass.getValue2(), value2);
    assertNotNull("New @NonNull field shouldn't be null.", outputClass.getSomeNewValue());
}

DataClass1Version1.java:

public class DataClass1Version1 {
    private int value1;
    private int value2;

    public DataClass1Version1(int value1, int value2) {
        this.value1 = value1;
        this.value2 = value2;
    }

    public int getValue1() {
        return value1;
    }

    public void setValue1(int value1) {
        this.value1 = value1;
    }

    public int getValue2() {
        return value2;
    }

    public void setValue2(int value2) {
        this.value2 = value2;
    }

    @Override
    public String toString() {
        return "DataClass1Version1{" +
                "value1=" + value1 +
                ", value2=" + value2 +
                '}';
    }
}

DataClass1Version2.java:

import android.support.annotation.NonNull;
import android.support.annotation.Nullable;

public class DataClass1Version2 {
    private int value1;
    private int value2;
    @Nullable
    private String someNullableString;
    //@NonNull
    //private String someNewValue; //you'll get a warning for not initializing this value
    @NonNull
    private String someNewValue = ""; //if you don't initialize this field here, it could be null after json deserialization

    private DataClass1Version2() { 
        //adding this constructor caused a warning when someNewValue didn't have an initializer
    }

    public DataClass1Version2(int value1, int value2, @Nullable String someNullableString, @NonNull String someNewValue) {
        this.value1 = value1;
        this.value2 = value2;
        this.someNullableString = someNullableString;
        this.someNewValue = someNewValue;
    }

    public int getValue1() {
        return value1;
    }

    public void setValue1(int value1) {
        this.value1 = value1;
    }

    public int getValue2() {
        return value2;
    }

    public void setValue2(int value2) {
        this.value2 = value2;
    }

    @Nullable
    public String getSomeNullableString() {
        return someNullableString;
    }

    public void setSomeNullableString(@Nullable String someNullableString) {
        this.someNullableString = someNullableString;
    }

    @NonNull
    public String getSomeNewValue() {
        return someNewValue;
    }

    public void setSomeNewValue(@NonNull String someNewValue) {
        this.someNewValue = someNewValue;
    }

    @Override
    public String toString() {
        return "DataClass1Version2{" +
                "value1=" + value1 +
                ", value2=" + value2 +
                ", someNullableString='" + someNullableString + '\'' +
                ", someNewValue='" + someNewValue + '\'' +
                '}';
    }
}

The problem with this solution is that I generally use final fields in my data classes and as such, a no-args constructor won't work. What should I do?

While writing this up, I thought of just initializing to some default values and then I guess since Gson uses reflection, it will overwrite those defaults, even though they are final fields. I still want to know what others think. Does this add extra weight to deserialization; if I have a bunch of ArrayLists and I create new instances in the no-args constructor, won't the new instances be instantly replaced with the instance Gson generated?

Here's DataClass2Version2 with final fields and default initializers:

import android.support.annotation.NonNull;
import android.support.annotation.Nullable;

public class DataClass2Version2 {
    private final int value1;
    private final int value2;
    @Nullable
    private final String someNullableString;
    @NonNull
    private final String someNewValue;

    private DataClass2Version2() {
        value1 = 0;
        value2 = 0;
        someNullableString = null;
        someNewValue = "";
    }

    public DataClass2Version2(int value1, int value2, @Nullable String someNullableString, @NonNull String someNewValue) {
        this.value1 = value1;
        this.value2 = value2;
        this.someNullableString = someNullableString;
        this.someNewValue = someNewValue;
    }

    public int getValue1() {
        return value1;
    }

    public int getValue2() {
        return value2;
    }

    @Nullable
    public String getSomeNullableString() {
        return someNullableString;
    }

    @NonNull
    public String getSomeNewValue() {
        return someNewValue;
    }

    @Override
    public String toString() {
        return "DataClass2Version2{" +
                "value1=" + value1 +
                ", value2=" + value2 +
                ", someNullableString='" + someNullableString + '\'' +
                ", someNewValue='" + someNewValue + '\'' +
                '}';
    }
}
2

If there are files in the wild which do not contain the field (or which have the field as null) and which you need to parse, then by definition the field is nullable. @NotNull is inappropriate in such cases.

So what can you do?

For starters, you can put a version number at the top level of the file, like this:

{
 "version": 1,
 ...other fields...
}

Then, you have one class for parsing version 1 files, one class for version 2 files, and so on. You throw nullable fields onto the old classes and non-nullable fields onto the new classes. Then you just need a common interface and (perhaps) a method which converts old classes into the newest class.

This has an important downside: if you do this a lot, you will end up with a lot of classes. Unfortunately, the best advice I can offer is "don't do this a lot." The fact is, schema changes are difficult once you have live (production) data. It does not matter if you're using SQL, JSON, or something entirely different. You have to be prepared to deal with legacy data in one way or another, and that will always be challenging, regardless of how you go about it.

1

You are looking at the technological solution, but really you need to look at the data.

If you expect a field ABC with a string value, then in your JSON data you can have: Nothing (the field isn't there), null (JSON contains null), an empty string (JSON contains an empty string), any old rubbish (maybe a number, maybe an array), or a non-empty string.

You need to decide how that data should be interpreted. Depending on what you receive for ABC, you can decide that this makes the whole record invalid (for example if there is an array where you expected a string), you might convert a number into a string, you might accept no value or null as an empty string, you might accept no value or null as a null object.

That's the real decision that you need to make. Of course if you declare ABC as @nonnull then any interpretation that makes ABC a null value isn't possible. If your JSON data contains nothing or a null value, you can only decide to interpret this as an empty string, or as an invalid record, since interpreting as a null value isn't possible anymore.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.