Search code examples
javajsonserializationgson

Gson serialize null only if value equals a specific pattern


Imagine I have an object that looks like this:

@Getter
@Setter
static class MyObject {
    private String firstName;
    private String lastName;
    private long salary;
}

and then I have:

private static final String KEEP_ME = "$$$___KEEP_ME___$$$";

MyObject obj = new MyObject();
obj.setFirstName(KEEP_ME);
obj.setLastName(null);
obj.setSalary(1000);

I want to create a custom Serializer to remove any null and keep the fields that equals KEEP_ME with a null value, the result I expect should looks like this:

{"firstName":null,"salary":1000}

My Serializer looks like:

static class NullStringSerializer implements JsonSerializer<String> {

    private static final String KEEP_ME = "$$$___KEEP_ME___$$$";

    @Override
    public JsonElement serialize(String src, Type typeOfSrc, JsonSerializationContext context) {
        if (src == null) {
            return JsonNull.INSTANCE;
        }

        if (KEEP_ME.equals(src)) {
            return JsonNull.INSTANCE;
        }

        return context.serialize(src, typeOfSrc);
    }
}

and

Gson gsonBuilder = new GsonBuilder()
                .registerTypeAdapter(String.class, new NullStringSerializer())
                .create();
gsonBuilder.toJson(obj);

but this give me:

{"salary":1000}

Any solution for this situation please ?


Solution

  • You can't do that with the custom value adapter like NullStringSerializer, because the Gson internal DTO-classes like MyObject type adapter's null-write policy allows either all nulls, or no nulls. Both controlled with a single flag set using GsonBuilder#serializeNulls(). The ExclusionStrategy interface won't help either because it's value-unaware. And that sucks.

    I encountered your string marker approach in frameworks like the Spring Framework, where the markers were used in annotations that are not allowed to contain null values, and that utilized actually the three-state approach just like your example suggests:

    • firstName is always defined regardless of having a value or null
    • lastName may be undefined because it might be encountered or not in the MyObject serialized payload

    And this is totally okay (and I don't see any reason of why the question has been downvoted). To my knowledge, the only two ways to implement the three-state approach is:

    • writing custom serializers for each class like MyObject (and that sucks too)
    • or:
      • removing the marker string KEEP_ME
      • introducing an existential type (the covering word suggested by Gemini) with something like Existential.value(...), Existential.nullValue() and Existential.undefinedValue() and then wrapping all three-state fields
        • or using Optional.of(...) (for values), Optinal.empty() (for null) and Java nulls for undefined
      • avoiding serializing null fields at any cost (controlled by GsonBuilder#serializeNulls() that sets the emit-nulls policy once invoked)
      • writing the raw null token to the JSON output stream for Existential.nullValue() or Optional.empty() (or Optional.ofNullable(null)).

    If you're okay with the second approach, it might be implemented like this:

    @AllArgsConstructor(access = AccessLevel.PRIVATE)
    public final class ExistentialTypeAdapter
            implements TypeAdapterFactory {
    
        @Getter
        private static final TypeAdapterFactory instance = new ExistentialTypeAdapter();
    
        @Override
        @Nullable
        public <T> TypeAdapter<T> create(final Gson gson, final TypeToken<T> typeToken) {
            if ( !Optional.class.isAssignableFrom(typeToken.getRawType()) ) {
                return null;
            }
            final ParameterizedType parameterizedType = (ParameterizedType) typeToken.getType();
            final Type optionalTypeArgument = parameterizedType.getActualTypeArguments()[0];
            final TypeAdapter<?> backingTypeAdapter = gson.getDelegateAdapter(this, TypeToken.get(optionalTypeArgument));
            @SuppressWarnings("unchecked")
            final TypeAdapter<T> adapter = (TypeAdapter<T>) new Adapter<>(backingTypeAdapter);
            return adapter;
        }
    
        @AllArgsConstructor(access = AccessLevel.PRIVATE)
        private static final class Adapter<T>
                extends TypeAdapter<Optional<T>> {
    
            private final TypeAdapter<T> backingTypeAdapter;
    
            @Override
            public Optional<T> read(final JsonReader in) {
                throw new UnsupportedOperationException();
            }
    
            @Override
            public void write(final JsonWriter out, final Optional<T> value)
                    throws IOException {
                if ( value.isEmpty() ) {
                    out.jsonValue("null"); // NOTE (!) it's NOT a string, but emits the `null` token
                    return;
                }
                backingTypeAdapter.write(out, value.get());
            }
    
        }
    
    }
    
    public final class ExistentialTypeAdapterTest {
    
        @Getter
        @Setter
        private static class MyObject {
    
            @Nullable
            @SuppressWarnings("OptionalUsedAsFieldOrParameterType")
            private Optional<String> firstName;
    
            @Nullable
            private String lastName;
    
            private long salary;
    
        }
    
        private static final Gson gson = new GsonBuilder()
                // DO NOT uncomment: .serializeNulls()
                .registerTypeAdapterFactory(ExistentialTypeAdapter.getInstance())
                .create();
    
        @Test
        public void test() {
            final MyObject obj = new MyObject();
            obj.setFirstName(Optional.empty()); // this would stand for "null" (the has-property state with explicit null value)
            obj.setLastName(null); // this would stand for "undefined" (the has-no-property state in the result JSON object)
            obj.setSalary(1000);
            System.out.println(gson.toJson(obj));
        }
    
    }
    

    The output:

    {"firstName":null,"salary":1000}
    

    Also, I do recommend using type adapters instead for performance reasons.