Search code examples
serializationrangegsonguavarangeset

Create a Gson TypeAdapter for a Guava Range


I am trying to serialize Guava Range objects to JSON using Gson, however the default serialization fails, and I'm unsure how to correctly implement a TypeAdapter for this generic type.

Gson gson = new Gson();
Range<Integer> range = Range.closed(10, 20);
String json = gson.toJson(range);
System.out.println(json);
Range<Integer> range2 = gson.fromJson(json, 
                            new TypeToken<Range<Integer>>(){}.getType());
System.out.println(range2);
assertEquals(range2, range);

This fails like so:

{"lowerBound":{"endpoint":10},"upperBound":{"endpoint":20}}
PASSED: typeTokenInterface
FAILED: range
java.lang.RuntimeException: Unable to invoke no-args constructor for
        com.google.common.collect.Cut<java.lang.Integer>. Register an
        InstanceCreator with Gson for this type may fix this problem.
    at com.google.gson.internal.ConstructorConstructor$12.construct(
        ConstructorConstructor.java:210)
    ...

Note that the default serialization actually loses information - it fails to report whether the endpoints are open or closed. I would prefer to see it serialized similar to its toString(), e.g. [10‥20] however simply calling toString() won't work with generic Range instances, as the elements of the range may not be primitives (Joda-Time LocalDate instances, for example). For the same reason, implementing a custom TypeAdapter seems difficult, as we don't know how to deserialize the endpoints.

I've implemented most of a TypeAdaptorFactory based on the template provided for Multimap which ought to work, but now I'm stuck on the generics. Here's what I have so far:

public class RangeTypeAdapterFactory implements TypeAdapterFactory {
  public <T> TypeAdapter<T> create(Gson gson, TypeToken<T> typeToken) {
    Type type = typeToken.getType();
    if (typeToken.getRawType() != Range.class
        || !(type instanceof ParameterizedType)) {
      return null;
    }

    Type elementType = ((ParameterizedType) type).getActualTypeArguments()[0];
    TypeAdapter<?> elementAdapter = (TypeAdapter<?>)gson.getAdapter(TypeToken.get(elementType));
    // Bound mismatch: The generic method newRangeAdapter(TypeAdapter<E>) of type
    // GsonUtils.RangeTypeAdapterFactory is not applicable for the arguments
    // (TypeAdapter<capture#4-of ?>). The inferred type capture#4-of ? is not a valid
    // substitute for the bounded parameter <E extends Comparable<?>>
    return (TypeAdapter<T>) newRangeAdapter(elementAdapter);
  }

  private <E extends Comparable<?>> TypeAdapter<Range<E>> newRangeAdapter(final TypeAdapter<E> elementAdapter) {
    return new TypeAdapter<Range<E>>() {
      @Override
      public void write(JsonWriter out, Range<E> value) throws IOException {
        if (value == null) {
          out.nullValue();
          return;
        }

        String repr = (value.lowerBoundType() == BoundType.CLOSED ? "[" : "(") +
                      (value.hasLowerBound() ? elementAdapter.toJson(value.lowerEndpoint()) : "-\u221e") +
                      '\u2025' +
                      (value.hasLowerBound() ? elementAdapter.toJson(value.upperEndpoint()) : "+\u221e") +
                      (value.upperBoundType() == BoundType.CLOSED ? "]" : ")");
        out.value(repr);
      }

      public Range<E> read(JsonReader in) throws IOException {
        if (in.peek() == JsonToken.NULL) {
          in.nextNull();
          return null;
        }

        String[] endpoints = in.nextString().split("\u2025");
        E lower = elementAdapter.fromJson(endpoints[0].substring(1));
        E upper = elementAdapter.fromJson(endpoints[1].substring(0,endpoints[1].length()-1));

        return Range.range(lower, endpoints[0].charAt(0) == '[' ? BoundType.CLOSED : BoundType.OPEN,
                           upper, endpoints[1].charAt(endpoints[1].length()-1) == '[' ? BoundType.CLOSED : BoundType.OPEN);
      }
    };
  }
}

However the return (TypeAdapter<T>) newRangeAdapter(elementAdapter); line has a compilation error and I'm now at a loss.

What's the best way to resolve this error? Is there a better way to serialize Range objects that I'm missing? What about if I want to serialize RangeSets?

Rather frustrating that the Google utility library and Google serialization library seem to require so much glue to work together :(


Solution

  • This feels somewhat like reinventing the wheel, but it was a lot quicker to put together and test than the time spent trying to get Gson to behave, so at least presently I'll be using the following Converters to serialize Range and RangeSet*, rather than Gson.

    /**
     * Converter between Range instances and Strings, essentially a custom serializer.
     * Ideally we'd let Gson or Guava do this for us, but presently this is cleaner.
     */
    public static <T extends Comparable<? super T>> Converter<Range<T>, String> rangeConverter(final Converter<T, String> elementConverter) {
      final String NEG_INFINITY = "-\u221e";
      final String POS_INFINITY = "+\u221e";
      final String DOTDOT = "\u2025";
      return new Converter<Range<T>, String>() {
        @Override
        protected String doForward(Range<T> range) {
          return (range.hasLowerBound() && range.lowerBoundType() == BoundType.CLOSED ? "[" : "(") +
                 (range.hasLowerBound() ? elementConverter.convert(range.lowerEndpoint()) : NEG_INFINITY) +
                 DOTDOT +
                 (range.hasUpperBound() ? elementConverter.convert(range.upperEndpoint()) : POS_INFINITY) +
                 (range.hasUpperBound() && range.upperBoundType() == BoundType.CLOSED ? "]" : ")");
        }
    
        @Override
        protected Range<T> doBackward(String range) {
          String[] endpoints = range.split(DOTDOT);
    
          Range<T> ret = Range.all();
          if(!endpoints[0].substring(1).equals(NEG_INFINITY)) {
            T lower = elementConverter.reverse().convert(endpoints[0].substring(1));
            ret = ret.intersection(Range.downTo(lower, endpoints[0].charAt(0) == '[' ? BoundType.CLOSED : BoundType.OPEN));
          }
          if(!endpoints[1].substring(0,endpoints[1].length()-1).equals(POS_INFINITY)) {
            T upper = elementConverter.reverse().convert(endpoints[1].substring(0,endpoints[1].length()-1));
            ret = ret.intersection(Range.upTo(upper, endpoints[1].charAt(endpoints[1].length()-1) == ']' ? BoundType.CLOSED : BoundType.OPEN));
          }
          return ret;
        }
      };
    }
    
    /**
     * Converter between RangeSet instances and Strings, essentially a custom serializer.
     * Ideally we'd let Gson or Guava do this for us, but presently this is cleaner.
     */
    public static <T extends Comparable<? super T>> Converter<RangeSet<T>, String> rangeSetConverter(final Converter<T, String> elementConverter) {
      return new Converter<RangeSet<T>, String>() {
        private final Converter<Range<T>, String> rangeConverter = rangeConverter(elementConverter);
        @Override
        protected String doForward(RangeSet<T> rs) {
          ArrayList<String> ls = new ArrayList<>();
          for(Range<T> range : rs.asRanges()) {
            ls.add(rangeConverter.convert(range));
          }
          return Joiner.on(", ").join(ls);
        }
    
        @Override
        protected RangeSet<T> doBackward(String rs) {
          Iterable<String> parts = Splitter.on(",").trimResults().split(rs);
          ImmutableRangeSet.Builder<T> build = ImmutableRangeSet.builder();
          for(String range : parts) {
            build.add(rangeConverter.reverse().convert(range));
          }
          return build.build();
        }
      };
    }
    

    *For inter-process communication, Java serialization would likely work just fine, as both classes implement Serializable. However I'm serializing to disk for more permanent storage, meaning I need a format I can trust won't change over time. Guava's serialization doesn't provide that guarantee.